mirror of
https://github.com/apache/superset.git
synced 2026-07-29 10:02:32 +00:00
Compare commits
17 Commits
fix-bignum
...
fix/embedd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
97b6465ece | ||
|
|
4f2fe42044 | ||
|
|
87be424f9c | ||
|
|
4d95a8d034 | ||
|
|
2d6e68b5f2 | ||
|
|
2e7bec3646 | ||
|
|
f4787a4f25 | ||
|
|
fa4e571db5 | ||
|
|
838ee27c29 | ||
|
|
7f54b0b13d | ||
|
|
f165c3fa78 | ||
|
|
8c6271e9ff | ||
|
|
44a8d9d469 | ||
|
|
d350792d43 | ||
|
|
c9136af8b6 | ||
|
|
f0838353a5 | ||
|
|
16b56873b0 |
2
.github/actions/setup-backend/action.yml
vendored
2
.github/actions/setup-backend/action.yml
vendored
@@ -36,7 +36,7 @@ runs:
|
||||
echo "PYTHON_VERSION=${{ inputs.python-version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
1
.github/actions/setup-supersetbot/action.yml
vendored
1
.github/actions/setup-supersetbot/action.yml
vendored
@@ -23,6 +23,7 @@ runs:
|
||||
if: ${{ inputs.from-npm == 'false' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: apache-superset/supersetbot
|
||||
path: supersetbot
|
||||
|
||||
|
||||
60
.github/dependabot.yml
vendored
60
.github/dependabot.yml
vendored
@@ -10,7 +10,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
@@ -59,7 +59,7 @@ updates:
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
@@ -76,7 +76,7 @@ updates:
|
||||
- pip
|
||||
- dependabot
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: ".github/actions"
|
||||
@@ -85,7 +85,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
@@ -110,7 +110,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
@@ -121,7 +121,7 @@ updates:
|
||||
- dependabot
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
@@ -133,7 +133,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
# Now for all of our plugins and packages!
|
||||
|
||||
@@ -147,7 +147,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
|
||||
@@ -159,7 +159,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
|
||||
@@ -171,7 +171,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
|
||||
@@ -186,7 +186,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
|
||||
@@ -198,7 +198,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
|
||||
@@ -210,7 +210,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
|
||||
@@ -222,7 +222,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
|
||||
@@ -234,7 +234,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-table/"
|
||||
@@ -249,7 +249,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
|
||||
@@ -261,7 +261,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
|
||||
@@ -273,7 +273,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
|
||||
@@ -285,7 +285,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
|
||||
@@ -297,7 +297,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
|
||||
@@ -309,7 +309,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
|
||||
@@ -321,7 +321,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
|
||||
@@ -333,7 +333,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
|
||||
@@ -345,7 +345,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
|
||||
@@ -357,7 +357,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
|
||||
@@ -373,7 +373,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/generator-superset/"
|
||||
@@ -385,7 +385,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
|
||||
@@ -397,7 +397,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-core/"
|
||||
@@ -414,7 +414,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-switchboard/"
|
||||
@@ -426,4 +426,4 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 5
|
||||
default-days: 7
|
||||
|
||||
43
.github/workflows/cancel_duplicates.yml
vendored
43
.github/workflows/cancel_duplicates.yml
vendored
@@ -1,43 +0,0 @@
|
||||
name: Cancel Duplicates
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Miscellaneous"
|
||||
types:
|
||||
- requested
|
||||
|
||||
jobs:
|
||||
cancel-duplicate-runs:
|
||||
name: Cancel duplicate workflow runs
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check number of queued tasks
|
||||
id: check_queued
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
get_count() {
|
||||
echo $(curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/$GITHUB_REPO/actions/runs?status=$1" | \
|
||||
jq ".total_count")
|
||||
}
|
||||
count=$(( `get_count queued` + `get_count in_progress` ))
|
||||
echo "Found $count unfinished jobs."
|
||||
echo "count=$count" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
if: steps.check_queued.outputs.count >= 20
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Cancel duplicate workflow runs
|
||||
if: steps.check_queued.outputs.count >= 20
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
pip install click requests typing_extensions python-dateutil
|
||||
python ./scripts/cancel_github_workflows.py
|
||||
@@ -26,6 +26,8 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check and notify
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
4
.github/workflows/claude.yml
vendored
4
.github/workflows/claude.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-permissions:
|
||||
if: |
|
||||
@@ -75,6 +78,7 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
|
||||
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -32,6 +32,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
|
||||
4
.github/workflows/dependency-review.yml
vendored
4
.github/workflows/dependency-review.yml
vendored
@@ -28,6 +28,8 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
continue-on-error: true
|
||||
@@ -50,6 +52,8 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
2
.github/workflows/embedded-sdk-release.yml
vendored
2
.github/workflows/embedded-sdk-release.yml
vendored
@@ -34,6 +34,8 @@ jobs:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-embedded-sdk/.nvmrc'
|
||||
|
||||
2
.github/workflows/embedded-sdk-test.yml
vendored
2
.github/workflows/embedded-sdk-test.yml
vendored
@@ -22,6 +22,8 @@ jobs:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-embedded-sdk/.nvmrc'
|
||||
|
||||
83
.github/workflows/ephemeral-env-pr-close.yml
vendored
83
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -1,83 +0,0 @@
|
||||
name: Cleanup ephemeral envs (PR close) [DEPRECATED]
|
||||
|
||||
# ⚠️ DEPRECATION NOTICE ⚠️
|
||||
# This workflow is deprecated and will be removed in a future version.
|
||||
# The new Superset Showtime workflow handles cleanup automatically.
|
||||
# See .github/workflows/showtime.yml and showtime-cleanup.yml for replacements.
|
||||
# Migration guide: https://github.com/mistercrunch/superset-showtime
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
has-secrets: ${{ steps.check.outputs.has-secrets }}
|
||||
steps:
|
||||
- name: "Check for secrets"
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${AWS_ACCESS_KEY_ID}" ]; then
|
||||
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ (secrets.AWS_ACCESS_KEY_ID != '' && secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}
|
||||
ephemeral-env-cleanup:
|
||||
needs: config
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: Cleanup ephemeral envs
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Describe ECS service
|
||||
id: describe-services
|
||||
run: |
|
||||
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Delete ECS service
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: delete-service
|
||||
run: |
|
||||
aws ecs delete-service \
|
||||
--cluster superset-ci \
|
||||
--service pr-${{ github.event.number }}-service \
|
||||
--force
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: delete-image-tag
|
||||
run: |
|
||||
aws ecr batch-delete-image \
|
||||
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
|
||||
--repository-name superset-ci \
|
||||
--image-ids imageTag=pr-${{ github.event.number }}
|
||||
|
||||
- name: Comment (success)
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ github.event.number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '⚠️ **DEPRECATED WORKFLOW** - Ephemeral environment shutdown and build artifacts deleted. Please migrate to the new Superset Showtime system for future PRs.'
|
||||
})
|
||||
350
.github/workflows/ephemeral-env.yml
vendored
350
.github/workflows/ephemeral-env.yml
vendored
@@ -1,350 +0,0 @@
|
||||
name: Ephemeral env workflow [DEPRECATED]
|
||||
|
||||
# ⚠️ DEPRECATION NOTICE ⚠️
|
||||
# This workflow is deprecated and will be removed in a future version.
|
||||
# Please use the new Superset Showtime workflow instead:
|
||||
# - Use label "🎪 trigger-start" instead of "testenv-up"
|
||||
# - Showtime provides better reliability and easier management
|
||||
# - See .github/workflows/showtime.yml for the replacement
|
||||
# - Migration guide: https://github.com/mistercrunch/superset-showtime
|
||||
|
||||
# Example manual trigger:
|
||||
# gh workflow run ephemeral-env.yml --ref fix_ephemerals --field label_name="testenv-up" --field issue_number=666
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
label_name:
|
||||
description: 'Label name to simulate label-based /testenv trigger'
|
||||
required: true
|
||||
default: 'testenv-up'
|
||||
issue_number:
|
||||
description: 'Issue or PR number'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
ephemeral-env-label:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-label
|
||||
cancel-in-progress: true
|
||||
name: Evaluate ephemeral env label trigger
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
slash-command: ${{ steps.eval-label.outputs.result }}
|
||||
feature-flags: ${{ steps.eval-feature-flags.outputs.result }}
|
||||
sha: ${{ steps.get-sha.outputs.sha }}
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Check for the "testenv-up" label
|
||||
id: eval-label
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
LABEL_NAME="${INPUT_LABEL_NAME}"
|
||||
else
|
||||
LABEL_NAME="${{ github.event.label.name }}"
|
||||
fi
|
||||
|
||||
echo "Evaluating label: $LABEL_NAME"
|
||||
|
||||
if [[ "$LABEL_NAME" == "testenv-up" ]]; then
|
||||
echo "result=up" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "result=noop" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
env:
|
||||
INPUT_LABEL_NAME: ${{ github.event.inputs.label_name }}
|
||||
- name: Get event SHA
|
||||
id: get-sha
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
let prSha;
|
||||
|
||||
// If event is workflow_dispatch, use the issue_number from inputs
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
const prNumber = "${{ github.event.inputs.issue_number }}";
|
||||
if (!prNumber) {
|
||||
console.log("No PR number found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch PR details using the provided issue_number
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
prSha = pr.head.sha;
|
||||
} else {
|
||||
// If it's not workflow_dispatch, use the PR head sha from the event
|
||||
prSha = context.payload.pull_request.head.sha;
|
||||
}
|
||||
|
||||
console.log(`PR SHA: ${prSha}`);
|
||||
core.setOutput("sha", prSha);
|
||||
|
||||
- name: Looking for feature flags in PR description
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: eval-feature-flags
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
with:
|
||||
script: |
|
||||
const description = context.payload.pull_request
|
||||
? context.payload.pull_request.body || ''
|
||||
: context.payload.inputs.pr_description || '';
|
||||
|
||||
const pattern = /FEATURE_(\w+)=(\w+)/g;
|
||||
let results = [];
|
||||
[...description.matchAll(pattern)].forEach(match => {
|
||||
const config = {
|
||||
name: `SUPERSET_FEATURE_${match[1]}`,
|
||||
value: match[2],
|
||||
};
|
||||
results.push(config);
|
||||
});
|
||||
|
||||
return results;
|
||||
|
||||
- name: Reply with confirmation comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const action = '${{ steps.eval-label.outputs.result }}';
|
||||
const user = context.actor;
|
||||
const runId = context.runId;
|
||||
const workflowUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
|
||||
|
||||
const issueNumber = context.payload.pull_request
|
||||
? context.payload.pull_request.number
|
||||
: context.payload.inputs.issue_number;
|
||||
|
||||
if (!issueNumber) {
|
||||
throw new Error("Issue number is not available.");
|
||||
}
|
||||
|
||||
const body = `⚠️ **DEPRECATED WORKFLOW** ⚠️\n\n@${user} This workflow is deprecated! Please use the new **Superset Showtime** system instead:\n\n` +
|
||||
`- Replace "testenv-up" label with "🎪 trigger-start"\n` +
|
||||
`- Better reliability and easier management\n` +
|
||||
`- See https://github.com/mistercrunch/superset-showtime for details\n\n` +
|
||||
`Processing your ephemeral environment request [here](${workflowUrl}). Action: **${action}**.` +
|
||||
` More information on [how to use or configure ephemeral environments]` +
|
||||
`(https://superset.apache.org/docs/contributing/howtos/#github-ephemeral-environments)`;
|
||||
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
ephemeral-docker-build:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-build
|
||||
cancel-in-progress: true
|
||||
needs: ephemeral-env-label
|
||||
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
|
||||
name: ephemeral-docker-build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ needs.ephemeral-env-label.outputs.sha }} : ${{steps.get-sha.outputs.sha}} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ needs.ephemeral-env-label.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Docker Environment
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
build: "true"
|
||||
install-docker-compose: "false"
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Build ephemeral env image
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
supersetbot docker \
|
||||
--push \
|
||||
--load \
|
||||
--preset ci \
|
||||
--platform linux/amd64 \
|
||||
--context-ref "$RELEASE" \
|
||||
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Load, tag and push image to ECR
|
||||
id: push-image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
ECR_REPOSITORY: superset-ci
|
||||
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
|
||||
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
|
||||
|
||||
ephemeral-env-up:
|
||||
needs: [ephemeral-env-label, ephemeral-docker-build]
|
||||
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
|
||||
name: Spin up an ephemeral environment
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
aws ecr describe-images \
|
||||
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
|
||||
--repository-name superset-ci \
|
||||
--image-ids imageTag=pr-$PR_NUMBER-ci
|
||||
|
||||
- name: Fail on missing container image
|
||||
if: steps.check-image.outcome == 'failure'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: errMsg
|
||||
});
|
||||
core.setFailed(errMsg);
|
||||
|
||||
- name: Fill in the new image ID in the Amazon ECS task definition
|
||||
id: task-def
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
|
||||
with:
|
||||
task-definition: .github/workflows/ecs-task-definition.json
|
||||
container-name: superset-ci
|
||||
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
|
||||
|
||||
- name: Update env vars in the Amazon ECS task definition
|
||||
run: |
|
||||
cat <<< "$(jq '.containerDefinitions[0].environment += ${{ needs.ephemeral-env-label.outputs.feature-flags }}' < ${{ steps.task-def.outputs.task-definition }})" > ${{ steps.task-def.outputs.task-definition }}
|
||||
|
||||
- name: Describe ECS service
|
||||
id: describe-services
|
||||
run: |
|
||||
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${INPUT_ISSUE_NUMBER}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
- name: Create ECS service
|
||||
id: create-service
|
||||
if: steps.describe-services.outputs.active != 'true'
|
||||
env:
|
||||
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
|
||||
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
aws ecs create-service \
|
||||
--cluster superset-ci \
|
||||
--service-name pr-$PR_NUMBER-service \
|
||||
--task-definition superset-ci \
|
||||
--launch-type FARGATE \
|
||||
--desired-count 1 \
|
||||
--platform-version LATEST \
|
||||
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
|
||||
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
|
||||
- name: Deploy Amazon ECS task definition
|
||||
id: deploy-task
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
|
||||
with:
|
||||
task-definition: ${{ steps.task-def.outputs.task-definition }}
|
||||
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
|
||||
cluster: superset-ci
|
||||
wait-for-service-stability: true
|
||||
wait-for-minutes: 10
|
||||
|
||||
- name: List tasks
|
||||
id: list-tasks
|
||||
run: |
|
||||
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${INPUT_ISSUE_NUMBER}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
- name: Get network interface
|
||||
id: get-eni
|
||||
run: |
|
||||
echo "eni=$(aws ecs describe-tasks --cluster superset-ci --tasks ${{ steps.list-tasks.outputs.task }} | jq '.tasks[0].attachments[0].details | map(select(.name=="networkInterfaceId"))[0].value')" >> $GITHUB_OUTPUT
|
||||
- name: Get public IP
|
||||
id: get-ip
|
||||
run: |
|
||||
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
|
||||
- name: Comment (success)
|
||||
if: ${{ success() }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issue_number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
|
||||
});
|
||||
- name: Comment (failure)
|
||||
if: ${{ failure() }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issue_number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'
|
||||
})
|
||||
@@ -16,6 +16,11 @@ jobs:
|
||||
|
||||
validate-all-ghas:
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
# Required for the zizmor action to upload its SARIF results to
|
||||
# GitHub code scanning (advanced-security is enabled by default).
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
2
.github/workflows/labeler.yml
vendored
2
.github/workflows/labeler.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@v6
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
|
||||
4
.github/workflows/latest-release-tag.yml
vendored
4
.github/workflows/latest-release-tag.yml
vendored
@@ -20,7 +20,9 @@ jobs:
|
||||
- name: Check for latest tag
|
||||
id: latest-tag
|
||||
run: |
|
||||
source ./scripts/tag_latest_release.sh $(echo ${{ github.event.release.tag_name }}) --dry-run
|
||||
source ./scripts/tag_latest_release.sh $(echo ${GITHUB_EVENT_RELEASE_TAG_NAME}) --dry-run
|
||||
env:
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
6
.github/workflows/release.yml
vendored
6
.github/workflows/release.yml
vendored
@@ -6,6 +6,9 @@ on:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -27,9 +30,12 @@ jobs:
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: Bump version and publish package(s)
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# pulls all commits (needed for lerna / semantic release to correctly version)
|
||||
fetch-depth: 0
|
||||
- name: Get tags and filter trigger tags
|
||||
|
||||
2
.github/workflows/showtime-trigger.yml
vendored
2
.github/workflows/showtime-trigger.yml
vendored
@@ -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
|
||||
|
||||
|
||||
11
.github/workflows/superset-docs-deploy.yml
vendored
11
.github/workflows/superset-docs-deploy.yml
vendored
@@ -27,6 +27,9 @@ concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -45,7 +48,13 @@ jobs:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
build-deploy:
|
||||
needs: config
|
||||
if: needs.config.outputs.has-secrets
|
||||
# For workflow_run triggers, only deploy when the triggering run originated
|
||||
# from this repository (not a fork), ensuring the checked-out code and any
|
||||
# local actions executed with deploy credentials are trusted.
|
||||
if: >-
|
||||
needs.config.outputs.has-secrets &&
|
||||
(github.event_name != 'workflow_run' ||
|
||||
github.event.workflow_run.head_repository.full_name == github.repository)
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
8
.github/workflows/superset-docs-verify.yml
vendored
8
.github/workflows/superset-docs-verify.yml
vendored
@@ -16,6 +16,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_sha || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linkinator:
|
||||
# See docs here: https://github.com/marketplace/actions/linkinator
|
||||
@@ -25,6 +28,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
@@ -97,7 +102,8 @@ jobs:
|
||||
# Only runs if integration tests succeeded
|
||||
if: >
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
name: Build (after integration tests)
|
||||
runs-on: ubuntu-24.04
|
||||
defaults:
|
||||
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
5
.github/workflows/superset-frontend.yml
vendored
5
.github/workflows/superset-frontend.yml
vendored
@@ -16,6 +16,9 @@ concurrency:
|
||||
env:
|
||||
TAG: apache/superset:GHA-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
13
.github/workflows/tag-release.yml
vendored
13
.github/workflows/tag-release.yml
vendored
@@ -21,6 +21,9 @@ on:
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -42,6 +45,8 @@ jobs:
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: docker-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
|
||||
@@ -51,6 +56,7 @@ jobs:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Docker Environment
|
||||
@@ -77,8 +83,9 @@ jobs:
|
||||
INPUT_RELEASE: ${{ github.event.inputs.release }}
|
||||
INPUT_FORCE_LATEST: ${{ github.event.inputs.force-latest }}
|
||||
INPUT_GIT_REF: ${{ github.event.inputs.git-ref }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
RELEASE="${{ github.event.release.tag_name }}"
|
||||
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
FORCE_LATEST=""
|
||||
EVENT="${{github.event_name}}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -114,6 +121,7 @@ jobs:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js 20
|
||||
@@ -128,11 +136,12 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_RELEASE: ${{ github.event.inputs.release }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
export GITHUB_ACTOR=""
|
||||
git fetch --all --tags
|
||||
git checkout master
|
||||
RELEASE="${{ github.event.release.tag_name }}"
|
||||
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# in the case of a manually-triggered run, read release from input
|
||||
RELEASE="${INPUT_RELEASE}"
|
||||
|
||||
2
.github/workflows/tech-debt.yml
vendored
2
.github/workflows/tech-debt.yml
vendored
@@ -33,6 +33,8 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
|
||||
2
.github/workflows/welcome-new-users.yml
vendored
2
.github/workflows/welcome-new-users.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Welcome Message
|
||||
uses: actions/first-interaction@v3
|
||||
uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
issue_message: |-
|
||||
|
||||
@@ -158,3 +158,14 @@ repos:
|
||||
language: system
|
||||
files: ^superset/config\.py$
|
||||
pass_filenames: false
|
||||
- id: zizmor
|
||||
name: zizmor (GHA security audit)
|
||||
entry: zizmor
|
||||
language: python
|
||||
additional_dependencies: [zizmor==1.25.2]
|
||||
files: ^\.github/
|
||||
types: [yaml]
|
||||
pass_filenames: false
|
||||
# Advisory until pre-existing findings are resolved; remove
|
||||
# --no-exit-codes to make this hook blocking.
|
||||
args: [--no-exit-codes, .github/]
|
||||
|
||||
@@ -113,7 +113,7 @@ RUN useradd --user-group -d ${SUPERSET_HOME} -m --no-log-init --shell /bin/bash
|
||||
# Some bash scripts needed throughout the layers
|
||||
COPY --chmod=755 docker/*.sh /app/docker/
|
||||
|
||||
RUN pip install --no-cache-dir --upgrade uv
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
|
||||
# Using uv as it's faster/simpler than pip
|
||||
RUN uv venv /app/.venv
|
||||
|
||||
@@ -128,7 +128,11 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"swagger-client": "3.37.3"
|
||||
"swagger-client": "3.37.3",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"yaml": "1.10.3",
|
||||
"uuid": "11.1.1"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
|
||||
dependencies:
|
||||
p-locate "^6.0.0"
|
||||
|
||||
lodash-es@^4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz"
|
||||
integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==
|
||||
lodash-es@4.18.1, lodash-es@^4.17.21:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d"
|
||||
integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==
|
||||
|
||||
lodash.debounce@^4, lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
@@ -9701,12 +9701,7 @@ lodash.uniq@^4.5.0:
|
||||
resolved "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz"
|
||||
integrity sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==
|
||||
|
||||
lodash@4.17.21:
|
||||
version "4.17.21"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
|
||||
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
|
||||
|
||||
lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
|
||||
lodash@4.17.21, lodash@4.18.1, lodash@^4.15.0, lodash@^4.17.10, lodash@^4.17.15, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.18.1:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
|
||||
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
|
||||
@@ -14726,15 +14721,10 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
|
||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||
|
||||
uuid@8.3.2, uuid@^8.3.2:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0":
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
|
||||
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
|
||||
uuid@11.1.1, uuid@8.3.2, "uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^8.3.2:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
|
||||
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
|
||||
|
||||
uvu@^0.5.0:
|
||||
version "0.5.6"
|
||||
@@ -15139,9 +15129,9 @@ ws@^7.3.1:
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.18.0, ws@^8.2.3:
|
||||
version "8.18.3"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
|
||||
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
||||
version "8.20.1"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
|
||||
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
|
||||
|
||||
wsl-utils@^0.1.0:
|
||||
version "0.1.0"
|
||||
@@ -15209,12 +15199,7 @@ yaml-ast-parser@0.0.43:
|
||||
resolved "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz"
|
||||
integrity sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==
|
||||
|
||||
yaml@1.10.2:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b"
|
||||
integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==
|
||||
|
||||
yaml@^1.10.0:
|
||||
yaml@1.10.2, yaml@1.10.3, yaml@^1.10.0:
|
||||
version "1.10.3"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
|
||||
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
|
||||
|
||||
@@ -208,7 +208,7 @@ kombu==5.5.3
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.11
|
||||
mako==1.3.12
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -451,7 +451,7 @@ tzdata==2025.2
|
||||
# pandas
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==2.6.3
|
||||
urllib3==2.7.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
|
||||
@@ -346,6 +346,7 @@ google-auth==2.43.0
|
||||
# google-api-core
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-bigquery-storage
|
||||
# google-cloud-core
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
@@ -360,7 +361,7 @@ google-cloud-bigquery==3.27.0
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
# sqlalchemy-bigquery
|
||||
google-cloud-bigquery-storage==2.19.1
|
||||
google-cloud-bigquery-storage==2.26.0
|
||||
# via pandas-gbq
|
||||
google-cloud-core==2.4.1
|
||||
# via google-cloud-bigquery
|
||||
@@ -506,7 +507,7 @@ limits==5.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-limiter
|
||||
mako==1.3.11
|
||||
mako==1.3.12
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -701,7 +702,7 @@ proto-plus==1.25.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-bigquery-storage
|
||||
protobuf==4.25.8
|
||||
protobuf==5.29.6
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-bigquery-storage
|
||||
@@ -839,7 +840,7 @@ python-dotenv==1.2.2
|
||||
# pydantic-settings
|
||||
python-ldap==3.4.4
|
||||
# via apache-superset
|
||||
python-multipart==0.0.20
|
||||
python-multipart==0.0.29
|
||||
# via mcp
|
||||
pytz==2025.2
|
||||
# via
|
||||
@@ -1071,7 +1072,7 @@ url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# requests-cache
|
||||
urllib3==2.6.3
|
||||
urllib3==2.7.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# botocore
|
||||
|
||||
@@ -33,6 +33,7 @@ PATTERNS = {
|
||||
r"^setup\.py",
|
||||
r"^pyproject\.toml$",
|
||||
r"^requirements/.+\.txt",
|
||||
r"^pyproject\.toml",
|
||||
r"^.pylintrc",
|
||||
],
|
||||
"frontend": [
|
||||
@@ -156,7 +157,7 @@ def main(event_type: str, sha: str, repo: str) -> None:
|
||||
|
||||
def get_git_sha() -> str:
|
||||
return os.getenv("GITHUB_SHA") or subprocess.check_output( # noqa: S603
|
||||
["git", "rev-parse", "HEAD"] # noqa: S607
|
||||
["git", "rev-parse", "HEAD"] # noqa: S603, S607
|
||||
).strip().decode("utf-8")
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
}
|
||||
[ -n "$output" ] && echo "$output"
|
||||
if [ -n "$output" ]; then echo "$output"; fi
|
||||
else
|
||||
echo "No JavaScript/TypeScript files to lint"
|
||||
fi
|
||||
|
||||
24
superset-embedded-sdk/jest.config.js
Normal file
24
superset-embedded-sdk/jest.config.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// @superset-ui/switchboard ships ES module syntax, so it must be
|
||||
// transformed by babel rather than ignored as a node_modules dependency.
|
||||
transformIgnorePatterns: ["/node_modules/(?!@superset-ui/switchboard)"],
|
||||
};
|
||||
91
superset-embedded-sdk/src/index.test.ts
Normal file
91
superset-embedded-sdk/src/index.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* 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 {
|
||||
validateEmbeddedDashboardId,
|
||||
validateSupersetDomain,
|
||||
findUnsafeSandboxExtras,
|
||||
} from "./index";
|
||||
|
||||
describe("validateEmbeddedDashboardId", () => {
|
||||
it("accepts a canonical uuid", () => {
|
||||
expect(() =>
|
||||
validateEmbeddedDashboardId("f4787a4f-2541-4f8a-9b5e-1e2d3c4b5a6f")
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("accepts a simple hexadecimal id", () => {
|
||||
expect(() => validateEmbeddedDashboardId("abc123")).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["../../evil"],
|
||||
["a/b"],
|
||||
["x?foo=bar"],
|
||||
["x#frag"],
|
||||
["a@b.com"],
|
||||
["foo bar"],
|
||||
["http://evil.com"],
|
||||
[""],
|
||||
["%2e%2e"],
|
||||
])("rejects an id with an unexpected format: %p", (id) => {
|
||||
expect(() => validateEmbeddedDashboardId(id)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateSupersetDomain", () => {
|
||||
it.each([
|
||||
["https://superset.example.com"],
|
||||
["http://localhost:8088"],
|
||||
// sub-path deployments are valid; the origin is what matters downstream
|
||||
["https://example.com/superset"],
|
||||
])("accepts a valid absolute URL: %p", (domain) => {
|
||||
expect(() => validateSupersetDomain(domain)).not.toThrow();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["superset.example.com"], // missing protocol
|
||||
["not a url"],
|
||||
[""],
|
||||
["/relative/path"],
|
||||
])("rejects a malformed domain: %p", (domain) => {
|
||||
expect(() => validateSupersetDomain(domain)).toThrow(
|
||||
"Invalid supersetDomain format"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUnsafeSandboxExtras", () => {
|
||||
it("returns the tokens that relax iframe isolation", () => {
|
||||
expect(
|
||||
findUnsafeSandboxExtras([
|
||||
"allow-forms",
|
||||
"allow-top-navigation",
|
||||
"allow-popups",
|
||||
"allow-top-navigation-by-user-activation",
|
||||
])
|
||||
).toEqual(["allow-top-navigation", "allow-top-navigation-by-user-activation"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when all tokens are safe", () => {
|
||||
expect(
|
||||
findUnsafeSandboxExtras(["allow-forms", "allow-popups", "allow-downloads"])
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -50,7 +50,9 @@ export type UiConfigType = {
|
||||
};
|
||||
|
||||
export type EmbedDashboardParams = {
|
||||
/** The id provided by the embed configuration UI in Superset */
|
||||
/** The id provided by the embed configuration UI in Superset.
|
||||
* This is the UUID generated by Superset's embed configuration and is
|
||||
* expected to contain only hexadecimal characters and hyphens. */
|
||||
id: string;
|
||||
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
|
||||
supersetDomain: string;
|
||||
@@ -112,6 +114,48 @@ export type EmbeddedDashboard = {
|
||||
setThemeMode: (mode: ThemeMode) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates that an embedded dashboard id has the expected format
|
||||
* (the UUID produced by Superset's embed configuration). Throws on
|
||||
* anything containing characters that are not part of that format.
|
||||
*/
|
||||
export function validateEmbeddedDashboardId(id: string): void {
|
||||
if (typeof id !== 'string' || !/^[a-f0-9-]+$/i.test(id)) {
|
||||
throw new Error('Invalid dashboard id format');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that supersetDomain is a parseable absolute URL (it must include
|
||||
* a protocol, e.g. https://superset.example.com). Throws otherwise. The
|
||||
* domain's origin is what gets used as the postMessage targetOrigin, so it
|
||||
* has to resolve to a well-formed origin.
|
||||
*/
|
||||
export function validateSupersetDomain(supersetDomain: string): void {
|
||||
try {
|
||||
// eslint-disable-next-line no-new
|
||||
new URL(supersetDomain);
|
||||
} catch {
|
||||
throw new Error('Invalid supersetDomain format');
|
||||
}
|
||||
}
|
||||
|
||||
// Sandbox tokens that materially relax the iframe's isolation (for example,
|
||||
// letting the embedded frame navigate the top-level page). They remain
|
||||
// supported via iframeSandboxExtras for callers that genuinely need them.
|
||||
const UNSAFE_SANDBOX_EXTRAS = [
|
||||
'allow-top-navigation',
|
||||
'allow-top-navigation-by-user-activation',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns any caller-provided sandbox tokens that relax the iframe's
|
||||
* isolation, so they can be surfaced and not enabled unintentionally.
|
||||
*/
|
||||
export function findUnsafeSandboxExtras(extras: string[]): string[] {
|
||||
return extras.filter(token => UNSAFE_SANDBOX_EXTRAS.includes(token));
|
||||
}
|
||||
|
||||
/**
|
||||
* Embeds a Superset dashboard into the page using an iframe.
|
||||
*/
|
||||
@@ -128,6 +172,8 @@ export async function embedDashboard({
|
||||
referrerPolicy,
|
||||
resolvePermalinkUrl,
|
||||
}: EmbedDashboardParams): Promise<EmbeddedDashboard> {
|
||||
validateEmbeddedDashboardId(id);
|
||||
|
||||
function log(...info: unknown[]) {
|
||||
if (debug) {
|
||||
console.debug(`[superset-embedded-sdk][dashboard ${id}]`, ...info);
|
||||
@@ -139,6 +185,7 @@ export async function embedDashboard({
|
||||
if (supersetDomain.endsWith('/')) {
|
||||
supersetDomain = supersetDomain.slice(0, -1);
|
||||
}
|
||||
validateSupersetDomain(supersetDomain);
|
||||
|
||||
function calculateConfig() {
|
||||
let configNumber = 0;
|
||||
@@ -195,6 +242,13 @@ export async function embedDashboard({
|
||||
iframe.sandbox.add('allow-forms'); // for forms to submit
|
||||
iframe.sandbox.add('allow-popups'); // for exporting charts as csv
|
||||
// additional sandbox props
|
||||
const unsafeSandboxExtras = findUnsafeSandboxExtras(iframeSandboxExtras);
|
||||
if (unsafeSandboxExtras.length > 0) {
|
||||
console.warn(
|
||||
'[superset-embedded-sdk] iframeSandboxExtras includes tokens that ' +
|
||||
`relax the iframe's isolation: ${unsafeSandboxExtras.join(', ')}`,
|
||||
);
|
||||
}
|
||||
iframeSandboxExtras.forEach((key: string) => {
|
||||
iframe.sandbox.add(key);
|
||||
});
|
||||
@@ -216,7 +270,9 @@ export async function embedDashboard({
|
||||
// we know the content window isn't null because we are in the load event handler.
|
||||
iframe.contentWindow!.postMessage(
|
||||
{ type: IFRAME_COMMS_MESSAGE_TYPE, handshake: 'port transfer' },
|
||||
supersetDomain,
|
||||
// Use the normalized origin (not the raw domain, which may carry a
|
||||
// sub-path for sub-path deployments) as the postMessage targetOrigin.
|
||||
new URL(supersetDomain).origin,
|
||||
[theirPort],
|
||||
);
|
||||
log('sent message channel to the iframe');
|
||||
|
||||
@@ -152,3 +152,33 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
);
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a compact pill filter (new UI that replaced comboboxes).
|
||||
* Clicks the pill button matching the label, then clicks the option in the panel.
|
||||
*/
|
||||
export async function selectPillOption(option: string, pillLabel?: string) {
|
||||
let pill: HTMLElement;
|
||||
if (pillLabel) {
|
||||
// Find the pill whose text content includes the label
|
||||
pill = await waitFor(() => {
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const match = pills.find(p => p.textContent?.includes(pillLabel));
|
||||
if (!match)
|
||||
throw new Error(`Could not find pill with label "${pillLabel}"`);
|
||||
return match;
|
||||
});
|
||||
} else {
|
||||
pill = await screen.findByTestId('compact-filter-pill');
|
||||
}
|
||||
await userEvent.click(pill);
|
||||
// Wait for the option list to appear and click the item
|
||||
const item = await waitFor(() => {
|
||||
const listbox = document.querySelector('[role="listbox"]');
|
||||
if (!listbox) throw new Error('No listbox found');
|
||||
const opt = within(listbox as HTMLElement).getByText(option);
|
||||
if (!opt) throw new Error(`Option "${option}" not found`);
|
||||
return opt;
|
||||
});
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
@@ -817,8 +817,11 @@ export function exploreJSON(
|
||||
),
|
||||
);
|
||||
(queriesResponse as QueryData[]).forEach(response => {
|
||||
if (response.warning) {
|
||||
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
|
||||
const { warning } = response as QueryData & {
|
||||
warning?: string | null;
|
||||
};
|
||||
if (warning) {
|
||||
dispatch(addWarningToast(warning, { noDuplicate: true }));
|
||||
}
|
||||
});
|
||||
return dispatch(
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CardSortSelect } from './CardSortSelect';
|
||||
|
||||
const options = [
|
||||
{ desc: false, id: 'title', label: 'Alphabetical', value: 'alphabetical' },
|
||||
{
|
||||
desc: true,
|
||||
id: 'changed_on',
|
||||
label: 'Recently modified',
|
||||
value: 'recently_modified',
|
||||
},
|
||||
{
|
||||
desc: false,
|
||||
id: 'changed_on',
|
||||
label: 'Least recently modified',
|
||||
value: 'least_recently_modified',
|
||||
},
|
||||
];
|
||||
|
||||
test('pill always shows "Sort" label with no value suffix and no clear button', () => {
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={jest.fn()}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/sort.*alphabetical/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('no clear button even when a non-default sort is active', () => {
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={jest.fn()}
|
||||
initialSort={[{ id: 'changed_on', desc: true }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a sort option from the panel calls onChange with the correct id and desc', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('compact-filter-pill'));
|
||||
expect(screen.getByText('Recently modified')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Recently modified'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: true }]);
|
||||
// Pill label stays "Sort" — value is in tooltip, not the label
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('selecting a different option from the panel calls onChange with correct args', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('compact-filter-pill'));
|
||||
await userEvent.click(screen.getByText('Least recently modified'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: false }]);
|
||||
});
|
||||
@@ -16,20 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { FormLabel, Select } from '@superset-ui/core/components';
|
||||
import { SELECT_WIDTH } from './utils';
|
||||
import type { SelectOption } from './types';
|
||||
import { CardSortSelectOption, SortColumn } from './types';
|
||||
|
||||
const SortContainer = styled.div`
|
||||
display: inline-flex;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
width: ${SELECT_WIDTH}px;
|
||||
`;
|
||||
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
|
||||
import CompactSelectPanel from './Filters/CompactSelectPanel';
|
||||
import type { FilterHandler } from './Filters/types';
|
||||
|
||||
interface CardViewSelectSortProps {
|
||||
onChange: (value: SortColumn[]) => void;
|
||||
@@ -42,6 +35,8 @@ export const CardSortSelect = ({
|
||||
onChange,
|
||||
options,
|
||||
}: CardViewSelectSortProps) => {
|
||||
const panelRef = useRef<FilterHandler>(null);
|
||||
|
||||
const defaultSort =
|
||||
(initialSort &&
|
||||
options.find(
|
||||
@@ -50,44 +45,41 @@ export const CardSortSelect = ({
|
||||
)) ||
|
||||
options[0];
|
||||
|
||||
const [value, setValue] = useState({
|
||||
const [currentValue, setCurrentValue] = useState<SelectOption>({
|
||||
label: defaultSort.label,
|
||||
value: defaultSort.value,
|
||||
});
|
||||
|
||||
const formattedOptions = useMemo(
|
||||
() => options.map(option => ({ label: option.label, value: option.value })),
|
||||
[options],
|
||||
);
|
||||
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
|
||||
|
||||
const handleOnChange = (selected: { label: string; value: string }) => {
|
||||
setValue(selected);
|
||||
const originalOption = options.find(
|
||||
({ value }) => value === selected.value,
|
||||
);
|
||||
if (originalOption) {
|
||||
const sortBy = [
|
||||
{
|
||||
id: originalOption.id,
|
||||
desc: originalOption.desc,
|
||||
},
|
||||
];
|
||||
onChange(sortBy);
|
||||
const handleSelect = (option: SelectOption | undefined) => {
|
||||
if (!option) return;
|
||||
const original = options.find(o => o.value === option.value);
|
||||
if (original) {
|
||||
setCurrentValue({ label: original.label, value: original.value });
|
||||
onChange([{ id: original.id, desc: original.desc }]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SortContainer>
|
||||
<Select
|
||||
ariaLabel={t('Sort')}
|
||||
header={<FormLabel>{t('Sort')}</FormLabel>}
|
||||
labelInValue
|
||||
onChange={handleOnChange}
|
||||
options={formattedOptions}
|
||||
showSearch
|
||||
value={value}
|
||||
data-test="card-sort-select"
|
||||
/>
|
||||
</SortContainer>
|
||||
<span data-test="card-sort-select">
|
||||
<CompactFilterTrigger
|
||||
label={t('Sort')}
|
||||
hasValue={false}
|
||||
onClear={() => {}}
|
||||
tooltipTitle={String(currentValue.label)}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
ref={panelRef}
|
||||
selects={selectOptions}
|
||||
value={currentValue}
|
||||
onSelect={handleSelect}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
|
||||
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
|
||||
const baseProps = {
|
||||
label: 'Owner',
|
||||
hasValue: false,
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultChildren = jest.fn(() => (
|
||||
<div data-testid="filter-content">Filter content</div>
|
||||
));
|
||||
|
||||
function renderTrigger(
|
||||
props: Partial<
|
||||
typeof baseProps & {
|
||||
hasValue: boolean;
|
||||
tooltipTitle?: string;
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
> = {},
|
||||
children = defaultChildren,
|
||||
) {
|
||||
return render(
|
||||
<CompactFilterTrigger {...baseProps} {...props}>
|
||||
{children}
|
||||
</CompactFilterTrigger>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders the label', () => {
|
||||
renderTrigger();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders as inactive pill with down chevron when hasValue is false', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
// No clear button when inactive
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders active state with clear icon when hasValue is true', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clear icon has descriptive aria-label matching the filter name', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(clearIcon).toHaveAttribute('aria-label', 'Clear Owner filter');
|
||||
});
|
||||
|
||||
test('clear icon is rendered inside the pill button', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(pill).toContainElement(clearIcon);
|
||||
});
|
||||
|
||||
test('toggles aria-expanded when pill is clicked', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('calls onClear when clear icon is clicked', async () => {
|
||||
const onClear = jest.fn();
|
||||
renderTrigger({ hasValue: true, onClear } as any);
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
await userEvent.click(clearIcon);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
|
||||
const { container } = renderTrigger();
|
||||
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
|
||||
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('calls children render prop with isOpen and onClose', async () => {
|
||||
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
|
||||
renderTrigger({}, children);
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(children).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to listbox by default', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to dialog when popupType is dialog', () => {
|
||||
renderTrigger({ popupType: 'dialog' });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
});
|
||||
|
||||
test('closing dropdown resets aria-expanded to false', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
export type FilterPanelRenderProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
interface CompactFilterTriggerProps {
|
||||
label: ReactNode;
|
||||
hasValue: boolean;
|
||||
onClear: () => void;
|
||||
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
|
||||
children: (props: FilterPanelRenderProps) => ReactNode;
|
||||
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
|
||||
tooltipTitle?: string;
|
||||
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
|
||||
* 'dialog' for form panels (date range, numerical range). */
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
|
||||
const FilterPill = styled.button<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
height: ${theme.controlHeight}px;
|
||||
padding: 0 ${theme.sizeUnit * 3}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
|
||||
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
|
||||
color: ${$active ? theme.colorPrimary : theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
font-weight: ${$active ? 600 : 400};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
/* AntD anticon spans carry vertical-align: -0.125em from global styles.
|
||||
align-self centers the span within the pill; the inner flex+align-items
|
||||
centers the svg within the span. */
|
||||
.anticon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colorPrimary};
|
||||
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ActiveDot = styled.span`
|
||||
${({ theme }) => css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorPrimary};
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function CompactFilterTrigger({
|
||||
label,
|
||||
hasValue,
|
||||
onClear,
|
||||
children,
|
||||
tooltipTitle,
|
||||
popupType = 'listbox',
|
||||
}: CompactFilterTriggerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
// Tracks whether tooltip should be suppressed after dropdown close.
|
||||
// Brave (and some other browsers) fire a synthetic mouseover on newly-exposed
|
||||
// elements when a popup disappears, triggering Tooltip onOpenChange(true)
|
||||
// without real user intent. We suppress until the cursor actually leaves the
|
||||
// pill (onMouseLeave), which is the first reliable "hover reset" signal.
|
||||
const tooltipSuppressedRef = useRef(false);
|
||||
|
||||
// Close dropdown on window resize — AntD Dropdown doesn't reposition
|
||||
// itself on resize so the panel ends up detached from the pill.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleResize = () => setOpen(false);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [open]);
|
||||
|
||||
const handleClear = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
setOpen(false);
|
||||
tooltipSuppressedRef.current = true;
|
||||
setTooltipOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={visible => {
|
||||
setOpen(visible);
|
||||
if (!visible) {
|
||||
tooltipSuppressedRef.current = true;
|
||||
setTooltipOpen(false);
|
||||
}
|
||||
}}
|
||||
trigger={['click']}
|
||||
popupRender={() =>
|
||||
children({ isOpen: open, onClose: () => setOpen(false) })
|
||||
}
|
||||
placement="bottomLeft"
|
||||
destroyPopupOnHide
|
||||
>
|
||||
<Tooltip
|
||||
title={tooltipTitle}
|
||||
open={!!tooltipTitle && !open && tooltipOpen}
|
||||
onOpenChange={visible => {
|
||||
if (visible && tooltipSuppressedRef.current) return;
|
||||
setTooltipOpen(visible && !!tooltipTitle && !open);
|
||||
}}
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<FilterPill
|
||||
$active={hasValue}
|
||||
type="button"
|
||||
data-test="compact-filter-pill"
|
||||
aria-haspopup={popupType}
|
||||
aria-expanded={open}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
onMouseLeave={() => {
|
||||
tooltipSuppressedRef.current = false;
|
||||
}}
|
||||
>
|
||||
{hasValue && <ActiveDot />}
|
||||
<span>{label}</span>
|
||||
{hasValue ? (
|
||||
<Icons.CloseOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorPrimary}
|
||||
onClick={handleClear}
|
||||
data-test="compact-filter-clear"
|
||||
aria-label={
|
||||
typeof label === 'string'
|
||||
? t('Clear %s filter', label)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Icons.DownOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
/>
|
||||
)}
|
||||
</FilterPill>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, act } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const SMALL_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
];
|
||||
|
||||
const LARGE_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
{ label: 'David', value: 4 },
|
||||
{ label: 'Eve', value: 5 },
|
||||
{ label: 'Frank', value: 6 },
|
||||
{ label: 'Grace', value: 7 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders options from selects prop', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides search input when selects.length is 6 or fewer', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when selects.length exceeds 6', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when fetchSelects is provided', () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('filters static options by search term', async () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onSelect with normalized option when an option is clicked', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('shows checkmark icon on selected option', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen
|
||||
.getByText('Alice')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('unselected options have aria-selected false', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const bobOption = screen
|
||||
.getByText('Bob')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(bobOption).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('calls onClose after a selection is made', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
|
||||
const onSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
// Component is fully controlled — visual deselection follows when the
|
||||
// parent passes value={undefined} after receiving the onSelect callback.
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows Loading text when loading prop is true', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
loading
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows No results when displayOptions is empty', () => {
|
||||
render(
|
||||
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options list with listbox role and accessible label', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
|
||||
});
|
||||
|
||||
test('option items have option role', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('fetches and displays remote options via fetchSelects on mount', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [{ label: 'Remote User', value: 99 }],
|
||||
totalCount: 1,
|
||||
});
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remote User')).toBeInTheDocument();
|
||||
});
|
||||
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects returns empty data', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects rejects', async () => {
|
||||
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('selects option via keyboard Enter key', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
|
||||
await userEvent.type(aliceOption, '{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('syncs selected state when external value prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Icons,
|
||||
Input,
|
||||
Constants,
|
||||
type InputRef,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { SelectOption, ListViewFilter as Filter } from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
// Show search box when there are more than this many static options.
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
// Page size for async select fetches — large enough to avoid most pagination
|
||||
// issues while still being a bounded request. Full infinite-load pagination
|
||||
// is a future improvement.
|
||||
const ASYNC_PAGE_SIZE = 200;
|
||||
|
||||
interface CompactSelectPanelProps {
|
||||
selects?: Filter['selects'];
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
value?: SelectOption;
|
||||
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
|
||||
onClose?: () => void;
|
||||
isOpen?: boolean;
|
||||
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
|
||||
panelStyle?: CSSProperties;
|
||||
/** External loading state from filter config */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
padding: 0 0 ${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SearchRow = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
|
||||
${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionList = styled.ul`
|
||||
${({ theme }) => css`
|
||||
margin: 0;
|
||||
padding: ${theme.paddingXXS}px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionItem = styled.li<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
|
||||
${theme.controlPaddingHorizontal}px;
|
||||
line-height: ${theme.lineHeight};
|
||||
cursor: pointer;
|
||||
font-size: ${theme.fontSize}px;
|
||||
color: ${theme.colorText};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: ${$active
|
||||
? theme.colorPrimaryBgHover
|
||||
: theme.colorFillTertiary};
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionLabel = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
`;
|
||||
|
||||
const StatusText = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
text-align: center;
|
||||
color: ${theme.colorTextDisabled};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
function CompactSelectPanel(
|
||||
{
|
||||
selects = [],
|
||||
fetchSelects,
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
isOpen,
|
||||
loading: externalLoading,
|
||||
panelStyle,
|
||||
}: CompactSelectPanelProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
|
||||
const isLoading = externalLoading || internalLoading;
|
||||
|
||||
const debouncedSetSearch = useMemo(
|
||||
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debouncedSetSearch.cancel();
|
||||
},
|
||||
[debouncedSetSearch],
|
||||
);
|
||||
|
||||
// Focus search input when dropdown opens; reset search when it closes
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (isOpen) {
|
||||
timeoutId = setTimeout(() => {
|
||||
inputRef.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch remote options when debounced search changes
|
||||
useEffect(() => {
|
||||
if (!fetchSelects) return;
|
||||
let cancelled = false;
|
||||
setInternalLoading(true);
|
||||
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
|
||||
.then(result => {
|
||||
if (!cancelled) setRemoteOptions(result?.data ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRemoteOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setInternalLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedSearch, fetchSelects]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
onSelect(undefined, true);
|
||||
},
|
||||
}));
|
||||
|
||||
const displayOptions = (
|
||||
fetchSelects
|
||||
? remoteOptions
|
||||
: selects.filter(o => {
|
||||
const label = typeof o.label === 'string' ? o.label : String(o.value);
|
||||
return label.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
).filter(o => o != null);
|
||||
|
||||
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelect = (opt: SelectOption, displayText?: string) => {
|
||||
const isDeselect = value?.value === opt.value;
|
||||
// Normalize to a plain string label for URL serialization:
|
||||
// 1. String labels pass through unchanged.
|
||||
// 2. ReactNode labels with a `title` field use that (set by callers for
|
||||
// options like owner-select where label contains name + email JSX).
|
||||
// 3. Fall back to DOM text content, then stringified value.
|
||||
const label =
|
||||
typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: (opt.title ?? displayText ?? String(opt.value ?? ''));
|
||||
const next = isDeselect ? undefined : { label, value: opt.value };
|
||||
onSelect(next, isDeselect);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelContainer style={panelStyle}>
|
||||
{showSearch && (
|
||||
<SearchRow>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
prefix={
|
||||
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
|
||||
}
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
debouncedSetSearch(e.target.value);
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
`}
|
||||
/>
|
||||
</SearchRow>
|
||||
)}
|
||||
<OptionList role="listbox" aria-label={t('Filter options')}>
|
||||
{isLoading ? (
|
||||
<StatusText>{t('Loading...')}</StatusText>
|
||||
) : displayOptions.length === 0 ? (
|
||||
<StatusText>{t('No results')}</StatusText>
|
||||
) : (
|
||||
displayOptions.map((opt, i) => {
|
||||
const isActive = value?.value === opt.value;
|
||||
const getDisplayText = (el: HTMLElement) =>
|
||||
el.textContent?.trim() || undefined;
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === displayOptions.length - 1;
|
||||
return (
|
||||
<OptionItem
|
||||
key={opt.value}
|
||||
$active={isActive}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
onClick={e =>
|
||||
handleSelect(opt, getDisplayText(e.currentTarget))
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(opt, getDisplayText(e.currentTarget));
|
||||
} else if (e.key === 'ArrowDown' && !isLast) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
} else if (e.key === 'ArrowUp' && !isFirst) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget
|
||||
.previousElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OptionLabel>{opt.label}</OptionLabel>
|
||||
{isActive && (
|
||||
<Icons.CheckOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorPrimary}
|
||||
/>
|
||||
)}
|
||||
</OptionItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</OptionList>
|
||||
</PanelContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CompactSelectPanel);
|
||||
@@ -1,112 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { useLocale } from 'src/hooks/useLocale';
|
||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import {
|
||||
AntdThemeProvider,
|
||||
Loading,
|
||||
FormLabel,
|
||||
RangePicker,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { BaseFilter, FilterHandler } from './types';
|
||||
import { FilterContainer } from './Base';
|
||||
import { RANGE_WIDTH } from '../utils';
|
||||
|
||||
interface DateRangeFilterProps extends BaseFilter {
|
||||
onSubmit: (val: number[] | string[]) => void;
|
||||
name: string;
|
||||
dateFilterValueType?: 'unix' | 'iso';
|
||||
}
|
||||
|
||||
type ValueState = [number, number] | [string, string] | null;
|
||||
|
||||
function DateRangeFilter(
|
||||
{
|
||||
Header,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
dateFilterValueType = 'unix',
|
||||
}: DateRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
|
||||
const dayjsValue = useMemo((): [Dayjs, Dayjs] | null => {
|
||||
if (!value || (Array.isArray(value) && !value.length)) return null;
|
||||
return [extendedDayjs(value[0]), extendedDayjs(value[1])];
|
||||
}, [value]);
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setValue(null);
|
||||
onSubmit([]);
|
||||
},
|
||||
}));
|
||||
|
||||
if (locale === null) {
|
||||
return <Loading position="inline-centered" />;
|
||||
}
|
||||
return (
|
||||
<AntdThemeProvider locale={locale}>
|
||||
<FilterContainer
|
||||
data-test="date-range-filter-container"
|
||||
vertical
|
||||
justify="center"
|
||||
align="start"
|
||||
width={RANGE_WIDTH}
|
||||
>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
<RangePicker
|
||||
placeholder={[t('Start date'), t('End date')]}
|
||||
showTime
|
||||
value={dayjsValue}
|
||||
onCalendarChange={(dayjsRange: [Dayjs, Dayjs]) => {
|
||||
if (!dayjsRange?.[0]?.valueOf() || !dayjsRange?.[1]?.valueOf()) {
|
||||
setValue(null);
|
||||
onSubmit([]);
|
||||
return;
|
||||
}
|
||||
const changeValue =
|
||||
dateFilterValueType === 'iso'
|
||||
? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
|
||||
: [
|
||||
dayjsRange[0]?.valueOf() ?? 0,
|
||||
dayjsRange[1]?.valueOf() ?? 0,
|
||||
];
|
||||
setValue(changeValue as ValueState);
|
||||
onSubmit(changeValue);
|
||||
}}
|
||||
/>
|
||||
</FilterContainer>
|
||||
</AntdThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DateRangeFilter);
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders children inside the wrapper', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div data-test="inner-content">Inner content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Apply button', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onClose when Apply button is clicked', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders without onClose and clicking Apply does not throw', async () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
// No onClose prop — click should not throw
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('visually hides label elements so pills remain accessible', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<label htmlFor="input">Date range</label>
|
||||
<input id="input" />
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
const label = screen.getByText('Date range');
|
||||
// The label must be in the DOM for screen readers but visually hidden via CSS
|
||||
expect(label).toBeInTheDocument();
|
||||
const computedStyle = window.getComputedStyle(label);
|
||||
// clip / overflow hidden pattern applied; position absolute is the key indicator
|
||||
expect(computedStyle.position).toBe('absolute');
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
|
||||
interface FilterPopoverContentProps {
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
|
||||
/* Visually hide the redundant label — the pill already shows it, but keep it
|
||||
accessible to screen readers so filter inputs have a named context. */
|
||||
label {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export default function FilterPopoverContent({
|
||||
children,
|
||||
onClose,
|
||||
}: FilterPopoverContentProps) {
|
||||
return (
|
||||
<Wrapper>
|
||||
{children}
|
||||
<Footer>
|
||||
<Button size="small" buttonStyle="primary" onClick={onClose}>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</Footer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -1,267 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,154 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Select, AsyncSelect, FormLabel } from '@superset-ui/core/components';
|
||||
import { ListViewFilter as Filter, SelectOption } from '../types';
|
||||
import type { BaseFilter, FilterHandler } from './types';
|
||||
import { FilterContainer } from './Base';
|
||||
import { SELECT_WIDTH } from '../utils';
|
||||
|
||||
interface SelectFilterProps extends BaseFilter {
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
name?: string;
|
||||
onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
|
||||
optionFilterProps?: string[];
|
||||
paginate?: boolean;
|
||||
selects: Filter['selects'];
|
||||
loading?: boolean;
|
||||
dropdownStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function SelectFilter(
|
||||
{
|
||||
Header,
|
||||
name,
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
optionFilterProps,
|
||||
selects = [],
|
||||
loading = false,
|
||||
dropdownStyle,
|
||||
}: SelectFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: (option?.title ?? String(selected.value)),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setSelectedOption(selected);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
onSelect(undefined, true);
|
||||
setSelectedOption(undefined);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
onClear();
|
||||
},
|
||||
}));
|
||||
|
||||
const fetchAndFormatSelects = useMemo(
|
||||
() => async (inputValue: string, page: number, pageSize: number) => {
|
||||
if (fetchSelects) {
|
||||
const selectValues = await fetchSelects(inputValue, page, pageSize);
|
||||
return {
|
||||
data: selectValues.data,
|
||||
totalCount: selectValues.totalCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
},
|
||||
[fetchSelects],
|
||||
);
|
||||
const placeholder = t('Choose...');
|
||||
return (
|
||||
<FilterContainer
|
||||
data-test="select-filter-container"
|
||||
width={SELECT_WIDTH}
|
||||
vertical
|
||||
justify="center"
|
||||
align="start"
|
||||
>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
{fetchSelects ? (
|
||||
<AsyncSelect
|
||||
allowClear
|
||||
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||
data-test="filters-select"
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
options={fetchAndFormatSelects}
|
||||
optionFilterProps={optionFilterProps}
|
||||
placeholder={placeholder}
|
||||
dropdownStyle={dropdownStyle}
|
||||
showSearch
|
||||
value={selectedOption}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||
data-test="filters-select"
|
||||
labelInValue
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
options={selects}
|
||||
placeholder={placeholder}
|
||||
dropdownStyle={dropdownStyle}
|
||||
showSearch
|
||||
value={selectedOption}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</FilterContainer>
|
||||
);
|
||||
}
|
||||
export default forwardRef(SelectFilter);
|
||||
@@ -0,0 +1,251 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, act } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
|
||||
import TimeRangeFilter from './TimeRange';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
// Suppress debounced evaluation — the initial useEffect handles the committed
|
||||
// value; the debounced path is an optimistic UX enhancement, not a contract.
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
useDebouncedEffect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
|
||||
FRAME_OPTIONS: [
|
||||
{ label: 'No filter', value: 'No filter' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
],
|
||||
guessFrame: jest.fn().mockReturnValue('Custom'),
|
||||
// 'No filter' is the string value of NO_TIME_RANGE constant
|
||||
useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/controls/DateFilterControl/components',
|
||||
() => ({
|
||||
AdvancedFrame: () => <div data-test="advanced-frame" />,
|
||||
CalendarFrame: () => <div data-test="calendar-frame" />,
|
||||
CommonFrame: () => <div data-test="common-frame" />,
|
||||
CustomFrame: ({ value }: { value: string }) => (
|
||||
<div data-test="custom-frame">{value}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
|
||||
() => ({
|
||||
CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
|
||||
}),
|
||||
);
|
||||
|
||||
const VALID_RANGE = '2024-01-01 : 2024-01-31';
|
||||
|
||||
// Default successful response that fetchTimeRange and the Apply handler both use
|
||||
const MOCK_TIME_RANGE_RESULT = {
|
||||
json: {
|
||||
result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
|
||||
},
|
||||
};
|
||||
|
||||
let getSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
getSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getSpy.mockRestore();
|
||||
});
|
||||
|
||||
function renderFilter(
|
||||
props: Partial<{
|
||||
value: string;
|
||||
onSubmit: jest.Mock;
|
||||
onClose: jest.Mock;
|
||||
}> = {},
|
||||
) {
|
||||
const onSubmit = props.onSubmit ?? jest.fn();
|
||||
const onClose = props.onClose ?? jest.fn();
|
||||
return render(
|
||||
<TimeRangeFilter
|
||||
value={props.value ?? VALID_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('renders range type label, actual time range section, and footer buttons', () => {
|
||||
renderFilter();
|
||||
expect(screen.getByText('Range type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actual time range')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the custom frame when guessFrame returns Custom', () => {
|
||||
renderFilter();
|
||||
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Apply is disabled until the API validates the initial value', async () => {
|
||||
// Block resolution so we can observe disabled state
|
||||
let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
|
||||
getSpy.mockReturnValue(
|
||||
new Promise(res => {
|
||||
resolve = res;
|
||||
}),
|
||||
);
|
||||
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
expect(apply).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
resolve!(MOCK_TIME_RANGE_RESULT);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply is enabled when the API returns a valid result', async () => {
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply is disabled when the API returns an error response', async () => {
|
||||
getSpy.mockRejectedValue(new Error('Bad request'));
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
// Give fetchTimeRange time to reject and set validTimeRange=false
|
||||
await waitFor(() => {
|
||||
expect(apply).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Cancel button calls onClose', async () => {
|
||||
const onClose = jest.fn();
|
||||
renderFilter({ onClose });
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Apply calls onSubmit([since, until]) and onClose when API succeeds', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
renderFilter({ onSubmit, onClose });
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(apply);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith([
|
||||
'2024-01-01T00:00:00',
|
||||
'2024-01-31T23:59:59',
|
||||
]);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Apply calls onClose but not onSubmit when the API call throws', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
// fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
|
||||
getSpy
|
||||
.mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in useEffect
|
||||
.mockRejectedValueOnce(new Error('network')); // Apply button API call
|
||||
|
||||
renderFilter({ onSubmit, onClose });
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(apply);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without an API call', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
render(
|
||||
<TimeRangeFilter
|
||||
value={NO_TIME_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const callsBefore = getSpy.mock.calls.length;
|
||||
await userEvent.click(apply);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(undefined);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
// No extra API call for NO_TIME_RANGE — the button short-circuits
|
||||
expect(getSpy.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
test('clearFilter via ref calls onSubmit(undefined)', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<TimeRangeFilter
|
||||
ref={ref}
|
||||
value={VALID_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
291
superset-frontend/src/components/ListView/Filters/TimeRange.tsx
Normal file
291
superset-frontend/src/components/ListView/Filters/TimeRange.tsx
Normal file
@@ -0,0 +1,291 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
NO_TIME_RANGE,
|
||||
SupersetClient,
|
||||
fetchTimeRange,
|
||||
} from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Button,
|
||||
Constants,
|
||||
Divider,
|
||||
Icons,
|
||||
Select,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useDebouncedEffect } from 'src/explore/exploreUtils';
|
||||
import {
|
||||
FRAME_OPTIONS,
|
||||
guessFrame,
|
||||
useDefaultTimeFilter,
|
||||
} from 'src/explore/components/controls/DateFilterControl/utils';
|
||||
import {
|
||||
AdvancedFrame,
|
||||
CalendarFrame,
|
||||
CommonFrame,
|
||||
CustomFrame,
|
||||
} from 'src/explore/components/controls/DateFilterControl/components';
|
||||
import { CurrentCalendarFrame } from 'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
|
||||
import type { FrameType } from 'src/explore/components/controls/DateFilterControl/types';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
interface TimeRangeFilterProps {
|
||||
value?: string;
|
||||
onSubmit: (value: [string, string] | undefined) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StyledRangeType = styled(Select)`
|
||||
width: 272px;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
width: 600px;
|
||||
padding: ${theme.sizeUnit * 3}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
|
||||
.ant-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
padding: 4px 17px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ant-divider-horizontal {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
line-height: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-style: normal;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.control-anchor-to {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.control-anchor-to-datetime {
|
||||
width: 217px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: right;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
span {
|
||||
margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.error {
|
||||
color: ${({ theme }) => theme.colorError};
|
||||
}
|
||||
`;
|
||||
|
||||
function TimeRangeFilter(
|
||||
{ value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
const value = valueProp ?? defaultTimeFilter;
|
||||
const theme = useTheme();
|
||||
|
||||
// guessedFrame is only used for the initial useState — value is stable at
|
||||
// mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
|
||||
// always mounts fresh with the current committed value.
|
||||
const guessedFrame = useMemo(() => guessFrame(value), [value]);
|
||||
const [frame, setFrame] = useState<FrameType>(guessedFrame);
|
||||
const [timeRangeValue, setTimeRangeValue] = useState(value);
|
||||
const [evalResponse, setEvalResponse] = useState(value);
|
||||
const [validTimeRange, setValidTimeRange] = useState(false);
|
||||
const [lastFetched, setLastFetched] = useState(value);
|
||||
|
||||
// Evaluate the committed value shown in "Actual time range".
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
setEvalResponse(NO_TIME_RANGE);
|
||||
setValidTimeRange(true);
|
||||
return;
|
||||
}
|
||||
fetchTimeRange(value).then(({ value: actual, error }) => {
|
||||
if (error) {
|
||||
setEvalResponse(error ?? '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalResponse(actual ?? value);
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
setLastFetched(value);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Debounced evaluation of the in-progress selection (drives "Actual time range").
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
if (timeRangeValue === NO_TIME_RANGE) {
|
||||
setEvalResponse(NO_TIME_RANGE);
|
||||
setLastFetched(NO_TIME_RANGE);
|
||||
setValidTimeRange(true);
|
||||
return;
|
||||
}
|
||||
if (lastFetched !== timeRangeValue) {
|
||||
fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
|
||||
if (error) {
|
||||
setEvalResponse(error ?? '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalResponse(actual ?? '');
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
setLastFetched(timeRangeValue);
|
||||
});
|
||||
}
|
||||
},
|
||||
Constants.SLOW_DEBOUNCE,
|
||||
[timeRangeValue],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
onSubmit(undefined);
|
||||
},
|
||||
}));
|
||||
|
||||
function onChangeFrame(val: FrameType) {
|
||||
if (val === NO_TIME_RANGE) {
|
||||
setTimeRangeValue(NO_TIME_RANGE);
|
||||
}
|
||||
setFrame(val);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentWrapper>
|
||||
<div className="control-label">{t('Range type')}</div>
|
||||
<StyledRangeType
|
||||
ariaLabel={t('Range type')}
|
||||
options={FRAME_OPTIONS}
|
||||
value={frame}
|
||||
onChange={onChangeFrame}
|
||||
/>
|
||||
{frame !== 'No filter' && <Divider />}
|
||||
{frame === 'Common' && (
|
||||
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Calendar' && (
|
||||
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Current' && (
|
||||
<CurrentCalendarFrame
|
||||
value={timeRangeValue}
|
||||
onChange={setTimeRangeValue}
|
||||
/>
|
||||
)}
|
||||
{frame === 'Advanced' && (
|
||||
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Custom' && (
|
||||
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="section-title">{t('Actual time range')}</div>
|
||||
{validTimeRange && (
|
||||
<div>
|
||||
{evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
|
||||
</div>
|
||||
)}
|
||||
{!validTimeRange && (
|
||||
<IconWrapper className="warning">
|
||||
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
|
||||
<span className="text error">{evalResponse}</span>
|
||||
</IconWrapper>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="footer">
|
||||
<Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
|
||||
{t('CANCEL')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
cta
|
||||
disabled={!validTimeRange}
|
||||
key="apply"
|
||||
onClick={async () => {
|
||||
if (timeRangeValue === NO_TIME_RANGE) {
|
||||
onSubmit(undefined);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// fetchTimeRange returns a formatted display string ("X ≤ col < Y"),
|
||||
// not the raw since/until strings. Call the API directly to get them.
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
|
||||
});
|
||||
const since: string | undefined =
|
||||
response?.json?.result[0]?.since;
|
||||
const until: string | undefined =
|
||||
response?.json?.result[0]?.until;
|
||||
if (since !== undefined && until !== undefined) {
|
||||
onSubmit([since, until]);
|
||||
}
|
||||
} catch {
|
||||
// leave filter unchanged on error
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('APPLY')}
|
||||
</Button>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(TimeRangeFilter);
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
|
||||
@@ -97,7 +98,335 @@ test('search filter passes autoComplete prop correctly', () => {
|
||||
expect(input.autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
test('renders multiple search filters with different inputName values', () => {
|
||||
test('renders a compact pill trigger for select filters', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill shows active state (clear button) when a value is selected', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [{ label: 'Alice', value: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { label: 'Alice', value: 1 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate cold URL load: value has only numeric value, no label in cache
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { value: 1 } as any,
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The pill should be active (clear button visible) and the static label
|
||||
// should be resolved as the tooltip source
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Time range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range pill shows active state when a time range string is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last week',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear icon is inside the pill (not a separate button)
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(clearIcon).toBeInTheDocument();
|
||||
expect(pill).toContainElement(clearIcon);
|
||||
});
|
||||
|
||||
test('datetime_range pill is inactive when value is NO_TIME_RANGE', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'No filter',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range pill shows the time range string as tooltip title', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last month',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pill is active and clear icon is inside
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Age range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range pill shows active state when value is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'age_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [18, 65],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear age range filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range onClear calls updateFilterValue with undefined directly', async () => {
|
||||
const updateFilterValue = jest.fn();
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last week',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={updateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
await userEvent.click(clearIcon);
|
||||
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
test('numerical_range onClear calls updateFilterValue with undefined directly', async () => {
|
||||
const updateFilterValue = jest.fn();
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'age_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [18, 65],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={updateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearBtn = screen.getByRole('button', {
|
||||
name: /clear age range filter/i,
|
||||
});
|
||||
await userEvent.click(clearBtn);
|
||||
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
test('renders only the first search filter when multiple search filters are configured', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Name',
|
||||
@@ -125,8 +454,8 @@ test('renders multiple search filters with different inputName values', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only the first search filter renders — one search box per page
|
||||
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs[0].name).toBe('filter_name_search');
|
||||
expect(inputs[1].name).toBe('description');
|
||||
});
|
||||
|
||||
@@ -19,12 +19,16 @@
|
||||
import {
|
||||
createRef,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { withTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import type {
|
||||
ListViewFilterValue as FilterValue,
|
||||
@@ -33,10 +37,13 @@ import type {
|
||||
SelectOption,
|
||||
} from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import SearchFilter from './Search';
|
||||
import SelectFilter from './Select';
|
||||
import DateRangeFilter from './DateRange';
|
||||
import NumericalRangeFilter from './NumericalRange';
|
||||
import TimeRangeFilter from './TimeRange';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
interface UIFiltersProps {
|
||||
filters: Filters;
|
||||
@@ -46,7 +53,10 @@ interface UIFiltersProps {
|
||||
|
||||
function UIFilters(
|
||||
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
|
||||
ref: RefObject<{ clearFilters: () => void }>,
|
||||
ref: RefObject<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>,
|
||||
) {
|
||||
const filterRefs = useMemo(
|
||||
() =>
|
||||
@@ -54,125 +64,320 @@ function UIFilters(
|
||||
[filters.length],
|
||||
);
|
||||
|
||||
// Cache display labels for select filters so tooltip works after URL round-trip
|
||||
// (URL serialization strips the label, leaving only the value).
|
||||
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Evaluated human-readable labels for datetime_range pills (e.g. "2024-05-01 : 2024-05-31").
|
||||
const [timeRangeTooltips, setTimeRangeTooltips] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
|
||||
// On cold load, URL params restore values but not labels for fetchSelects filters.
|
||||
// Fetch the first page of options and cache the matching label so the tooltip works.
|
||||
useEffect(() => {
|
||||
filters.forEach((filter, index) => {
|
||||
if (filter.input !== 'select' || !filter.fetchSelects) return;
|
||||
if (tooltipLabels[index]) return;
|
||||
const val = internalFilters?.[index]?.value as SelectOption | undefined;
|
||||
if (!val?.value) return;
|
||||
filter.fetchSelects('', 0, 500).then(result => {
|
||||
const match = result?.data?.find(
|
||||
(s: SelectOption) => s.value === val.value,
|
||||
);
|
||||
if (match) {
|
||||
const lbl =
|
||||
typeof match.label === 'string'
|
||||
? match.label
|
||||
: String(match.value ?? '');
|
||||
setTooltipLabels(prev => ({ ...prev, [index]: lbl }));
|
||||
}
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [internalFilters]);
|
||||
|
||||
// Build datetime_range tooltips from the resolved [start, end] array value.
|
||||
// Handles both ISO strings and unix-ms numbers.
|
||||
useEffect(() => {
|
||||
filters.forEach((filter, index) => {
|
||||
if (filter.input !== 'datetime_range') return;
|
||||
const val = internalFilters?.[index]?.value;
|
||||
if (Array.isArray(val) && val.length === 2) {
|
||||
const fmt = (v: unknown) => {
|
||||
const d = new Date(v as string | number);
|
||||
return isNaN(d.getTime())
|
||||
? String(v)
|
||||
: d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
};
|
||||
const tooltip = `${fmt(val[0])} – ${fmt(val[1])}`;
|
||||
setTimeRangeTooltips(prev =>
|
||||
prev[index] === tooltip ? prev : { ...prev, [index]: tooltip },
|
||||
);
|
||||
} else {
|
||||
setTimeRangeTooltips(prev => {
|
||||
if (!(index in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [filters, internalFilters]);
|
||||
|
||||
const clearFilterAtIndex = useCallback(
|
||||
(index: number) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
setTooltipLabels(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[updateFilterValue],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilters: () => {
|
||||
filterRefs.forEach((filter: any) => {
|
||||
filter.current?.clearFilter?.();
|
||||
filterRefs.forEach((_, index) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
});
|
||||
setTooltipLabels({});
|
||||
setTimeRangeTooltips({});
|
||||
},
|
||||
clearFilterById: (id: string) => {
|
||||
const index = filters.findIndex(f => f.id === id);
|
||||
if (index >= 0) {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
clearFilterAtIndex(index);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<>
|
||||
{filters.map(
|
||||
(
|
||||
{
|
||||
Header,
|
||||
fetchSelects,
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
optionFilterProps,
|
||||
paginate,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
loading,
|
||||
dateFilterValueType,
|
||||
min,
|
||||
max,
|
||||
popupStyle,
|
||||
autoComplete,
|
||||
inputName,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
const initialValue = internalFilters?.[index]?.value;
|
||||
if (input === 'select') {
|
||||
return (
|
||||
<SelectFilter
|
||||
// Search always leads the filter bar regardless of declaration order.
|
||||
// Only the first search filter renders; subsequent ones are skipped (see note below).
|
||||
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
|
||||
// Group Key on RLS) are not currently accessible via the filter bar. Those
|
||||
// pages previously relied on multiple inline inputs. This is a known UX
|
||||
// trade-off — revisit if admin workflows require additional search fields.
|
||||
let searchFilterRendered = false;
|
||||
|
||||
// Render in two passes: search first, then all other filter types.
|
||||
const renderFilter = (_: (typeof filters)[number], index: number) => {
|
||||
const {
|
||||
Header,
|
||||
fetchSelects,
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
loading,
|
||||
min,
|
||||
max,
|
||||
autoComplete,
|
||||
inputName,
|
||||
popupStyle,
|
||||
dateFilterValueType,
|
||||
} = filters[index];
|
||||
const initialValue = internalFilters?.[index]?.value;
|
||||
if (input === 'select') {
|
||||
const selectValue = initialValue as SelectOption | undefined;
|
||||
// Prefer cached label (survives URL round-trips where only the value
|
||||
// is preserved). Fall back to the static selects list for cold loads.
|
||||
const cachedLabel = tooltipLabels[index];
|
||||
const staticFallback = cachedLabel
|
||||
? undefined
|
||||
: selects?.find(s => s.value === selectValue?.value)?.label;
|
||||
const tooltipTitle = !!selectValue
|
||||
? cachedLabel ||
|
||||
(typeof staticFallback === 'string' ? staticFallback : undefined)
|
||||
: t('Choose...');
|
||||
return (
|
||||
<span key={key} data-test="select-filter-container">
|
||||
<CompactFilterTrigger
|
||||
label={Header}
|
||||
hasValue={!!selectValue}
|
||||
tooltipTitle={tooltipTitle}
|
||||
onClear={() => clearFilterAtIndex(index)}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
selects={selects}
|
||||
fetchSelects={fetchSelects}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={id}
|
||||
value={initialValue as SelectOption | undefined}
|
||||
loading={loading ?? false}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelStyle={popupStyle}
|
||||
onSelect={(
|
||||
option: SelectOption | undefined,
|
||||
isClear?: boolean,
|
||||
) => {
|
||||
if (onFilterUpdate) {
|
||||
// Filter change triggers both onChange AND onClear, only want to track onChange
|
||||
if (!isClear) {
|
||||
onFilterUpdate(option);
|
||||
}
|
||||
if (option && !isClear) {
|
||||
setTooltipLabels(prev => ({
|
||||
...prev,
|
||||
[index]:
|
||||
typeof option.label === 'string'
|
||||
? option.label
|
||||
: String(option.value ?? ''),
|
||||
}));
|
||||
}
|
||||
if (onFilterUpdate && !isClear) {
|
||||
onFilterUpdate(option);
|
||||
}
|
||||
|
||||
updateFilterValue(index, option);
|
||||
}}
|
||||
optionFilterProps={optionFilterProps}
|
||||
paginate={paginate}
|
||||
selects={selects}
|
||||
loading={loading ?? false}
|
||||
dropdownStyle={popupStyle}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={inputName ?? id}
|
||||
toolTipDescription={toolTipDescription}
|
||||
onSubmit={(value: string) => {
|
||||
if (onFilterUpdate) {
|
||||
onFilterUpdate(value);
|
||||
}
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
if (searchFilterRendered) return null;
|
||||
searchFilterRendered = true;
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={inputName ?? id}
|
||||
toolTipDescription={toolTipDescription}
|
||||
onSubmit={(value: string) => {
|
||||
if (onFilterUpdate) {
|
||||
onFilterUpdate(value);
|
||||
}
|
||||
|
||||
updateFilterValue(index, value);
|
||||
}}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'datetime_range') {
|
||||
// dateFilterValueType absent or 'unix': column stores unix ms (e.g. Query History start_time).
|
||||
// 'iso': column stores ISO date strings (e.g. UsersList created_on, ActionLog dttm).
|
||||
const isUnixType = !dateFilterValueType || dateFilterValueType === 'unix';
|
||||
|
||||
// initialValue may be [ms, ms] (unix), ["iso","iso"] (iso), or legacy string.
|
||||
// Always reconstruct panelValue as "ISO : ISO" so the TimeRange panel
|
||||
// can parse it as a Custom date range regardless of storage type.
|
||||
let resolvedIsoRange: [string, string] | null = null;
|
||||
if (Array.isArray(initialValue) && initialValue.length === 2) {
|
||||
if (typeof initialValue[0] === 'number') {
|
||||
resolvedIsoRange = [
|
||||
new Date(initialValue[0]).toISOString(),
|
||||
new Date(initialValue[1] as number).toISOString(),
|
||||
];
|
||||
} else if (typeof initialValue[0] === 'string') {
|
||||
resolvedIsoRange = initialValue as [string, string];
|
||||
}
|
||||
}
|
||||
const legacyStringVal =
|
||||
!resolvedIsoRange &&
|
||||
typeof initialValue === 'string' &&
|
||||
initialValue !== NO_TIME_RANGE
|
||||
? initialValue
|
||||
: null;
|
||||
const hasTimeValue = !!(resolvedIsoRange || legacyStringVal);
|
||||
const panelValue =
|
||||
resolvedIsoRange?.join(' : ') ?? legacyStringVal ?? undefined;
|
||||
return (
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
label={Header}
|
||||
hasValue={hasTimeValue}
|
||||
tooltipTitle={
|
||||
hasTimeValue ? (timeRangeTooltips[index] ?? panelValue) : undefined
|
||||
}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
updateFilterValue(index, undefined);
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<TimeRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
value={panelValue}
|
||||
onClose={onClose}
|
||||
onSubmit={value => {
|
||||
if (!value) {
|
||||
updateFilterValue(index, undefined);
|
||||
} else if (isUnixType) {
|
||||
// Convert ISO strings to unix ms for numeric columns
|
||||
updateFilterValue(index, [
|
||||
new Date(value[0]).getTime(),
|
||||
new Date(value[1]).getTime(),
|
||||
]);
|
||||
} else {
|
||||
updateFilterValue(index, value);
|
||||
}}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'datetime_range') {
|
||||
return (
|
||||
<DateRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
dateFilterValueType={dateFilterValueType || 'unix'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
return (
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
const hasRangeValue =
|
||||
Array.isArray(initialValue) &&
|
||||
initialValue.some(v => v !== null && v !== undefined);
|
||||
const rangeTooltip = hasRangeValue
|
||||
? (initialValue as (number | null | undefined)[])
|
||||
.filter(v => v !== null && v !== undefined)
|
||||
.join(' – ')
|
||||
: undefined;
|
||||
return (
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
label={Header}
|
||||
hasValue={hasRangeValue}
|
||||
tooltipTitle={rangeTooltip}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
updateFilterValue(index, undefined);
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<NumericalRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
min={min}
|
||||
max={max}
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
</FilterPopoverContent>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search first */}
|
||||
{filters.map((_, index) =>
|
||||
filters[index].input === 'search'
|
||||
? renderFilter(filters[index], index)
|
||||
: null,
|
||||
)}
|
||||
{/* Then all other filter types */}
|
||||
{filters.map((_, index) =>
|
||||
filters[index].input !== 'search'
|
||||
? renderFilter(filters[index], index)
|
||||
: null,
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -301,15 +301,19 @@ describe('ListView', () => {
|
||||
});
|
||||
|
||||
test('renders UI filters', () => {
|
||||
const filterControls = screen.getAllByRole('combobox');
|
||||
expect(filterControls).toHaveLength(2);
|
||||
// select and datetime_range filters render as compact pill buttons;
|
||||
// search filter renders as a text input
|
||||
const filterPills = screen.getAllByTestId('compact-filter-pill');
|
||||
expect(filterPills).toHaveLength(3); // ID, Age, Time
|
||||
});
|
||||
|
||||
test('calls fetchData on filter', async () => {
|
||||
// Handle select filter
|
||||
const selectFilter = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.click(selectFilter);
|
||||
const option = screen.getByText('foo');
|
||||
// Click the ID compact pill to open its option panel
|
||||
const idPill = screen.getByRole('button', { name: 'ID' });
|
||||
await userEvent.click(idPill);
|
||||
|
||||
// Wait for and click the 'foo' option in the dropdown panel
|
||||
const option = await screen.findByRole('option', { name: 'foo' });
|
||||
await userEvent.click(option);
|
||||
|
||||
// Handle search filter
|
||||
@@ -341,7 +345,10 @@ describe('ListView', () => {
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
const sortSelect = screen.getByTestId('card-sort-select');
|
||||
const sortSelectContainer = screen.getByTestId('card-sort-select');
|
||||
const sortSelect = sortSelectContainer.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement;
|
||||
await userEvent.click(sortSelect);
|
||||
|
||||
const sortOption = screen.getByText('Alphabetical');
|
||||
|
||||
@@ -65,13 +65,43 @@ const ListViewStyles = styled.div`
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: ${theme.sizeUnit * 4}px;
|
||||
|
||||
& .controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
column-gap: ${theme.sizeUnit * 7}px;
|
||||
row-gap: ${theme.sizeUnit * 4}px;
|
||||
align-items: center;
|
||||
column-gap: ${theme.sizeUnit * 2}px;
|
||||
row-gap: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Search input — fixed width/height matching pill height, label hidden */
|
||||
[data-test='search-filter-container'] {
|
||||
width: ${theme.sizeUnit * 44}px;
|
||||
flex-shrink: 0;
|
||||
height: ${theme.controlHeight}px;
|
||||
align-self: center;
|
||||
/* Hide the FormLabel Flex wrapper entirely so it doesn't affect
|
||||
the column's justify-content centering calculation. */
|
||||
> .ant-flex {
|
||||
display: none;
|
||||
}
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
.ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Select filter pill wrappers — make them proper flex items so the
|
||||
inline-flex button inside doesn't introduce line-box quirks. */
|
||||
[data-test='select-filter-container'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +197,6 @@ const bulkSelectColumnConfig = {
|
||||
const ViewModeContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
margin-top: ${theme.sizeUnit * 5 + 1}px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
|
||||
@@ -192,6 +221,29 @@ const ViewModeContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const ClearAllButton = styled.button`
|
||||
${({ theme }) => `
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
color: ${theme.colorPrimary};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: ${theme.controlHeight}px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${theme.colorPrimaryHover};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${theme.colorTextDisabled};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmptyWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.sizeUnit * 40}px 0;
|
||||
@@ -356,6 +408,14 @@ export function ListView<T extends object = any>({
|
||||
clearFilterById: (id: string) => void;
|
||||
}>(null);
|
||||
|
||||
const hasActiveFilters = internalFilters.some(f => {
|
||||
if (f.value === null || f.value === undefined || f.value === '')
|
||||
return false;
|
||||
if (Array.isArray(f.value))
|
||||
return f.value.some(v => v !== null && v !== undefined && v !== '');
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wire the optional external filtersRef to our internal filterControlsRef.
|
||||
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
|
||||
// ref is populated before the first paint and after every update.
|
||||
@@ -421,6 +481,21 @@ export function ListView<T extends object = any>({
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
<span>
|
||||
<ClearAllButton
|
||||
type="button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={() => filterControlsRef.current?.clearFilters()}
|
||||
>
|
||||
{t('Clear all')}
|
||||
</ClearAllButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
|
||||
|
||||
@@ -31,6 +31,7 @@ import 'dayjs/locale/pt';
|
||||
import 'dayjs/locale/pt-br';
|
||||
import 'dayjs/locale/ru';
|
||||
import 'dayjs/locale/ko';
|
||||
import 'dayjs/locale/cs';
|
||||
import 'dayjs/locale/sk';
|
||||
import 'dayjs/locale/sl';
|
||||
import 'dayjs/locale/nl';
|
||||
@@ -50,6 +51,7 @@ export const LOCALE_MAPPING = {
|
||||
pt_BR: () => import('antd/locale/pt_BR'),
|
||||
ru: () => import('antd/locale/ru_RU'),
|
||||
ko: () => import('antd/locale/ko_KR'),
|
||||
cs: () => import('antd/locale/cs_CZ'),
|
||||
sk: () => import('antd/locale/sk_SK'),
|
||||
sl: () => import('antd/locale/sl_SI'),
|
||||
nl: () => import('antd/locale/nl_NL'),
|
||||
|
||||
@@ -57,9 +57,12 @@ const mockUser = {
|
||||
const findFilterByLabel = (labelText: string) => {
|
||||
const containers = screen.getAllByTestId('select-filter-container');
|
||||
for (const container of containers) {
|
||||
const label = container.querySelector('label');
|
||||
if (label?.textContent === labelText) {
|
||||
return container.querySelector('[role="combobox"], .ant-select');
|
||||
// Compact pill filters show the label as button text
|
||||
const pill = container.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement | null;
|
||||
if (pill && pill.textContent?.includes(labelText)) {
|
||||
return pill;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -156,18 +156,16 @@ describe('DashboardList Card View Tests', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the sort select by its testId, then the combobox within it
|
||||
// Find the sort select by its testId, then the pill button within it
|
||||
const sortContainer = screen.getByTestId('card-sort-select');
|
||||
const sortCombobox = within(sortContainer).getByRole('combobox');
|
||||
await userEvent.click(sortCombobox);
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const sortPill = sortContainer.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement;
|
||||
await userEvent.click(sortPill);
|
||||
|
||||
// Select "Alphabetical" from the dropdown
|
||||
const alphabeticalOption = await waitFor(() =>
|
||||
within(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
document.querySelector('.rc-virtual-list')!,
|
||||
).getByText('Alphabetical'),
|
||||
);
|
||||
const alphabeticalOption = await screen.findByText('Alphabetical');
|
||||
await userEvent.click(alphabeticalOption);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
screen,
|
||||
selectOption,
|
||||
selectPillOption,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API call', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
await selectPillOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
@@ -242,7 +242,7 @@ test('selecting Owner filter encodes rel_m_m owner in API call', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectOption('Admin User', 'Owner');
|
||||
await selectPillOption('Admin User', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
@@ -287,7 +287,7 @@ test('selecting Modified by filter encodes rel_o_m changed_by in API call', asyn
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectOption('Admin User', 'Modified by');
|
||||
await selectPillOption('Admin User', 'Modified by');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
|
||||
@@ -20,7 +20,7 @@ import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { selectOption } from 'spec/helpers/testing-library';
|
||||
import { selectPillOption } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
setupMocks,
|
||||
renderDatasetList,
|
||||
@@ -102,11 +102,11 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
|
||||
).toBeGreaterThan(callsBeforeSort);
|
||||
});
|
||||
|
||||
// 2. Apply a filter using selectOption helper
|
||||
// 2. Apply a filter using selectPillOption helper (compact pill UI)
|
||||
const beforeFilterCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
await selectOption('Virtual', 'Type');
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { selectOption } from 'spec/helpers/testing-library';
|
||||
import { selectPillOption } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
setupMocks,
|
||||
renderDatasetList,
|
||||
@@ -1510,11 +1510,8 @@ test('bulk selection clears when filter changes', async () => {
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Wait for filter combobox to be ready before applying filter
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Apply a filter using selectOption helper
|
||||
await selectOption('Virtual', 'Type');
|
||||
// Apply a filter using selectPillOption helper (compact pill UI)
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
@@ -1556,16 +1553,13 @@ test('type filter API call includes correct filter parameter', async () => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for Type filter combobox
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter
|
||||
await selectOption('Virtual', 'Type');
|
||||
// Apply Type filter using compact pill UI
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
@@ -1606,16 +1600,13 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for Type filter combobox
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter
|
||||
await selectOption('Virtual', 'Type');
|
||||
// Apply Type filter using compact pill UI
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -200,8 +200,8 @@ test('renders Name search filter', async () => {
|
||||
test('renders Type filter (Virtual/Physical dropdown)', async () => {
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
// Filter dropdowns should be present
|
||||
const filters = await screen.findAllByRole('combobox');
|
||||
// Filter pills should be present (compact pill UI)
|
||||
const filters = await screen.findAllByTestId('compact-filter-pill');
|
||||
expect(filters.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -445,7 +445,8 @@ test('selecting Database filter triggers API call with database relation filter'
|
||||
|
||||
await waitForDatasetsPageReady();
|
||||
|
||||
const filtersContainers = screen.getAllByRole('combobox');
|
||||
// Filter pills should be present (compact pill UI replaces comboboxes)
|
||||
const filtersContainers = screen.getAllByTestId('compact-filter-pill');
|
||||
expect(filtersContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -121,13 +121,17 @@ describe('GroupsList', () => {
|
||||
|
||||
test('renders the filters correctly', async () => {
|
||||
await renderComponent();
|
||||
const filtersSelect = screen.getAllByTestId('filters-select')[0];
|
||||
|
||||
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
|
||||
// The compact filter UI renders the first search filter as an input,
|
||||
// and select filters as pill buttons. Only "Name" search renders inline;
|
||||
// "Label" and "Description" searches are hidden (one search box per page).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
|
||||
// Select filters render as compact pill buttons
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const pillLabels = pills.map(p => p.textContent ?? '');
|
||||
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
|
||||
expect(pillLabels.some(l => /users/i.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
test('renders correct columns in the table', async () => {
|
||||
|
||||
@@ -151,8 +151,11 @@ describe('RolesList', () => {
|
||||
test('renders filters options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(4);
|
||||
// Compact filter UI: one search input for "Name" and 3 select pills
|
||||
// (Users, Permissions, Groups).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
const selectContainers = screen.getAllByTestId('select-filter-container');
|
||||
expect(selectContainers).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -166,11 +166,14 @@ describe('RuleList RTL', () => {
|
||||
test('renders filter options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
// Compact filter UI: only the first search filter renders (Name),
|
||||
// subsequent search filters (Group Key) are hidden — one search box per page.
|
||||
const searchFilters = screen.queryAllByTestId('filters-search');
|
||||
expect(searchFilters).toHaveLength(2);
|
||||
expect(searchFilters).toHaveLength(1);
|
||||
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
|
||||
// Select filters render as compact pill buttons (Filter Type, Modified by)
|
||||
const selectContainers = screen.queryAllByTestId('select-filter-container');
|
||||
expect(selectContainers).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -138,16 +138,16 @@ describe('UsersList', () => {
|
||||
test('renders filters options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
const submenu = screen.queryAllByTestId('filters-select')[0];
|
||||
expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
|
||||
// The compact filter UI shows: only the first search filter as an input,
|
||||
// and select/datetime filters as pill buttons. Only "First name" search
|
||||
// renders (subsequent search filters are hidden — one search box per page).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
|
||||
// Select and datetime filters render as compact pill buttons
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const pillLabels = pills.map(p => p.textContent ?? '');
|
||||
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
|
||||
expect(pillLabels.some(l => /is active\?/i.test(l))).toBe(true);
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -145,12 +145,28 @@ Database Connections:
|
||||
- list_databases: List database connections with advanced filters (1-based pagination)
|
||||
- get_database_info: Get detailed database connection info by ID (backend, capabilities)
|
||||
|
||||
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)
|
||||
|
||||
Themes:
|
||||
- list_themes: List themes with advanced filters (1-based pagination)
|
||||
- get_theme_info: Get theme details by ID or UUID (includes json_data configuration)
|
||||
|
||||
User and Role Management:
|
||||
- list_users: List users with filtering (1-based pagination, admin only)
|
||||
- get_user_info: Get user details by ID (admin only)
|
||||
- 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)
|
||||
@@ -178,7 +194,7 @@ SQL Lab Integration:
|
||||
- get_query_info: Get SQL query history details by ID
|
||||
|
||||
Schema Discovery:
|
||||
- get_schema: Get schema metadata for chart/dataset/dashboard (columns, filters)
|
||||
- get_schema: Get schema metadata for chart/dataset/dashboard/database/css_template/theme (columns, filters)
|
||||
|
||||
Action Logs (requires SUPERSET_LOG_VIEW and FAB_ADD_SECURITY_VIEWS):
|
||||
- list_action_logs: List user action logs with filtering and pagination (defaults to last 7 days)
|
||||
@@ -393,9 +409,10 @@ IMPORTANT - Tool-Only Interaction:
|
||||
|
||||
General usage tips:
|
||||
- All listing tools use 1-based pagination (first page is 1)
|
||||
- Use get_schema to discover filterable columns, sortable columns, and default columns
|
||||
for chart/dataset/dashboard/database. For action_log and task tools, consult each
|
||||
tool's docstring — filterable and sortable columns are listed there directly.
|
||||
- Use get_schema (chart/dataset/dashboard/database) to discover filterable columns,
|
||||
sortable columns, and default columns for those resource types
|
||||
- For action_log, task, list_rls_filters, and list_plugins tools, filterable/sortable
|
||||
columns are listed inline in each tool's docstring — get_schema does not cover these
|
||||
- Use 'filters' parameter for advanced queries with filter columns from get_schema
|
||||
- IDs can be integer or UUID format where supported
|
||||
- All tools return structured, Pydantic-typed responses
|
||||
@@ -676,6 +693,10 @@ from superset.mcp_service.chart.tool import ( # noqa: F401, E402
|
||||
update_chart,
|
||||
update_chart_preview,
|
||||
)
|
||||
from superset.mcp_service.css_template.tool import ( # noqa: F401, E402
|
||||
get_css_template_info,
|
||||
list_css_templates,
|
||||
)
|
||||
from superset.mcp_service.dashboard.tool import ( # noqa: F401, E402
|
||||
add_chart_to_existing_dashboard,
|
||||
generate_dashboard,
|
||||
@@ -696,10 +717,18 @@ from superset.mcp_service.dataset.tool import ( # noqa: F401, E402
|
||||
from superset.mcp_service.explore.tool import ( # noqa: F401, E402
|
||||
generate_explore_link,
|
||||
)
|
||||
from superset.mcp_service.plugin.tool import ( # noqa: F401, E402
|
||||
get_plugin_info,
|
||||
list_plugins,
|
||||
)
|
||||
from superset.mcp_service.query.tool import ( # noqa: F401, E402
|
||||
get_query_info,
|
||||
list_queries,
|
||||
)
|
||||
from superset.mcp_service.rls.tool import ( # noqa: F401, E402
|
||||
get_rls_filter_info,
|
||||
list_rls_filters,
|
||||
)
|
||||
from superset.mcp_service.role.tool import ( # noqa: F401, E402
|
||||
get_role_info,
|
||||
list_roles,
|
||||
@@ -732,6 +761,10 @@ from superset.mcp_service.task.tool import ( # noqa: F401, E402
|
||||
get_task_info,
|
||||
list_tasks,
|
||||
)
|
||||
from superset.mcp_service.theme.tool import ( # noqa: F401, E402
|
||||
get_theme_info,
|
||||
list_themes,
|
||||
)
|
||||
from superset.mcp_service.user.tool import ( # noqa: F401, E402
|
||||
get_user_info,
|
||||
list_users,
|
||||
|
||||
@@ -635,3 +635,82 @@ CHART_ALL_COLUMNS: list[str] = []
|
||||
DATASET_ALL_COLUMNS: list[str] = []
|
||||
DASHBOARD_ALL_COLUMNS: list[str] = []
|
||||
DATABASE_ALL_COLUMNS: list[str] = []
|
||||
|
||||
|
||||
# CSS Template configuration
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"uuid",
|
||||
"template_name",
|
||||
]
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"template_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
CSS_TEMPLATE_SEARCH_COLUMNS = ["template_name"]
|
||||
CSS_TEMPLATE_FILTER_COLUMNS: dict[str, list[str]] = {
|
||||
"template_name": ["eq", "sw", "ilike"],
|
||||
"created_by_fk": ["eq"],
|
||||
}
|
||||
CSS_TEMPLATE_EXTRA_COLUMNS: dict[str, ColumnMetadata] = {
|
||||
"created_by_name": ColumnMetadata(
|
||||
name="created_by_name",
|
||||
description="Username of the creator",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
"changed_by_name": ColumnMetadata(
|
||||
name="changed_by_name",
|
||||
description="Username of the last modifier",
|
||||
type="str",
|
||||
is_default=False,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def get_css_template_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for CssTemplate model dynamically."""
|
||||
from superset.models.core import CssTemplate
|
||||
|
||||
return get_columns_from_model(
|
||||
CssTemplate,
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_EXTRA_COLUMNS,
|
||||
exclude_columns=set(USER_DIRECTORY_FIELDS)
|
||||
- {"created_by_name", "changed_by_name"},
|
||||
)
|
||||
|
||||
|
||||
# Theme configuration
|
||||
THEME_DEFAULT_COLUMNS = [
|
||||
"id",
|
||||
"theme_name",
|
||||
"uuid",
|
||||
]
|
||||
THEME_SORTABLE_COLUMNS = [
|
||||
"id",
|
||||
"theme_name",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
THEME_SEARCH_COLUMNS = ["theme_name"]
|
||||
THEME_FILTER_COLUMNS: dict[str, list[str]] = {
|
||||
"theme_name": ["eq", "sw", "ilike"],
|
||||
"is_system": ["eq"],
|
||||
"is_system_default": ["eq"],
|
||||
"is_system_dark": ["eq"],
|
||||
"created_by_fk": ["eq"],
|
||||
}
|
||||
|
||||
|
||||
def get_theme_columns() -> list[ColumnMetadata]:
|
||||
"""Get column metadata for Theme model dynamically."""
|
||||
from superset.models.core import Theme
|
||||
|
||||
return get_columns_from_model(
|
||||
Theme,
|
||||
THEME_DEFAULT_COLUMNS,
|
||||
exclude_columns=set(USER_DIRECTORY_FIELDS),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,9 @@
|
||||
from typing import Literal
|
||||
|
||||
# Supported model types for schema discovery and MCP tools
|
||||
ModelType = Literal["chart", "dataset", "dashboard", "database"]
|
||||
ModelType = Literal[
|
||||
"chart", "dataset", "dashboard", "database", "css_template", "theme"
|
||||
]
|
||||
|
||||
# Pagination defaults
|
||||
DEFAULT_PAGE_SIZE = 10 # Default number of items per page
|
||||
|
||||
16
superset/mcp_service/css_template/__init__.py
Normal file
16
superset/mcp_service/css_template/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
280
superset/mcp_service/css_template/schemas.py
Normal file
280
superset/mcp_service/css_template/schemas.py
Normal file
@@ -0,0 +1,280 @@
|
||||
# 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 CSS template-related 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.common.cache_schemas import MetadataCacheControl
|
||||
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.sanitization import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
|
||||
class CssTemplateFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for CSS template listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal["template_name", "created_by_fk"] = Field(
|
||||
...,
|
||||
description="Column to filter on. Use get_schema(model_type='css_template') "
|
||||
"for available filter columns. To filter by creator, first call find_users "
|
||||
"to resolve a name to a user ID, then filter by created_by_fk with "
|
||||
"that integer ID.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class CssTemplateInfo(BaseModel):
|
||||
id: int | None = Field(None, description="CSS template ID")
|
||||
uuid: str | None = Field(None, description="CSS template UUID")
|
||||
template_name: str | None = Field(None, description="CSS template name")
|
||||
css: str | None = Field(
|
||||
None,
|
||||
description="CSS content (can be large; request via select_columns=['css'])",
|
||||
)
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
created_by_name: str | None = Field(None, description="Username of the creator")
|
||||
changed_by_name: str | None = Field(
|
||||
None, description="Username of the last modifier"
|
||||
)
|
||||
|
||||
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]:
|
||||
"""Filter fields based on serialization context.
|
||||
|
||||
If context contains 'select_columns', only include those fields.
|
||||
Otherwise, include all fields (default behavior).
|
||||
"""
|
||||
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 CssTemplateList(BaseModel):
|
||||
css_templates: List[CssTemplateInfo]
|
||||
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,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each CSS template",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[CssTemplateFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListCssTemplatesRequest(MetadataCacheControl):
|
||||
"""Request schema for list_css_templates."""
|
||||
|
||||
filters: Annotated[
|
||||
List[CssTemplateFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Cannot be "
|
||||
"used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified. Use select_columns=['css'] to include the CSS content.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against CSS template fields. "
|
||||
"Cannot be used together 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="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[CssTemplateFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, CssTemplateFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListCssTemplatesRequest":
|
||||
"""Prevent using both search and filters simultaneously."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class CssTemplateError(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")
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str) -> str:
|
||||
"""Wrap error text before it is exposed to LLM context."""
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "CssTemplateError":
|
||||
"""Create a standardized CssTemplateError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetCssTemplateInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_css_template_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="CSS template identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_css_template_info_for_llm_context(
|
||||
css_template_info: CssTemplateInfo,
|
||||
) -> CssTemplateInfo:
|
||||
"""Wrap CSS template user-controlled fields before LLM exposure."""
|
||||
payload = css_template_info.model_dump(mode="python")
|
||||
for field_name in ("template_name", "css", "created_by_name", "changed_by_name"):
|
||||
payload[field_name] = sanitize_for_llm_context(
|
||||
payload.get(field_name),
|
||||
field_path=(field_name,),
|
||||
)
|
||||
return CssTemplateInfo.model_validate(payload)
|
||||
|
||||
|
||||
def serialize_css_template_object(obj: Any) -> CssTemplateInfo | None:
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
return _sanitize_css_template_info_for_llm_context(
|
||||
CssTemplateInfo(
|
||||
id=getattr(obj, "id", None),
|
||||
uuid=str(getattr(obj, "uuid", "")) if getattr(obj, "uuid", None) else None,
|
||||
template_name=getattr(obj, "template_name", None),
|
||||
css=getattr(obj, "css", None),
|
||||
changed_on=getattr(obj, "changed_on", None),
|
||||
created_on=getattr(obj, "created_on", None),
|
||||
created_by_name=getattr(obj, "created_by_name", None) or None,
|
||||
changed_by_name=getattr(obj, "changed_by_name", None) or None,
|
||||
)
|
||||
)
|
||||
24
superset/mcp_service/css_template/tool/__init__.py
Normal file
24
superset/mcp_service/css_template/tool/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_css_template_info import get_css_template_info
|
||||
from .list_css_templates import list_css_templates
|
||||
|
||||
__all__ = [
|
||||
"list_css_templates",
|
||||
"get_css_template_info",
|
||||
]
|
||||
108
superset/mcp_service/css_template/tool/get_css_template_info.py
Normal file
108
superset/mcp_service/css_template/tool/get_css_template_info.py
Normal file
@@ -0,0 +1,108 @@
|
||||
# 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 CSS template 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.css_template.schemas import (
|
||||
CssTemplateError,
|
||||
CssTemplateInfo,
|
||||
GetCssTemplateInfoRequest,
|
||||
serialize_css_template_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="CssTemplate",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get CSS template info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_css_template_info(
|
||||
request: GetCssTemplateInfoRequest, ctx: Context
|
||||
) -> CssTemplateInfo | CssTemplateError:
|
||||
"""Get CSS template details by ID or UUID.
|
||||
|
||||
Returns the full CSS template including the css content.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a CSS template ID, use the list_css_templates tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving CSS template information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_css_template_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=CssTemplateInfo,
|
||||
error_schema=CssTemplateError,
|
||||
serializer=serialize_css_template_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, CssTemplateInfo):
|
||||
await ctx.info(
|
||||
"CSS template information retrieved successfully: "
|
||||
"id=%s, template_name=%s" % (result.id, result.template_name)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"CSS template retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"CSS template information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
return CssTemplateError(
|
||||
error=f"Failed to get CSS template info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
150
superset/mcp_service/css_template/tool/list_css_templates.py
Normal file
150
superset/mcp_service/css_template/tool/list_css_templates.py
Normal file
@@ -0,0 +1,150 @@
|
||||
# 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 CSS templates FastMCP tool
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import CssTemplate
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
CssTemplateError,
|
||||
CssTemplateFilter,
|
||||
CssTemplateInfo,
|
||||
CssTemplateList,
|
||||
ListCssTemplatesRequest,
|
||||
serialize_css_template_object,
|
||||
)
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_CSS_TEMPLATES_REQUEST = ListCssTemplatesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="CssTemplate",
|
||||
annotations=ToolAnnotations(
|
||||
title="List CSS templates",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_css_templates(
|
||||
request: ListCssTemplatesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> CssTemplateList | CssTemplateError:
|
||||
"""List CSS templates with filtering and search.
|
||||
|
||||
Returns CSS template metadata including name. Use select_columns=['css']
|
||||
to include the CSS content (omitted by default due to size).
|
||||
|
||||
Sortable columns for order_column: id, template_name, changed_on,
|
||||
created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_css_templates")
|
||||
|
||||
request = request or _DEFAULT_LIST_CSS_TEMPLATES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing CSS templates: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
await ctx.debug(
|
||||
"CSS template listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
get_all_column_names,
|
||||
get_css_template_columns,
|
||||
)
|
||||
|
||||
all_columns = get_all_column_names(get_css_template_columns())
|
||||
|
||||
def _serialize_css_template(
|
||||
obj: "CssTemplate | None", cols: list[str] | None
|
||||
) -> CssTemplateInfo | None:
|
||||
return serialize_css_template_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=CssTemplateInfo,
|
||||
item_serializer=_serialize_css_template,
|
||||
filter_type=CssTemplateFilter,
|
||||
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
search_columns=["template_name"],
|
||||
list_field_name="css_templates",
|
||||
output_list_schema=CssTemplateList,
|
||||
all_columns=all_columns,
|
||||
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_css_templates.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(
|
||||
"CSS templates listed successfully: count=%s, total_count=%s"
|
||||
% (
|
||||
len(result.css_templates) if hasattr(result, "css_templates") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
with event_logger.log_context(action="mcp.list_css_templates.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"CSS template listing failed: page=%s, page_size=%s, error=%s, "
|
||||
"error_type=%s"
|
||||
% (request.page, request.page_size, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -733,6 +733,7 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
default_sort: str = "changed_on",
|
||||
default_sort_direction: Literal["asc", "desc"] = "desc",
|
||||
exclude_filter_columns: set[str] | None = None,
|
||||
filter_columns_override: dict[str, list[str]] | None = None,
|
||||
logger: logging.Logger | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -750,6 +751,9 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
default_sort_direction: Default sort direction
|
||||
exclude_filter_columns: Column names to omit from filter discovery
|
||||
(e.g., sensitive fields like passwords or connection URIs)
|
||||
filter_columns_override: When set, use this mapping directly as the
|
||||
filter_columns output instead of querying the DAO. Use this to
|
||||
restrict advertised filters to the exact set the list tool accepts.
|
||||
logger: Optional logger instance
|
||||
"""
|
||||
super().__init__(logger)
|
||||
@@ -770,9 +774,12 @@ class ModelGetSchemaCore(BaseCore, Generic[S]):
|
||||
# Hide user-directory columns from filter discovery, except the small
|
||||
# set callers may legitimately filter by ID (resolved via find_users).
|
||||
self.exclude_filter_columns.update(USER_DIRECTORY_FIELDS - USER_FILTER_FIELDS)
|
||||
self.filter_columns_override = filter_columns_override
|
||||
|
||||
def _get_filter_columns(self) -> Dict[str, List[str]]:
|
||||
"""Get filterable columns and operators from the DAO."""
|
||||
if self.filter_columns_override is not None:
|
||||
return self.filter_columns_override
|
||||
try:
|
||||
filterable = self.dao_class.get_filterable_columns_and_operators()
|
||||
# Defensive handling: ensure we have a valid mapping
|
||||
|
||||
16
superset/mcp_service/plugin/__init__.py
Normal file
16
superset/mcp_service/plugin/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
23
superset/mcp_service/plugin/dao.py
Normal file
23
superset/mcp_service/plugin/dao.py
Normal file
@@ -0,0 +1,23 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from superset.daos.base import BaseDAO
|
||||
from superset.models.dynamic_plugins import DynamicPlugin
|
||||
|
||||
|
||||
class DynamicPluginDAO(BaseDAO[DynamicPlugin]):
|
||||
pass
|
||||
213
superset/mcp_service/plugin/schemas.py
Normal file
213
superset/mcp_service/plugin/schemas.py
Normal file
@@ -0,0 +1,213 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for dynamic plugin responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
DEFAULT_PLUGIN_COLUMNS = ["id", "name", "key", "bundle_url"]
|
||||
|
||||
ALL_PLUGIN_COLUMNS = [
|
||||
"id",
|
||||
"name",
|
||||
"key",
|
||||
"bundle_url",
|
||||
"changed_on",
|
||||
"created_on",
|
||||
]
|
||||
|
||||
SORTABLE_PLUGIN_COLUMNS = ["id", "name", "key", "changed_on", "created_on"]
|
||||
|
||||
|
||||
class PluginColumnFilter(ColumnOperator):
|
||||
"""Filter object for plugin listing."""
|
||||
|
||||
col: Literal["name", "key"] = Field(..., description="Column to filter on.")
|
||||
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by"
|
||||
)
|
||||
|
||||
|
||||
class PluginInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Plugin ID")
|
||||
name: str | None = Field(None, description="Plugin display name")
|
||||
key: str | None = Field(None, description="Plugin key (corresponds to viz_type)")
|
||||
bundle_url: str | None = Field(None, description="URL to the plugin bundle")
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
created_on: str | datetime | None = Field(None, description="Creation timestamp")
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
data = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
return data
|
||||
|
||||
|
||||
class PluginList(BaseModel):
|
||||
plugins: List[PluginInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(default_factory=list)
|
||||
columns_loaded: List[str] = Field(default_factory=list)
|
||||
columns_available: List[str] = Field(default_factory=list)
|
||||
sortable_columns: List[str] = Field(default_factory=list)
|
||||
filters_applied: List[PluginColumnFilter] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListPluginsRequest(BaseModel):
|
||||
"""Request schema for list_plugins."""
|
||||
|
||||
filters: Annotated[
|
||||
List[PluginColumnFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (col, opr, value). "
|
||||
"Cannot be used with search.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="Columns to include in response. Defaults to common columns.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search on plugin name or key. "
|
||||
"Cannot be used with filters.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(default="desc", description="Sort direction"),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[PluginColumnFilter]:
|
||||
return parse_json_or_model_list(v, PluginColumnFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListPluginsRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
|
||||
return self
|
||||
|
||||
|
||||
class PluginError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "PluginError":
|
||||
from datetime import timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetPluginInfoRequest(BaseModel):
|
||||
"""Request schema for get_plugin_info."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="Plugin ID"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_plugin_object(plugin: Any) -> PluginInfo | None:
|
||||
if not plugin:
|
||||
return None
|
||||
|
||||
return PluginInfo(
|
||||
id=getattr(plugin, "id", None),
|
||||
name=getattr(plugin, "name", None),
|
||||
key=getattr(plugin, "key", None),
|
||||
bundle_url=getattr(plugin, "bundle_url", None),
|
||||
changed_on=getattr(plugin, "changed_on", None),
|
||||
created_on=getattr(plugin, "created_on", None),
|
||||
)
|
||||
24
superset/mcp_service/plugin/tool/__init__.py
Normal file
24
superset/mcp_service/plugin/tool/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_plugin_info import get_plugin_info
|
||||
from .list_plugins import list_plugins
|
||||
|
||||
__all__ = [
|
||||
"list_plugins",
|
||||
"get_plugin_info",
|
||||
]
|
||||
101
superset/mcp_service/plugin/tool/get_plugin_info.py
Normal file
101
superset/mcp_service/plugin/tool/get_plugin_info.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get plugin info FastMCP tool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
from superset.mcp_service.plugin.schemas import (
|
||||
GetPluginInfoRequest,
|
||||
PluginError,
|
||||
PluginInfo,
|
||||
serialize_plugin_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="DynamicPlugin",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get plugin info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_plugin_info(
|
||||
request: GetPluginInfoRequest, ctx: Context
|
||||
) -> PluginInfo | PluginError:
|
||||
"""Get dynamic plugin details by ID. Requires admin access.
|
||||
|
||||
Returns full plugin configuration including name, key, and bundle URL.
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{"identifier": 1}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving plugin information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.mcp_service.plugin.dao import DynamicPluginDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_plugin_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=DynamicPluginDAO,
|
||||
output_schema=PluginInfo,
|
||||
error_schema=PluginError,
|
||||
serializer=serialize_plugin_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, PluginInfo):
|
||||
await ctx.info(
|
||||
"Plugin retrieved: id=%s, name=%s, key=%s"
|
||||
% (result.id, result.name, result.key)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Plugin retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Plugin info retrieval failed: identifier=%s, error=%s"
|
||||
% (request.identifier, str(e))
|
||||
)
|
||||
return PluginError(
|
||||
error=f"Failed to get plugin info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
123
superset/mcp_service/plugin/tool/list_plugins.py
Normal file
123
superset/mcp_service/plugin/tool/list_plugins.py
Normal file
@@ -0,0 +1,123 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List plugins FastMCP tool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.plugin.schemas import (
|
||||
ALL_PLUGIN_COLUMNS,
|
||||
DEFAULT_PLUGIN_COLUMNS,
|
||||
ListPluginsRequest,
|
||||
PluginColumnFilter,
|
||||
PluginError,
|
||||
PluginInfo,
|
||||
PluginList,
|
||||
serialize_plugin_object,
|
||||
SORTABLE_PLUGIN_COLUMNS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_PLUGINS_REQUEST = ListPluginsRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="DynamicPlugin",
|
||||
annotations=ToolAnnotations(
|
||||
title="List plugins",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_plugins(
|
||||
request: ListPluginsRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> PluginList | PluginError:
|
||||
"""List dynamic plugins registered in this Superset instance. Requires admin access.
|
||||
|
||||
Returns plugin metadata including name, key, and bundle URL.
|
||||
|
||||
Sortable columns for order_column: id, name, key, changed_on, created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_plugins")
|
||||
|
||||
request = request or _DEFAULT_LIST_PLUGINS_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing plugins: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.mcp_service.plugin.dao import DynamicPluginDAO
|
||||
|
||||
def _serialize_plugin(obj: object, cols: list[str]) -> PluginInfo | None:
|
||||
return serialize_plugin_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=DynamicPluginDAO,
|
||||
output_schema=PluginInfo,
|
||||
item_serializer=_serialize_plugin,
|
||||
filter_type=PluginColumnFilter,
|
||||
default_columns=DEFAULT_PLUGIN_COLUMNS,
|
||||
search_columns=["name", "key"],
|
||||
list_field_name="plugins",
|
||||
output_list_schema=PluginList,
|
||||
all_columns=ALL_PLUGIN_COLUMNS,
|
||||
sortable_columns=SORTABLE_PLUGIN_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_plugins.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=request.select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"Plugins listed: count=%s, total_count=%s"
|
||||
% (len(result.plugins), result.total_count)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
with event_logger.log_context(action="mcp.list_plugins.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Plugin listing failed: error=%s, error_type=%s"
|
||||
% (str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -140,7 +140,7 @@ def user_can_view_data_model_metadata() -> bool:
|
||||
|
||||
|
||||
def filter_user_directory_fields(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Remove fields that expose users, roles, owners, or access metadata."""
|
||||
"""Remove fields that expose users, owners, or access metadata."""
|
||||
return {
|
||||
key: value for key, value in data.items() if key not in USER_DIRECTORY_FIELDS
|
||||
}
|
||||
|
||||
16
superset/mcp_service/rls/__init__.py
Normal file
16
superset/mcp_service/rls/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
255
superset/mcp_service/rls/schemas.py
Normal file
255
superset/mcp_service/rls/schemas.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Pydantic schemas for row level security filter responses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Any, Dict, List, Literal
|
||||
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
ConfigDict,
|
||||
Field,
|
||||
field_validator,
|
||||
model_serializer,
|
||||
model_validator,
|
||||
PositiveInt,
|
||||
)
|
||||
|
||||
from superset.daos.base import ColumnOperator, ColumnOperatorEnum
|
||||
from superset.mcp_service.constants import DEFAULT_PAGE_SIZE, MAX_PAGE_SIZE
|
||||
from superset.mcp_service.system.schemas import PaginationInfo
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
|
||||
DEFAULT_RLS_COLUMNS = ["id", "name", "filter_type", "clause"]
|
||||
|
||||
ALL_RLS_COLUMNS = [
|
||||
"id",
|
||||
"name",
|
||||
"filter_type",
|
||||
"tables",
|
||||
"roles",
|
||||
"clause",
|
||||
"group_key",
|
||||
"changed_on",
|
||||
]
|
||||
|
||||
SORTABLE_RLS_COLUMNS = ["id", "name", "filter_type", "changed_on"]
|
||||
|
||||
|
||||
class RlsColumnFilter(ColumnOperator):
|
||||
"""Filter object for RLS filter listing."""
|
||||
|
||||
col: Literal["name", "filter_type"] = Field(
|
||||
...,
|
||||
description="Column to filter on.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(..., description="Operator to use.")
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by"
|
||||
)
|
||||
|
||||
|
||||
class RlsTableRef(BaseModel):
|
||||
id: int | None = Field(None, description="Table ID")
|
||||
table_name: str | None = Field(None, description="Table name")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RlsRoleRef(BaseModel):
|
||||
id: int | None = Field(None, description="Role ID")
|
||||
name: str | None = Field(None, description="Role name")
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class RlsFilterInfo(BaseModel):
|
||||
id: int | None = Field(None, description="RLS filter ID")
|
||||
name: str | None = Field(None, description="RLS filter name")
|
||||
filter_type: str | None = Field(None, description="Filter type: Regular or Base")
|
||||
tables: List[RlsTableRef] | None = Field(
|
||||
None, description="Tables this filter applies to"
|
||||
)
|
||||
roles: List[RlsRoleRef] | None = Field(
|
||||
None, description="Roles this filter applies to"
|
||||
)
|
||||
clause: str | None = Field(None, description="SQL WHERE clause")
|
||||
group_key: str | None = Field(
|
||||
None, description="Group key for Base filter grouping"
|
||||
)
|
||||
changed_on: str | datetime | None = Field(
|
||||
None, description="Last modification timestamp"
|
||||
)
|
||||
model_config = ConfigDict(
|
||||
from_attributes=True,
|
||||
ser_json_timedelta="iso8601",
|
||||
populate_by_name=True,
|
||||
)
|
||||
|
||||
@model_serializer(mode="wrap")
|
||||
def _filter_fields_by_context(self, serializer: Any, info: Any) -> Dict[str, Any]:
|
||||
data = serializer(self)
|
||||
if info.context and isinstance(info.context, dict):
|
||||
select_columns = info.context.get("select_columns")
|
||||
if select_columns:
|
||||
requested_fields = set(select_columns)
|
||||
return {k: v for k, v in data.items() if k in requested_fields}
|
||||
return data
|
||||
|
||||
|
||||
class RlsFilterList(BaseModel):
|
||||
rls_filters: List[RlsFilterInfo]
|
||||
count: int
|
||||
total_count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
has_previous: bool
|
||||
has_next: bool
|
||||
columns_requested: List[str] = Field(default_factory=list)
|
||||
columns_loaded: List[str] = Field(default_factory=list)
|
||||
columns_available: List[str] = Field(default_factory=list)
|
||||
sortable_columns: List[str] = Field(default_factory=list)
|
||||
filters_applied: List[RlsColumnFilter] = Field(default_factory=list)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListRlsFiltersRequest(BaseModel):
|
||||
"""Request schema for list_rls_filters."""
|
||||
|
||||
filters: Annotated[
|
||||
List[RlsColumnFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (col, opr, value). "
|
||||
"Cannot be used with search.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="Columns to include in response. Defaults to common columns.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search on filter name. Cannot be used with filters.",
|
||||
),
|
||||
]
|
||||
order_column: Annotated[
|
||||
str | None, Field(default=None, description="Column to order results by")
|
||||
]
|
||||
order_direction: Annotated[
|
||||
Literal["asc", "desc"],
|
||||
Field(default="desc", description="Sort direction"),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[RlsColumnFilter]:
|
||||
return parse_json_or_model_list(v, RlsColumnFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListRlsFiltersRequest":
|
||||
if self.search and self.filters:
|
||||
raise ValueError("Cannot use both 'search' and 'filters' simultaneously.")
|
||||
return self
|
||||
|
||||
|
||||
class RlsFilterError(BaseModel):
|
||||
error: str = Field(..., description="Error message")
|
||||
error_type: str = Field(..., description="Type of error")
|
||||
timestamp: str | datetime | None = Field(None, description="Error timestamp")
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "RlsFilterError":
|
||||
from datetime import timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetRlsFilterInfoRequest(BaseModel):
|
||||
"""Request schema for get_rls_filter_info."""
|
||||
|
||||
identifier: Annotated[
|
||||
int,
|
||||
Field(description="RLS filter ID"),
|
||||
]
|
||||
|
||||
|
||||
def serialize_rls_filter_object(rls_filter: Any) -> RlsFilterInfo | None:
|
||||
if not rls_filter:
|
||||
return None
|
||||
|
||||
tables = [
|
||||
RlsTableRef(
|
||||
id=getattr(t, "id", None),
|
||||
table_name=getattr(t, "table_name", None),
|
||||
)
|
||||
for t in (getattr(rls_filter, "tables", None) or [])
|
||||
]
|
||||
|
||||
roles = [
|
||||
RlsRoleRef(
|
||||
id=getattr(r, "id", None),
|
||||
name=getattr(r, "name", None),
|
||||
)
|
||||
for r in (getattr(rls_filter, "roles", None) or [])
|
||||
]
|
||||
|
||||
return RlsFilterInfo(
|
||||
id=getattr(rls_filter, "id", None),
|
||||
name=getattr(rls_filter, "name", None),
|
||||
filter_type=getattr(rls_filter, "filter_type", None),
|
||||
tables=tables,
|
||||
roles=roles,
|
||||
clause=getattr(rls_filter, "clause", None),
|
||||
group_key=getattr(rls_filter, "group_key", None),
|
||||
changed_on=getattr(rls_filter, "changed_on", None),
|
||||
)
|
||||
24
superset/mcp_service/rls/tool/__init__.py
Normal file
24
superset/mcp_service/rls/tool/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_rls_filter_info import get_rls_filter_info
|
||||
from .list_rls_filters import list_rls_filters
|
||||
|
||||
__all__ = [
|
||||
"list_rls_filters",
|
||||
"get_rls_filter_info",
|
||||
]
|
||||
101
superset/mcp_service/rls/tool/get_rls_filter_info.py
Normal file
101
superset/mcp_service/rls/tool/get_rls_filter_info.py
Normal file
@@ -0,0 +1,101 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
Get RLS filter info FastMCP tool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelGetInfoCore
|
||||
from superset.mcp_service.rls.schemas import (
|
||||
GetRlsFilterInfoRequest,
|
||||
RlsFilterError,
|
||||
RlsFilterInfo,
|
||||
serialize_rls_filter_object,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Row Level Security",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get RLS filter info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_rls_filter_info(
|
||||
request: GetRlsFilterInfoRequest, ctx: Context
|
||||
) -> RlsFilterInfo | RlsFilterError:
|
||||
"""Get row level security filter details by ID. Requires admin access.
|
||||
|
||||
Returns full RLS filter configuration including name, type, tables, roles,
|
||||
and clause.
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{"identifier": 1}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving RLS filter information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.security import RLSDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_rls_filter_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=RLSDAO,
|
||||
output_schema=RlsFilterInfo,
|
||||
error_schema=RlsFilterError,
|
||||
serializer=serialize_rls_filter_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, RlsFilterInfo):
|
||||
await ctx.info(
|
||||
"RLS filter retrieved: id=%s, name=%s" % (result.id, result.name)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"RLS filter retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"RLS filter info retrieval failed: identifier=%s, error=%s"
|
||||
% (request.identifier, str(e))
|
||||
)
|
||||
return RlsFilterError(
|
||||
error=f"Failed to get RLS filter info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
147
superset/mcp_service/rls/tool/list_rls_filters.py
Normal file
147
superset/mcp_service/rls/tool/list_rls_filters.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List RLS filters FastMCP tool.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.privacy import USER_DIRECTORY_FIELDS
|
||||
from superset.mcp_service.rls.schemas import (
|
||||
ALL_RLS_COLUMNS,
|
||||
DEFAULT_RLS_COLUMNS,
|
||||
ListRlsFiltersRequest,
|
||||
RlsColumnFilter,
|
||||
RlsFilterError,
|
||||
RlsFilterInfo,
|
||||
RlsFilterList,
|
||||
serialize_rls_filter_object,
|
||||
SORTABLE_RLS_COLUMNS,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_RLS_FILTERS_REQUEST = ListRlsFiltersRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Row Level Security",
|
||||
annotations=ToolAnnotations(
|
||||
title="List RLS filters",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_rls_filters(
|
||||
request: ListRlsFiltersRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> RlsFilterList | RlsFilterError:
|
||||
"""List row level security filters. Requires admin access.
|
||||
|
||||
Returns RLS filter metadata including name, filter type, tables, roles, and clause.
|
||||
|
||||
Sortable columns for order_column: id, name, filter_type, changed_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_rls_filters")
|
||||
|
||||
request = request or _DEFAULT_LIST_RLS_FILTERS_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing RLS filters: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.security import RLSDAO
|
||||
|
||||
def _serialize_rls_filter(obj: object, cols: list[str]) -> RlsFilterInfo | None:
|
||||
return serialize_rls_filter_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=RLSDAO,
|
||||
output_schema=RlsFilterInfo,
|
||||
item_serializer=_serialize_rls_filter,
|
||||
filter_type=RlsColumnFilter,
|
||||
default_columns=DEFAULT_RLS_COLUMNS,
|
||||
search_columns=["name"],
|
||||
list_field_name="rls_filters",
|
||||
output_list_schema=RlsFilterList,
|
||||
all_columns=ALL_RLS_COLUMNS,
|
||||
sortable_columns=SORTABLE_RLS_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
# Strip USER_DIRECTORY_FIELDS (e.g. 'roles') before handing off to
|
||||
# run_tool, which would raise ValueError if all requested columns are
|
||||
# privacy-filtered. Roles are restored in the model_dump context below.
|
||||
run_select_columns: list[str] | None = None
|
||||
if request.select_columns:
|
||||
filtered = [
|
||||
c for c in request.select_columns if c not in USER_DIRECTORY_FIELDS
|
||||
]
|
||||
run_select_columns = filtered or None
|
||||
|
||||
with event_logger.log_context(action="mcp.list_rls_filters.query"):
|
||||
result = list_tool.run_tool(
|
||||
filters=request.filters,
|
||||
search=request.search,
|
||||
select_columns=run_select_columns,
|
||||
order_column=request.order_column,
|
||||
order_direction=request.order_direction,
|
||||
page=max(request.page - 1, 0),
|
||||
page_size=request.page_size,
|
||||
)
|
||||
|
||||
await ctx.info(
|
||||
"RLS filters listed: count=%s, total_count=%s"
|
||||
% (len(result.rls_filters), result.total_count)
|
||||
)
|
||||
|
||||
# Build column selection using ALL_RLS_COLUMNS as the source of truth,
|
||||
# bypassing the USER_DIRECTORY_FIELDS privacy filter applied by
|
||||
# ModelListCore. 'roles' in an RLS filter is which roles the filter
|
||||
# applies to — core filter data — not user-directory metadata (like
|
||||
# dashboard.roles, which exposes who has access to the resource).
|
||||
if request.select_columns:
|
||||
columns_to_filter = [
|
||||
c for c in request.select_columns if c in ALL_RLS_COLUMNS
|
||||
]
|
||||
if not columns_to_filter:
|
||||
columns_to_filter = list(DEFAULT_RLS_COLUMNS)
|
||||
else:
|
||||
columns_to_filter = list(DEFAULT_RLS_COLUMNS)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_rls_filters.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"RLS filter listing failed: error=%s, error_type=%s"
|
||||
% (str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -30,10 +30,15 @@ from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.auth import MCPPermissionDeniedError
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
CHART_DEFAULT_COLUMNS,
|
||||
CHART_SEARCH_COLUMNS,
|
||||
CHART_SORTABLE_COLUMNS,
|
||||
CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
CSS_TEMPLATE_FILTER_COLUMNS,
|
||||
CSS_TEMPLATE_SEARCH_COLUMNS,
|
||||
CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
DASHBOARD_DEFAULT_COLUMNS,
|
||||
DASHBOARD_SEARCH_COLUMNS,
|
||||
DASHBOARD_SORTABLE_COLUMNS,
|
||||
@@ -44,12 +49,18 @@ from superset.mcp_service.common.schema_discovery import (
|
||||
DATASET_SEARCH_COLUMNS,
|
||||
DATASET_SORTABLE_COLUMNS,
|
||||
get_chart_columns,
|
||||
get_css_template_columns,
|
||||
get_dashboard_columns,
|
||||
get_database_columns,
|
||||
get_dataset_columns,
|
||||
get_theme_columns,
|
||||
GetSchemaRequest,
|
||||
GetSchemaResponse,
|
||||
ModelSchemaInfo,
|
||||
THEME_DEFAULT_COLUMNS,
|
||||
THEME_FILTER_COLUMNS,
|
||||
THEME_SEARCH_COLUMNS,
|
||||
THEME_SORTABLE_COLUMNS,
|
||||
)
|
||||
from superset.mcp_service.constants import ModelType
|
||||
from superset.mcp_service.mcp_core import ModelGetSchemaCore
|
||||
@@ -144,6 +155,44 @@ def _get_database_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
)
|
||||
|
||||
|
||||
def _get_css_template_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
"""Create CSS template schema core with dynamically extracted columns."""
|
||||
from superset.daos.css import CssTemplateDAO
|
||||
|
||||
return ModelGetSchemaCore(
|
||||
model_type="css_template",
|
||||
dao_class=CssTemplateDAO,
|
||||
output_schema=ModelSchemaInfo,
|
||||
select_columns=get_css_template_columns(),
|
||||
sortable_columns=CSS_TEMPLATE_SORTABLE_COLUMNS,
|
||||
default_columns=CSS_TEMPLATE_DEFAULT_COLUMNS,
|
||||
search_columns=CSS_TEMPLATE_SEARCH_COLUMNS,
|
||||
default_sort="changed_on",
|
||||
default_sort_direction="desc",
|
||||
filter_columns_override=CSS_TEMPLATE_FILTER_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
def _get_theme_schema_core() -> ModelGetSchemaCore[ModelSchemaInfo]:
|
||||
"""Create theme schema core with dynamically extracted columns."""
|
||||
from superset.daos.theme import ThemeDAO
|
||||
|
||||
return ModelGetSchemaCore(
|
||||
model_type="theme",
|
||||
dao_class=ThemeDAO,
|
||||
output_schema=ModelSchemaInfo,
|
||||
select_columns=get_theme_columns(),
|
||||
sortable_columns=THEME_SORTABLE_COLUMNS,
|
||||
default_columns=THEME_DEFAULT_COLUMNS,
|
||||
search_columns=THEME_SEARCH_COLUMNS,
|
||||
default_sort="changed_on",
|
||||
default_sort_direction="desc",
|
||||
filter_columns_override=THEME_FILTER_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
|
||||
# Map model types to their core factory functions
|
||||
_SCHEMA_CORE_FACTORIES: dict[
|
||||
ModelType,
|
||||
@@ -153,12 +202,25 @@ _SCHEMA_CORE_FACTORIES: dict[
|
||||
"dataset": _get_dataset_schema_core,
|
||||
"dashboard": _get_dashboard_schema_core,
|
||||
"database": _get_database_schema_core,
|
||||
"css_template": _get_css_template_schema_core,
|
||||
"theme": _get_theme_schema_core,
|
||||
}
|
||||
|
||||
# Maps each model type to the FAB class permission name used by its tools.
|
||||
# Used for per-model-type inline RBAC checks instead of a single static
|
||||
# class_permission_name on the @tool decorator.
|
||||
_MODEL_TYPE_CLASS_PERMISSION: dict[ModelType, str] = {
|
||||
"chart": "Chart",
|
||||
"dataset": "Dataset",
|
||||
"dashboard": "Dashboard",
|
||||
"database": "Database",
|
||||
"css_template": "CssTemplate",
|
||||
"theme": "Theme",
|
||||
}
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Dataset",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get schema",
|
||||
readOnlyHint=True,
|
||||
@@ -182,13 +244,39 @@ async def get_schema(
|
||||
Column metadata is extracted dynamically from SQLAlchemy models.
|
||||
|
||||
Args:
|
||||
model_type: One of "chart", "dataset", "dashboard", or "database"
|
||||
model_type: One of "chart", "dataset", "dashboard", "database",
|
||||
"css_template", or "theme"
|
||||
|
||||
Returns:
|
||||
Comprehensive schema information for the requested model type
|
||||
"""
|
||||
await ctx.info(f"Getting schema for model_type={request.model_type}")
|
||||
|
||||
# Per-model-type RBAC check (replaces the static class_permission_name on @tool,
|
||||
# which wrongly gated all schema types behind Dataset permission).
|
||||
class_permission = _MODEL_TYPE_CLASS_PERMISSION.get(request.model_type)
|
||||
if class_permission:
|
||||
from flask import current_app, g
|
||||
|
||||
from superset import security_manager
|
||||
|
||||
if current_app.config.get("MCP_RBAC_ENABLED", True) and not (
|
||||
security_manager.can_access("can_read", class_permission)
|
||||
):
|
||||
user_str = getattr(getattr(g, "user", None), "username", None)
|
||||
logger.warning(
|
||||
"get_schema RBAC denied: user=%s type=%s view=%s",
|
||||
user_str,
|
||||
request.model_type,
|
||||
class_permission,
|
||||
)
|
||||
raise MCPPermissionDeniedError(
|
||||
permission_name="can_read",
|
||||
view_name=class_permission,
|
||||
user=user_str,
|
||||
tool_name="get_schema",
|
||||
)
|
||||
|
||||
can_view_data_model_metadata = user_can_view_data_model_metadata()
|
||||
if not can_view_data_model_metadata and request.model_type in {
|
||||
"dataset",
|
||||
|
||||
16
superset/mcp_service/theme/__init__.py
Normal file
16
superset/mcp_service/theme/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
305
superset/mcp_service/theme/schemas.py
Normal file
305
superset/mcp_service/theme/schemas.py
Normal file
@@ -0,0 +1,305 @@
|
||||
# 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 theme-related 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.common.cache_schemas import MetadataCacheControl
|
||||
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.sanitization import sanitize_for_llm_context
|
||||
from superset.mcp_service.utils.schema_utils import (
|
||||
parse_json_or_list,
|
||||
parse_json_or_model_list,
|
||||
)
|
||||
from superset.utils import json as superset_json
|
||||
|
||||
|
||||
class ThemeFilter(ColumnOperator):
|
||||
"""
|
||||
Filter object for theme listing.
|
||||
col: The column to filter on. Must be one of the allowed filter fields.
|
||||
opr: The operator to use. Must be one of the supported operators.
|
||||
value: The value to filter by (type depends on col and opr).
|
||||
"""
|
||||
|
||||
col: Literal[
|
||||
"theme_name",
|
||||
"is_system",
|
||||
"is_system_default",
|
||||
"is_system_dark",
|
||||
"created_by_fk",
|
||||
] = Field(
|
||||
...,
|
||||
description="Column to filter on. Use get_schema(model_type='theme') for "
|
||||
"available filter columns. To filter by creator, first call find_users "
|
||||
"to resolve a name to a user ID, then filter by created_by_fk with "
|
||||
"that integer ID.",
|
||||
)
|
||||
opr: ColumnOperatorEnum = Field(
|
||||
...,
|
||||
description="Operator to use.",
|
||||
)
|
||||
value: str | int | float | bool | List[str | int | float | bool] = Field(
|
||||
..., description="Value to filter by (type depends on col and opr)"
|
||||
)
|
||||
|
||||
|
||||
class ThemeInfo(BaseModel):
|
||||
id: int | None = Field(None, description="Theme ID")
|
||||
uuid: str | None = Field(None, description="Theme UUID")
|
||||
theme_name: str | None = Field(None, description="Theme display name")
|
||||
json_data: Dict[str, Any] | None = Field(
|
||||
None,
|
||||
description="Theme token configuration as a parsed dictionary",
|
||||
)
|
||||
is_system: bool | None = Field(None, description="Whether this is a system theme")
|
||||
is_system_default: bool | None = Field(
|
||||
None, description="Whether this is the default system theme"
|
||||
)
|
||||
is_system_dark: bool | None = Field(
|
||||
None, description="Whether this is the dark system theme"
|
||||
)
|
||||
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]:
|
||||
"""Filter fields based on serialization context.
|
||||
|
||||
If context contains 'select_columns', only include those fields.
|
||||
Otherwise, include all fields (default behavior).
|
||||
"""
|
||||
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 ThemeList(BaseModel):
|
||||
themes: List[ThemeInfo]
|
||||
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,
|
||||
description="Requested columns for the response",
|
||||
)
|
||||
columns_loaded: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that were actually loaded for each theme",
|
||||
)
|
||||
columns_available: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="All columns available for selection via select_columns parameter",
|
||||
)
|
||||
sortable_columns: List[str] = Field(
|
||||
default_factory=list,
|
||||
description="Columns that can be used with order_column parameter",
|
||||
)
|
||||
filters_applied: List[ThemeFilter] = Field(
|
||||
default_factory=list,
|
||||
description="List of advanced filter dicts applied to the query.",
|
||||
)
|
||||
pagination: PaginationInfo | None = None
|
||||
timestamp: datetime | None = None
|
||||
model_config = ConfigDict(ser_json_timedelta="iso8601")
|
||||
|
||||
|
||||
class ListThemesRequest(MetadataCacheControl):
|
||||
"""Request schema for list_themes."""
|
||||
|
||||
filters: Annotated[
|
||||
List[ThemeFilter],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of filter objects (column, operator, value). Cannot be "
|
||||
"used together with 'search'.",
|
||||
),
|
||||
]
|
||||
select_columns: Annotated[
|
||||
List[str],
|
||||
Field(
|
||||
default_factory=list,
|
||||
description="List of columns to select. Defaults to common columns if not "
|
||||
"specified.",
|
||||
),
|
||||
]
|
||||
search: Annotated[
|
||||
str | None,
|
||||
Field(
|
||||
default=None,
|
||||
description="Text search string to match against theme fields. "
|
||||
"Cannot be used together 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="Direction to order results ('asc' or 'desc')"
|
||||
),
|
||||
]
|
||||
page: Annotated[
|
||||
PositiveInt,
|
||||
Field(default=1, description="Page number for pagination (1-based)"),
|
||||
]
|
||||
page_size: Annotated[
|
||||
int,
|
||||
Field(
|
||||
default=DEFAULT_PAGE_SIZE,
|
||||
gt=0,
|
||||
le=MAX_PAGE_SIZE,
|
||||
description=f"Number of items per page (max {MAX_PAGE_SIZE})",
|
||||
),
|
||||
]
|
||||
|
||||
@field_validator("filters", mode="before")
|
||||
@classmethod
|
||||
def parse_filters(cls, v: Any) -> List[ThemeFilter]:
|
||||
"""Accept both JSON string and list of objects."""
|
||||
return parse_json_or_model_list(v, ThemeFilter, "filters")
|
||||
|
||||
@field_validator("select_columns", mode="before")
|
||||
@classmethod
|
||||
def parse_columns(cls, v: Any) -> List[str]:
|
||||
"""Accept JSON array, list, or comma-separated string."""
|
||||
return parse_json_or_list(v, "select_columns")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_search_and_filters(self) -> "ListThemesRequest":
|
||||
"""Prevent using both search and filters simultaneously."""
|
||||
if self.search and self.filters:
|
||||
raise ValueError(
|
||||
"Cannot use both 'search' and 'filters' parameters simultaneously. "
|
||||
"Use either 'search' for text-based searching across multiple fields, "
|
||||
"or 'filters' for precise column-based filtering, but not both."
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class ThemeError(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")
|
||||
|
||||
@field_validator("error")
|
||||
@classmethod
|
||||
def sanitize_error_for_llm_context(cls, value: str) -> str:
|
||||
"""Wrap error text before it is exposed to LLM context."""
|
||||
return sanitize_for_llm_context(value, field_path=("error",))
|
||||
|
||||
@classmethod
|
||||
def create(cls, error: str, error_type: str) -> "ThemeError":
|
||||
"""Create a standardized ThemeError with timestamp."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
return cls(
|
||||
error=error, error_type=error_type, timestamp=datetime.now(timezone.utc)
|
||||
)
|
||||
|
||||
|
||||
class GetThemeInfoRequest(MetadataCacheControl):
|
||||
"""Request schema for get_theme_info with support for ID or UUID."""
|
||||
|
||||
identifier: Annotated[
|
||||
int | str,
|
||||
Field(description="Theme identifier - can be numeric ID or UUID string"),
|
||||
]
|
||||
|
||||
|
||||
def _sanitize_theme_info_for_llm_context(
|
||||
theme_info: ThemeInfo,
|
||||
) -> ThemeInfo:
|
||||
"""Wrap theme user-controlled fields before LLM exposure."""
|
||||
payload = theme_info.model_dump(mode="python")
|
||||
payload["theme_name"] = sanitize_for_llm_context(
|
||||
payload.get("theme_name"),
|
||||
field_path=("theme_name",),
|
||||
)
|
||||
if payload.get("json_data") is not None:
|
||||
payload["json_data"] = sanitize_for_llm_context(
|
||||
payload["json_data"],
|
||||
field_path=("json_data",),
|
||||
)
|
||||
return ThemeInfo.model_validate(payload)
|
||||
|
||||
|
||||
def serialize_theme_object(obj: Any) -> ThemeInfo | None:
|
||||
if not obj:
|
||||
return None
|
||||
|
||||
parsed_json_data: Dict[str, Any] | None = None
|
||||
if (raw_json_data := getattr(obj, "json_data", None)) is not None:
|
||||
if isinstance(raw_json_data, dict):
|
||||
parsed_json_data = raw_json_data
|
||||
elif isinstance(raw_json_data, str):
|
||||
try:
|
||||
parsed_json_data = superset_json.loads(raw_json_data)
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
|
||||
return _sanitize_theme_info_for_llm_context(
|
||||
ThemeInfo(
|
||||
id=getattr(obj, "id", None),
|
||||
uuid=str(getattr(obj, "uuid", "")) if getattr(obj, "uuid", None) else None,
|
||||
theme_name=getattr(obj, "theme_name", None),
|
||||
json_data=parsed_json_data,
|
||||
is_system=getattr(obj, "is_system", None),
|
||||
is_system_default=getattr(obj, "is_system_default", None),
|
||||
is_system_dark=getattr(obj, "is_system_dark", None),
|
||||
changed_on=getattr(obj, "changed_on", None),
|
||||
created_on=getattr(obj, "created_on", None),
|
||||
)
|
||||
)
|
||||
24
superset/mcp_service/theme/tool/__init__.py
Normal file
24
superset/mcp_service/theme/tool/__init__.py
Normal file
@@ -0,0 +1,24 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
from .get_theme_info import get_theme_info
|
||||
from .list_themes import list_themes
|
||||
|
||||
__all__ = [
|
||||
"list_themes",
|
||||
"get_theme_info",
|
||||
]
|
||||
116
superset/mcp_service/theme/tool/get_theme_info.py
Normal file
116
superset/mcp_service/theme/tool/get_theme_info.py
Normal file
@@ -0,0 +1,116 @@
|
||||
# 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 theme 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.theme.schemas import (
|
||||
GetThemeInfoRequest,
|
||||
serialize_theme_object,
|
||||
ThemeError,
|
||||
ThemeInfo,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["discovery"],
|
||||
class_permission_name="Theme",
|
||||
annotations=ToolAnnotations(
|
||||
title="Get theme info",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def get_theme_info(
|
||||
request: GetThemeInfoRequest, ctx: Context
|
||||
) -> ThemeInfo | ThemeError:
|
||||
"""Get theme details by ID or UUID.
|
||||
|
||||
Returns theme configuration including json_data.
|
||||
|
||||
IMPORTANT FOR LLM CLIENTS:
|
||||
- Use numeric ID (e.g., 123) or UUID string (e.g., "a1b2c3d4-...")
|
||||
- To find a theme ID, use the list_themes tool first
|
||||
|
||||
Example usage:
|
||||
```json
|
||||
{
|
||||
"identifier": 1
|
||||
}
|
||||
```
|
||||
|
||||
Or with UUID:
|
||||
```json
|
||||
{
|
||||
"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"
|
||||
}
|
||||
```
|
||||
"""
|
||||
await ctx.info(
|
||||
"Retrieving theme information: identifier=%s" % (request.identifier,)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.theme import ThemeDAO
|
||||
|
||||
with event_logger.log_context(action="mcp.get_theme_info.lookup"):
|
||||
get_tool = ModelGetInfoCore(
|
||||
dao_class=ThemeDAO,
|
||||
output_schema=ThemeInfo,
|
||||
error_schema=ThemeError,
|
||||
serializer=serialize_theme_object,
|
||||
supports_slug=False,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
result = get_tool.run_tool(request.identifier)
|
||||
|
||||
if isinstance(result, ThemeInfo):
|
||||
await ctx.info(
|
||||
"Theme information retrieved successfully: "
|
||||
"id=%s, theme_name=%s, uuid=%s"
|
||||
% (result.id, result.theme_name, result.uuid)
|
||||
)
|
||||
else:
|
||||
await ctx.warning(
|
||||
"Theme retrieval failed: error_type=%s, error=%s"
|
||||
% (result.error_type, result.error)
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Theme information retrieval failed: identifier=%s, error=%s, "
|
||||
"error_type=%s" % (request.identifier, str(e), type(e).__name__)
|
||||
)
|
||||
return ThemeError(
|
||||
error=f"Failed to get theme info: {str(e)}",
|
||||
error_type="InternalError",
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
)
|
||||
147
superset/mcp_service/theme/tool/list_themes.py
Normal file
147
superset/mcp_service/theme/tool/list_themes.py
Normal file
@@ -0,0 +1,147 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
"""
|
||||
List themes FastMCP tool
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from fastmcp import Context
|
||||
from superset_core.mcp.decorators import tool, ToolAnnotations
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Theme
|
||||
|
||||
from superset.extensions import event_logger
|
||||
from superset.mcp_service.mcp_core import ModelListCore
|
||||
from superset.mcp_service.theme.schemas import (
|
||||
ListThemesRequest,
|
||||
serialize_theme_object,
|
||||
ThemeError,
|
||||
ThemeFilter,
|
||||
ThemeInfo,
|
||||
ThemeList,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_DEFAULT_LIST_THEMES_REQUEST = ListThemesRequest()
|
||||
|
||||
|
||||
@tool(
|
||||
tags=["core"],
|
||||
class_permission_name="Theme",
|
||||
annotations=ToolAnnotations(
|
||||
title="List themes",
|
||||
readOnlyHint=True,
|
||||
destructiveHint=False,
|
||||
),
|
||||
)
|
||||
async def list_themes(
|
||||
request: ListThemesRequest | None = None,
|
||||
ctx: Context | None = None,
|
||||
) -> ThemeList | ThemeError:
|
||||
"""List themes with filtering and search.
|
||||
|
||||
Returns theme metadata including name and UUID.
|
||||
|
||||
Sortable columns for order_column: id, theme_name, changed_on, created_on
|
||||
"""
|
||||
if ctx is None:
|
||||
raise RuntimeError("FastMCP context is required for list_themes")
|
||||
|
||||
request = request or _DEFAULT_LIST_THEMES_REQUEST.model_copy(deep=True)
|
||||
|
||||
await ctx.info(
|
||||
"Listing themes: page=%s, page_size=%s, search=%s"
|
||||
% (request.page, request.page_size, request.search)
|
||||
)
|
||||
await ctx.debug(
|
||||
"Theme listing parameters: filters=%s, order_column=%s, "
|
||||
"order_direction=%s, select_columns=%s"
|
||||
% (
|
||||
request.filters,
|
||||
request.order_column,
|
||||
request.order_direction,
|
||||
request.select_columns,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
from superset.daos.theme import ThemeDAO
|
||||
from superset.mcp_service.common.schema_discovery import (
|
||||
get_all_column_names,
|
||||
get_theme_columns,
|
||||
THEME_DEFAULT_COLUMNS,
|
||||
THEME_SORTABLE_COLUMNS,
|
||||
)
|
||||
|
||||
all_columns = get_all_column_names(get_theme_columns())
|
||||
|
||||
def _serialize_theme(
|
||||
obj: "Theme | None", cols: list[str] | None
|
||||
) -> ThemeInfo | None:
|
||||
return serialize_theme_object(obj)
|
||||
|
||||
list_tool = ModelListCore(
|
||||
dao_class=ThemeDAO,
|
||||
output_schema=ThemeInfo,
|
||||
item_serializer=_serialize_theme,
|
||||
filter_type=ThemeFilter,
|
||||
default_columns=THEME_DEFAULT_COLUMNS,
|
||||
search_columns=["theme_name"],
|
||||
list_field_name="themes",
|
||||
output_list_schema=ThemeList,
|
||||
all_columns=all_columns,
|
||||
sortable_columns=THEME_SORTABLE_COLUMNS,
|
||||
logger=logger,
|
||||
)
|
||||
|
||||
with event_logger.log_context(action="mcp.list_themes.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(
|
||||
"Themes listed successfully: count=%s, total_count=%s"
|
||||
% (
|
||||
len(result.themes) if hasattr(result, "themes") else 0,
|
||||
getattr(result, "total_count", None),
|
||||
)
|
||||
)
|
||||
|
||||
columns_to_filter = result.columns_requested
|
||||
with event_logger.log_context(action="mcp.list_themes.serialization"):
|
||||
return result.model_dump(
|
||||
mode="json",
|
||||
context={"select_columns": columns_to_filter},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
await ctx.error(
|
||||
"Theme listing failed: page=%s, page_size=%s, error=%s, error_type=%s"
|
||||
% (request.page, request.page_size, str(e), type(e).__name__)
|
||||
)
|
||||
raise
|
||||
@@ -24,6 +24,7 @@ from __future__ import annotations
|
||||
import builtins
|
||||
import logging
|
||||
import textwrap
|
||||
import threading
|
||||
from ast import literal_eval
|
||||
from contextlib import closing, contextmanager, nullcontext, suppress
|
||||
from copy import deepcopy
|
||||
@@ -56,7 +57,7 @@ from sqlalchemy.engine.reflection import Inspector
|
||||
from sqlalchemy.engine.url import URL
|
||||
from sqlalchemy.exc import NoSuchModuleError
|
||||
from sqlalchemy.ext.hybrid import hybrid_property
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.orm import Mapper, relationship
|
||||
from sqlalchemy.pool import NullPool
|
||||
from sqlalchemy.schema import UniqueConstraint
|
||||
from sqlalchemy.sql import ColumnElement, expression, Select
|
||||
@@ -94,6 +95,15 @@ from superset.utils.oauth2 import (
|
||||
metadata = Model.metadata # pylint: disable=no-member
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Per-process SQLAlchemy engine cache (#27897). Key is
|
||||
# (database_id, str(sqlalchemy_url), repr(sorted(engine_kwargs.items()))).
|
||||
# Lock-guarded against the gunicorn-threaded check-then-set race on first
|
||||
# access. Cache is per-process, per-(URL + final engine_kwargs), so a
|
||||
# password rotation, host change, or DB_CONNECTION_MUTATOR producing
|
||||
# different kwargs naturally falls through to a fresh engine.
|
||||
_ENGINE_CACHE: dict[tuple[int, str, str], Engine] = {}
|
||||
_ENGINE_CACHE_LOCK = threading.Lock()
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset_core.queries.types import AsyncQueryHandle, QueryOptions, QueryResult
|
||||
|
||||
@@ -495,7 +505,12 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
cursor.close()
|
||||
|
||||
sqla.event.listen(engine, "connect", run_prequeries)
|
||||
yield engine
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
sqla.event.remove(engine, "connect", run_prequeries)
|
||||
else:
|
||||
yield engine
|
||||
|
||||
def _get_sqla_engine( # pylint: disable=too-many-locals # noqa: C901
|
||||
self,
|
||||
@@ -565,10 +580,36 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
security_manager,
|
||||
source,
|
||||
)
|
||||
# Per-process engine cache (#27897). SQLAlchemy expects ``create_engine``
|
||||
# to be called once per process per URL so its connection pool can do
|
||||
# its job. Recreating the engine every call defeats the pool that
|
||||
# operators configure via ``DB_CONNECTION_MUTATOR`` (e.g. duckdb with a
|
||||
# size-1 queue). Cache regardless of ``nullpool``: even a NullPool
|
||||
# engine has nontrivial construction cost (URL parsing, dialect
|
||||
# resolution, connect_args setup, and re-running the mutator), and
|
||||
# production callsites pass ``nullpool=True`` by default — gating the
|
||||
# cache on ``not nullpool`` would leave it dormant everywhere it
|
||||
# actually matters. Unsaved instances (``self.id is None``) are
|
||||
# excluded so two distinct in-memory ``Database`` objects with the
|
||||
# same URI can't collide on a shared cache entry.
|
||||
cache_key: tuple[int, str, str] | None = None
|
||||
if self.id is not None:
|
||||
cache_key = (
|
||||
self.id,
|
||||
str(sqlalchemy_url),
|
||||
repr(sorted(engine_kwargs.items())),
|
||||
)
|
||||
with _ENGINE_CACHE_LOCK:
|
||||
if cached := _ENGINE_CACHE.get(cache_key):
|
||||
return cached
|
||||
try:
|
||||
return create_engine(sqlalchemy_url, **engine_kwargs)
|
||||
engine = create_engine(sqlalchemy_url, **engine_kwargs)
|
||||
except Exception as ex:
|
||||
raise self.db_engine_spec.get_dbapi_mapped_exception(ex) from ex
|
||||
if cache_key is not None:
|
||||
with _ENGINE_CACHE_LOCK:
|
||||
_ENGINE_CACHE[cache_key] = engine
|
||||
return engine
|
||||
|
||||
def add_database_to_signature(
|
||||
self,
|
||||
@@ -1343,6 +1384,30 @@ sqla.event.listen(Database, "after_update", security_manager.database_after_upda
|
||||
sqla.event.listen(Database, "after_delete", security_manager.database_after_delete)
|
||||
|
||||
|
||||
def _evict_engine_cache(
|
||||
mapper: Mapper,
|
||||
connection: Connection,
|
||||
target: "Database",
|
||||
) -> None:
|
||||
"""Evict all cached engines for a database when it is updated or deleted.
|
||||
|
||||
URL/kwargs changes already produce a new cache key, so stale engines are
|
||||
never served to callers. This eviction step is purely to reclaim memory:
|
||||
without it, old engines for a renamed host or rotated password would linger
|
||||
in _ENGINE_CACHE until the process restarted.
|
||||
"""
|
||||
if target.id is None:
|
||||
return
|
||||
with _ENGINE_CACHE_LOCK:
|
||||
stale = [k for k in _ENGINE_CACHE if k[0] == target.id]
|
||||
for k in stale:
|
||||
_ENGINE_CACHE.pop(k, None)
|
||||
|
||||
|
||||
sqla.event.listen(Database, "after_update", _evict_engine_cache)
|
||||
sqla.event.listen(Database, "after_delete", _evict_engine_cache)
|
||||
|
||||
|
||||
class DatabaseUserOAuth2Tokens(Model, AuditMixinNullable):
|
||||
"""
|
||||
Store OAuth2 tokens, for authenticating to DBs using user personal tokens.
|
||||
|
||||
16
tests/unit_tests/mcp_service/css_template/__init__.py
Normal file
16
tests/unit_tests/mcp_service/css_template/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
16
tests/unit_tests/mcp_service/css_template/tool/__init__.py
Normal file
16
tests/unit_tests/mcp_service/css_template/tool/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
@@ -0,0 +1,280 @@
|
||||
# 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, patch
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
from fastmcp.exceptions import ToolError
|
||||
from pydantic import ValidationError
|
||||
|
||||
from superset.mcp_service.app import mcp
|
||||
from superset.mcp_service.css_template.schemas import (
|
||||
CssTemplateFilter,
|
||||
ListCssTemplatesRequest,
|
||||
)
|
||||
from superset.mcp_service.utils.sanitization import (
|
||||
LLM_CONTEXT_CLOSE_DELIMITER,
|
||||
LLM_CONTEXT_OPEN_DELIMITER,
|
||||
)
|
||||
from superset.utils import json
|
||||
|
||||
logging.basicConfig(level=logging.DEBUG)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _wrapped(value: str) -> str:
|
||||
return f"{LLM_CONTEXT_OPEN_DELIMITER}\n{value}\n{LLM_CONTEXT_CLOSE_DELIMITER}"
|
||||
|
||||
|
||||
class TestCssTemplateFilterSchema:
|
||||
"""Tests for CssTemplateFilter schema — filterable columns."""
|
||||
|
||||
def test_invalid_filter_column_rejected(self):
|
||||
"""Columns not in the Literal set must be rejected."""
|
||||
with pytest.raises(ValidationError):
|
||||
CssTemplateFilter(col="not_a_real_column", opr="eq", value="test")
|
||||
|
||||
def test_valid_filter_column_accepted(self):
|
||||
"""template_name is a valid filter column."""
|
||||
f = CssTemplateFilter(col="template_name", opr="eq", value="my_template")
|
||||
assert f.col == "template_name"
|
||||
|
||||
def test_css_column_not_filterable(self):
|
||||
"""css is not a public filter column (large field)."""
|
||||
with pytest.raises(ValidationError):
|
||||
CssTemplateFilter(col="css", opr="eq", value="body {}")
|
||||
|
||||
def test_created_by_fk_filter_accepted(self):
|
||||
"""created_by_fk is a valid filter column for filtering by creator."""
|
||||
f = CssTemplateFilter(col="created_by_fk", opr="eq", value=1)
|
||||
assert f.col == "created_by_fk"
|
||||
|
||||
|
||||
def create_mock_css_template(
|
||||
template_id: int = 1,
|
||||
template_name: str = "my_template",
|
||||
css: str = "body { color: red; }",
|
||||
uuid: str | None = None,
|
||||
created_by_name: str | None = "admin",
|
||||
changed_by_name: str | None = "admin",
|
||||
) -> MagicMock:
|
||||
"""Factory function to create mock CSS template objects."""
|
||||
template = MagicMock()
|
||||
template.id = template_id
|
||||
template.template_name = template_name
|
||||
template.css = css
|
||||
template.uuid = (
|
||||
uuid if uuid is not None else f"test-css-template-uuid-{template_id}"
|
||||
)
|
||||
template.changed_on = None
|
||||
template.created_on = None
|
||||
template.created_by_name = created_by_name
|
||||
template.changed_by_name = changed_by_name
|
||||
return template
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mcp_server():
|
||||
return mcp
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_auth():
|
||||
"""Mock authentication for all tests."""
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
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
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_basic(mock_list, mcp_server):
|
||||
"""Test basic CSS template listing functionality."""
|
||||
template = create_mock_css_template()
|
||||
mock_list.return_value = ([template], 1)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListCssTemplatesRequest(page=1, page_size=10)
|
||||
result = await client.call_tool(
|
||||
"list_css_templates", {"request": request.model_dump()}
|
||||
)
|
||||
assert result.content is not None
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["css_templates"] is not None
|
||||
assert len(data["css_templates"]) == 1
|
||||
assert data["css_templates"][0]["id"] == 1
|
||||
assert data["css_templates"][0]["template_name"] == _wrapped("my_template")
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_css_not_in_default_columns(mock_list, mcp_server):
|
||||
"""Test that css field is excluded from default columns (it's large)."""
|
||||
template = create_mock_css_template()
|
||||
mock_list.return_value = ([template], 1)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool("list_css_templates", {})
|
||||
data = json.loads(result.content[0].text)
|
||||
|
||||
assert data["css_templates"] is not None
|
||||
assert len(data["css_templates"]) == 1
|
||||
# css not in default columns
|
||||
assert "css" not in data["css_templates"][0]
|
||||
assert data["columns_requested"] == ["id", "uuid", "template_name"]
|
||||
assert data["css_templates"][0]["uuid"] == "test-css-template-uuid-1"
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_with_search(mock_list, mcp_server):
|
||||
"""Test CSS template listing with search functionality."""
|
||||
template = create_mock_css_template(template_name="dark_theme_css")
|
||||
mock_list.return_value = ([template], 1)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListCssTemplatesRequest(page=1, page_size=10, search="dark")
|
||||
result = await client.call_tool(
|
||||
"list_css_templates", {"request": request.model_dump()}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert len(data["css_templates"]) == 1
|
||||
assert data["css_templates"][0]["template_name"] == _wrapped("dark_theme_css")
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_with_select_columns_css(mock_list, mcp_server):
|
||||
"""Test that css can be requested via select_columns."""
|
||||
template = create_mock_css_template(css="body { margin: 0; }")
|
||||
mock_list.return_value = ([template], 1)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListCssTemplatesRequest(
|
||||
page=1, page_size=10, select_columns=["id", "template_name", "css"]
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"list_css_templates", {"request": request.model_dump()}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["css_templates"][0]["css"] == _wrapped("body { margin: 0; }")
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_with_filters(mock_list, mcp_server):
|
||||
"""Test CSS template listing with filters."""
|
||||
template = create_mock_css_template(template_name="bootstrap_css")
|
||||
mock_list.return_value = ([template], 1)
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListCssTemplatesRequest(
|
||||
page=1,
|
||||
page_size=10,
|
||||
filters=[{"col": "template_name", "opr": "eq", "value": "bootstrap_css"}],
|
||||
)
|
||||
result = await client.call_tool(
|
||||
"list_css_templates", {"request": request.model_dump()}
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert len(data["css_templates"]) == 1
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.list")
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_css_templates_api_error(mock_list, mcp_server):
|
||||
"""Test error handling when DAO raises an exception."""
|
||||
mock_list.side_effect = ToolError("CSS template error")
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
request = ListCssTemplatesRequest(page=1, page_size=10)
|
||||
with pytest.raises(ToolError) as excinfo: # noqa: PT012
|
||||
await client.call_tool(
|
||||
"list_css_templates", {"request": request.model_dump()}
|
||||
)
|
||||
assert "CSS template error" in str(excinfo.value)
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_css_template_info_basic(mock_find, mcp_server):
|
||||
"""Test basic get CSS template info functionality."""
|
||||
template = create_mock_css_template()
|
||||
mock_find.return_value = template
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_css_template_info", {"request": {"identifier": 1}}
|
||||
)
|
||||
assert result.content is not None
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["id"] == 1
|
||||
assert data["template_name"] == _wrapped("my_template")
|
||||
assert data["uuid"] == "test-css-template-uuid-1"
|
||||
assert data["css"] == _wrapped("body { color: red; }")
|
||||
assert data["created_by_name"] == _wrapped("admin")
|
||||
assert data["changed_by_name"] == _wrapped("admin")
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_css_template_info_by_uuid(mock_find, mcp_server):
|
||||
"""Test get CSS template info by UUID."""
|
||||
template = create_mock_css_template(uuid="a1b2c3d4-5678-90ab-cdef-1234567890ab")
|
||||
mock_find.return_value = template
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_css_template_info",
|
||||
{"request": {"identifier": "a1b2c3d4-5678-90ab-cdef-1234567890ab"}},
|
||||
)
|
||||
data = json.loads(result.content[0].text)
|
||||
assert data["id"] == 1
|
||||
assert data["uuid"] == "a1b2c3d4-5678-90ab-cdef-1234567890ab"
|
||||
|
||||
mock_find.assert_called_once_with(
|
||||
"a1b2c3d4-5678-90ab-cdef-1234567890ab", id_column="uuid", query_options=None
|
||||
)
|
||||
|
||||
|
||||
@patch("superset.daos.css.CssTemplateDAO.find_by_id")
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_css_template_info_not_found(mock_find, mcp_server):
|
||||
"""Test get CSS template info when template does not exist."""
|
||||
mock_find.return_value = None
|
||||
|
||||
async with Client(mcp_server) as client:
|
||||
result = await client.call_tool(
|
||||
"get_css_template_info", {"request": {"identifier": 999}}
|
||||
)
|
||||
assert json.loads(result.content[0].text)["error_type"] == "not_found"
|
||||
|
||||
|
||||
def test_list_css_templates_request_search_and_filters_conflict():
|
||||
"""Cannot use search and filters simultaneously."""
|
||||
with pytest.raises(ValidationError):
|
||||
ListCssTemplatesRequest(
|
||||
search="something",
|
||||
filters=[{"col": "template_name", "opr": "eq", "value": "test"}],
|
||||
)
|
||||
16
tests/unit_tests/mcp_service/plugin/__init__.py
Normal file
16
tests/unit_tests/mcp_service/plugin/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user