mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
@@ -36,7 +36,7 @@ runs:
|
||||
echo "PYTHON_VERSION=${{ inputs.python-version }}" >> $GITHUB_ENV
|
||||
fi
|
||||
- name: Set up Python ${{ env.PYTHON_VERSION }}
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
cache: ${{ inputs.cache }}
|
||||
|
||||
@@ -26,7 +26,7 @@ runs:
|
||||
|
||||
- name: Set up QEMU
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4.1.0
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
|
||||
with:
|
||||
# Pin the binfmt image to a specific QEMU release. The default
|
||||
# (`tonistiigi/binfmt:latest`) is a moving target, and drift across
|
||||
@@ -39,12 +39,12 @@ runs:
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
if: ${{ inputs.build == 'true' }}
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Try to login to DockerHub
|
||||
if: ${{ inputs.login-to-dockerhub == 'true' }}
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
username: ${{ inputs.dockerhub-user }}
|
||||
password: ${{ inputs.dockerhub-token }}
|
||||
|
||||
@@ -10,7 +10,7 @@ runs:
|
||||
steps:
|
||||
|
||||
- name: Setup Node Env
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -21,9 +21,8 @@ runs:
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
if: ${{ inputs.from-npm == 'false' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
persist-credentials: false
|
||||
repository: apache-superset/supersetbot
|
||||
path: supersetbot
|
||||
|
||||
|
||||
+30
-30
@@ -10,7 +10,7 @@ updates:
|
||||
schedule:
|
||||
interval: "daily"
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
ignore:
|
||||
@@ -59,7 +59,7 @@ updates:
|
||||
open-pull-requests-limit: 30
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
|
||||
- package-ecosystem: "pip"
|
||||
@@ -76,7 +76,7 @@ updates:
|
||||
- pip
|
||||
- dependabot
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: ".github/actions"
|
||||
@@ -85,7 +85,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/docs/"
|
||||
@@ -110,7 +110,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/"
|
||||
@@ -121,7 +121,7 @@ updates:
|
||||
- dependabot
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-websocket/utils/client-ws-app/"
|
||||
@@ -133,7 +133,7 @@ updates:
|
||||
open-pull-requests-limit: 10
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
# Now for all of our plugins and packages!
|
||||
|
||||
@@ -147,7 +147,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-partition/"
|
||||
@@ -159,7 +159,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-world-map/"
|
||||
@@ -171,7 +171,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-pivot-table/"
|
||||
@@ -186,7 +186,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-chord/"
|
||||
@@ -198,7 +198,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-horizon/"
|
||||
@@ -210,7 +210,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-rose/"
|
||||
@@ -222,7 +222,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-deckgl/"
|
||||
@@ -234,7 +234,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-table/"
|
||||
@@ -249,7 +249,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-country-map/"
|
||||
@@ -261,7 +261,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-map-box/"
|
||||
@@ -273,7 +273,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-preset-chart-nvd3/"
|
||||
@@ -285,7 +285,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-word-cloud/"
|
||||
@@ -297,7 +297,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/"
|
||||
@@ -309,7 +309,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-echarts/"
|
||||
@@ -321,7 +321,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-ag-grid-table/"
|
||||
@@ -333,7 +333,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-cartodiagram/"
|
||||
@@ -345,7 +345,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/legacy-plugin-chart-parallel-coordinates/"
|
||||
@@ -357,7 +357,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/plugins/plugin-chart-handlebars/"
|
||||
@@ -373,7 +373,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/generator-superset/"
|
||||
@@ -385,7 +385,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-chart-controls/"
|
||||
@@ -397,7 +397,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-core/"
|
||||
@@ -414,7 +414,7 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/superset-frontend/packages/superset-ui-switchboard/"
|
||||
@@ -426,4 +426,4 @@ updates:
|
||||
open-pull-requests-limit: 5
|
||||
versioning-strategy: increase
|
||||
cooldown:
|
||||
default-days: 7
|
||||
default-days: 5
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Cancel Duplicates
|
||||
on:
|
||||
workflow_run:
|
||||
workflows:
|
||||
- "Miscellaneous"
|
||||
types:
|
||||
- requested
|
||||
|
||||
jobs:
|
||||
cancel-duplicate-runs:
|
||||
name: Cancel duplicate workflow runs
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
actions: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Check number of queued tasks
|
||||
id: check_queued
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPO: ${{ github.repository }}
|
||||
run: |
|
||||
get_count() {
|
||||
echo $(curl -s -H "Authorization: token $GITHUB_TOKEN" \
|
||||
"https://api.github.com/repos/$GITHUB_REPO/actions/runs?status=$1" | \
|
||||
jq ".total_count")
|
||||
}
|
||||
count=$(( `get_count queued` + `get_count in_progress` ))
|
||||
echo "Found $count unfinished jobs."
|
||||
echo "count=$count" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
if: steps.check_queued.outputs.count >= 20
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Cancel duplicate workflow runs
|
||||
if: steps.check_queued.outputs.count >= 20
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
pip install click requests typing_extensions python-dateutil
|
||||
python ./scripts/cancel_github_workflows.py
|
||||
@@ -26,8 +26,6 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Check and notify
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-permissions:
|
||||
if: |
|
||||
@@ -78,7 +75,6 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Run Claude PR Action
|
||||
|
||||
@@ -32,8 +32,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Check for file changes
|
||||
id: check
|
||||
|
||||
@@ -28,8 +28,6 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: "Dependency Review"
|
||||
uses: actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0
|
||||
continue-on-error: true
|
||||
@@ -52,8 +50,6 @@ jobs:
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
|
||||
@@ -95,11 +95,7 @@ jobs:
|
||||
# in the context of push (using multi-platform build), we need to pull the image locally
|
||||
- name: Docker pull
|
||||
if: github.event_name == 'push' && (steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker)
|
||||
run: |
|
||||
for i in 1 2 3; do
|
||||
docker pull $IMAGE_TAG && break
|
||||
[ $i -lt 3 ] && sleep 30
|
||||
done
|
||||
run: docker pull $IMAGE_TAG
|
||||
|
||||
- name: Print docker stats
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend || steps.check.outputs.docker
|
||||
|
||||
@@ -34,8 +34,6 @@ jobs:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-embedded-sdk/.nvmrc'
|
||||
|
||||
@@ -22,8 +22,6 @@ jobs:
|
||||
working-directory: superset-embedded-sdk
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version-file: './superset-embedded-sdk/.nvmrc'
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
name: Cleanup ephemeral envs (PR close) [DEPRECATED]
|
||||
|
||||
# ⚠️ DEPRECATION NOTICE ⚠️
|
||||
# This workflow is deprecated and will be removed in a future version.
|
||||
# The new Superset Showtime workflow handles cleanup automatically.
|
||||
# See .github/workflows/showtime.yml and showtime-cleanup.yml for replacements.
|
||||
# Migration guide: https://github.com/mistercrunch/superset-showtime
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
has-secrets: ${{ steps.check.outputs.has-secrets }}
|
||||
steps:
|
||||
- name: "Check for secrets"
|
||||
id: check
|
||||
shell: bash
|
||||
run: |
|
||||
if [ -n "${AWS_ACCESS_KEY_ID}" ]; then
|
||||
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ (secrets.AWS_ACCESS_KEY_ID != '' && secrets.AWS_SECRET_ACCESS_KEY != '') || '' }}
|
||||
ephemeral-env-cleanup:
|
||||
needs: config
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: Cleanup ephemeral envs
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Describe ECS service
|
||||
id: describe-services
|
||||
run: |
|
||||
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${{ github.event.number }}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Delete ECS service
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: delete-service
|
||||
run: |
|
||||
aws ecs delete-service \
|
||||
--cluster superset-ci \
|
||||
--service pr-${{ github.event.number }}-service \
|
||||
--force
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: delete-image-tag
|
||||
run: |
|
||||
aws ecr batch-delete-image \
|
||||
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
|
||||
--repository-name superset-ci \
|
||||
--image-ids imageTag=pr-${{ github.event.number }}
|
||||
|
||||
- name: Comment (success)
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ github.event.number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '⚠️ **DEPRECATED WORKFLOW** - Ephemeral environment shutdown and build artifacts deleted. Please migrate to the new Superset Showtime system for future PRs.'
|
||||
})
|
||||
@@ -0,0 +1,350 @@
|
||||
name: Ephemeral env workflow [DEPRECATED]
|
||||
|
||||
# ⚠️ DEPRECATION NOTICE ⚠️
|
||||
# This workflow is deprecated and will be removed in a future version.
|
||||
# Please use the new Superset Showtime workflow instead:
|
||||
# - Use label "🎪 trigger-start" instead of "testenv-up"
|
||||
# - Showtime provides better reliability and easier management
|
||||
# - See .github/workflows/showtime.yml for the replacement
|
||||
# - Migration guide: https://github.com/mistercrunch/superset-showtime
|
||||
|
||||
# Example manual trigger:
|
||||
# gh workflow run ephemeral-env.yml --ref fix_ephemerals --field label_name="testenv-up" --field issue_number=666
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- labeled
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
label_name:
|
||||
description: 'Label name to simulate label-based /testenv trigger'
|
||||
required: true
|
||||
default: 'testenv-up'
|
||||
issue_number:
|
||||
description: 'Issue or PR number'
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
ephemeral-env-label:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-label
|
||||
cancel-in-progress: true
|
||||
name: Evaluate ephemeral env label trigger
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
pull-requests: write
|
||||
outputs:
|
||||
slash-command: ${{ steps.eval-label.outputs.result }}
|
||||
feature-flags: ${{ steps.eval-feature-flags.outputs.result }}
|
||||
sha: ${{ steps.get-sha.outputs.sha }}
|
||||
env:
|
||||
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
steps:
|
||||
- name: Check for the "testenv-up" label
|
||||
id: eval-label
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||
LABEL_NAME="${INPUT_LABEL_NAME}"
|
||||
else
|
||||
LABEL_NAME="${{ github.event.label.name }}"
|
||||
fi
|
||||
|
||||
echo "Evaluating label: $LABEL_NAME"
|
||||
|
||||
if [[ "$LABEL_NAME" == "testenv-up" ]]; then
|
||||
echo "result=up" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "result=noop" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
env:
|
||||
INPUT_LABEL_NAME: ${{ github.event.inputs.label_name }}
|
||||
- name: Get event SHA
|
||||
id: get-sha
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
let prSha;
|
||||
|
||||
// If event is workflow_dispatch, use the issue_number from inputs
|
||||
if (context.eventName === "workflow_dispatch") {
|
||||
const prNumber = "${{ github.event.inputs.issue_number }}";
|
||||
if (!prNumber) {
|
||||
console.log("No PR number found.");
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch PR details using the provided issue_number
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
pull_number: prNumber
|
||||
});
|
||||
|
||||
prSha = pr.head.sha;
|
||||
} else {
|
||||
// If it's not workflow_dispatch, use the PR head sha from the event
|
||||
prSha = context.payload.pull_request.head.sha;
|
||||
}
|
||||
|
||||
console.log(`PR SHA: ${prSha}`);
|
||||
core.setOutput("sha", prSha);
|
||||
|
||||
- name: Looking for feature flags in PR description
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
id: eval-feature-flags
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
with:
|
||||
script: |
|
||||
const description = context.payload.pull_request
|
||||
? context.payload.pull_request.body || ''
|
||||
: context.payload.inputs.pr_description || '';
|
||||
|
||||
const pattern = /FEATURE_(\w+)=(\w+)/g;
|
||||
let results = [];
|
||||
[...description.matchAll(pattern)].forEach(match => {
|
||||
const config = {
|
||||
name: `SUPERSET_FEATURE_${match[1]}`,
|
||||
value: match[2],
|
||||
};
|
||||
results.push(config);
|
||||
});
|
||||
|
||||
return results;
|
||||
|
||||
- name: Reply with confirmation comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
if: steps.eval-label.outputs.result == 'up'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const action = '${{ steps.eval-label.outputs.result }}';
|
||||
const user = context.actor;
|
||||
const runId = context.runId;
|
||||
const workflowUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${runId}`;
|
||||
|
||||
const issueNumber = context.payload.pull_request
|
||||
? context.payload.pull_request.number
|
||||
: context.payload.inputs.issue_number;
|
||||
|
||||
if (!issueNumber) {
|
||||
throw new Error("Issue number is not available.");
|
||||
}
|
||||
|
||||
const body = `⚠️ **DEPRECATED WORKFLOW** ⚠️\n\n@${user} This workflow is deprecated! Please use the new **Superset Showtime** system instead:\n\n` +
|
||||
`- Replace "testenv-up" label with "🎪 trigger-start"\n` +
|
||||
`- Better reliability and easier management\n` +
|
||||
`- See https://github.com/mistercrunch/superset-showtime for details\n\n` +
|
||||
`Processing your ephemeral environment request [here](${workflowUrl}). Action: **${action}**.` +
|
||||
` More information on [how to use or configure ephemeral environments]` +
|
||||
`(https://superset.apache.org/docs/contributing/howtos/#github-ephemeral-environments)`;
|
||||
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: issueNumber,
|
||||
body,
|
||||
});
|
||||
|
||||
ephemeral-docker-build:
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-build
|
||||
cancel-in-progress: true
|
||||
needs: ephemeral-env-label
|
||||
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
|
||||
name: ephemeral-docker-build
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ needs.ephemeral-env-label.outputs.sha }} : ${{steps.get-sha.outputs.sha}} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
ref: ${{ needs.ephemeral-env-label.outputs.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Docker Environment
|
||||
uses: ./.github/actions/setup-docker
|
||||
with:
|
||||
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
|
||||
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
build: "true"
|
||||
install-docker-compose: "false"
|
||||
|
||||
- name: Setup supersetbot
|
||||
uses: ./.github/actions/setup-supersetbot/
|
||||
|
||||
- name: Build ephemeral env image
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
supersetbot docker \
|
||||
--push \
|
||||
--load \
|
||||
--preset ci \
|
||||
--platform linux/amd64 \
|
||||
--context-ref "$RELEASE" \
|
||||
--extra-flags "--build-arg INCLUDE_CHROMIUM=false"
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Load, tag and push image to ECR
|
||||
id: push-image
|
||||
env:
|
||||
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
|
||||
ECR_REPOSITORY: superset-ci
|
||||
IMAGE_TAG: apache/superset:${{ needs.ephemeral-env-label.outputs.sha }}-ci
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
docker tag $IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:pr-$PR_NUMBER-ci
|
||||
docker push -a $ECR_REGISTRY/$ECR_REPOSITORY
|
||||
|
||||
ephemeral-env-up:
|
||||
needs: [ephemeral-env-label, ephemeral-docker-build]
|
||||
if: needs.ephemeral-env-label.outputs.slash-command == 'up'
|
||||
name: Spin up an ephemeral environment
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: us-west-2
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
continue-on-error: true
|
||||
env:
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
aws ecr describe-images \
|
||||
--registry-id $(echo "${{ steps.login-ecr.outputs.registry }}" | grep -Eo "^[0-9]+") \
|
||||
--repository-name superset-ci \
|
||||
--image-ids imageTag=pr-$PR_NUMBER-ci
|
||||
|
||||
- name: Fail on missing container image
|
||||
if: steps.check-image.outcome == 'failure'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const errMsg = '@${{ github.event.comment.user.login }} Container image not yet published for this PR. Please try again when build is complete.';
|
||||
github.rest.issues.createComment({
|
||||
issue_number: ${{ github.event.inputs.issue_number || github.event.pull_request.number }},
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: errMsg
|
||||
});
|
||||
core.setFailed(errMsg);
|
||||
|
||||
- name: Fill in the new image ID in the Amazon ECS task definition
|
||||
id: task-def
|
||||
uses: aws-actions/amazon-ecs-render-task-definition@6853cfae8c3a7d978fbf68b5a55453395541dfbb # v1
|
||||
with:
|
||||
task-definition: .github/workflows/ecs-task-definition.json
|
||||
container-name: superset-ci
|
||||
image: ${{ steps.login-ecr.outputs.registry }}/superset-ci:pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-ci
|
||||
|
||||
- name: Update env vars in the Amazon ECS task definition
|
||||
run: |
|
||||
cat <<< "$(jq '.containerDefinitions[0].environment += ${{ needs.ephemeral-env-label.outputs.feature-flags }}' < ${{ steps.task-def.outputs.task-definition }})" > ${{ steps.task-def.outputs.task-definition }}
|
||||
|
||||
- name: Describe ECS service
|
||||
id: describe-services
|
||||
run: |
|
||||
echo "active=$(aws ecs describe-services --cluster superset-ci --services pr-${INPUT_ISSUE_NUMBER}-service | jq '.services[] | select(.status == "ACTIVE") | any')" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
- name: Create ECS service
|
||||
id: create-service
|
||||
if: steps.describe-services.outputs.active != 'true'
|
||||
env:
|
||||
ECR_SUBNETS: subnet-0e15a5034b4121710,subnet-0e8efef4a72224974
|
||||
ECR_SECURITY_GROUP: sg-092ff3a6ae0574d91
|
||||
PR_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
run: |
|
||||
aws ecs create-service \
|
||||
--cluster superset-ci \
|
||||
--service-name pr-$PR_NUMBER-service \
|
||||
--task-definition superset-ci \
|
||||
--launch-type FARGATE \
|
||||
--desired-count 1 \
|
||||
--platform-version LATEST \
|
||||
--network-configuration "awsvpcConfiguration={subnets=[$ECR_SUBNETS],securityGroups=[$ECR_SECURITY_GROUP],assignPublicIp=ENABLED}" \
|
||||
--tags key=pr,value=$PR_NUMBER key=github_user,value=${{ github.actor }}
|
||||
- name: Deploy Amazon ECS task definition
|
||||
id: deploy-task
|
||||
uses: aws-actions/amazon-ecs-deploy-task-definition@a310a830f5c14e583e35d84e4e1ec7dd177c3c9c # v2
|
||||
with:
|
||||
task-definition: ${{ steps.task-def.outputs.task-definition }}
|
||||
service: pr-${{ github.event.inputs.issue_number || github.event.pull_request.number }}-service
|
||||
cluster: superset-ci
|
||||
wait-for-service-stability: true
|
||||
wait-for-minutes: 10
|
||||
|
||||
- name: List tasks
|
||||
id: list-tasks
|
||||
run: |
|
||||
echo "task=$(aws ecs list-tasks --cluster superset-ci --service-name pr-${INPUT_ISSUE_NUMBER}-service | jq '.taskArns | first')" >> $GITHUB_OUTPUT
|
||||
env:
|
||||
INPUT_ISSUE_NUMBER: ${{ github.event.inputs.issue_number || github.event.pull_request.number }}
|
||||
- name: Get network interface
|
||||
id: get-eni
|
||||
run: |
|
||||
echo "eni=$(aws ecs describe-tasks --cluster superset-ci --tasks ${{ steps.list-tasks.outputs.task }} | jq '.tasks[0].attachments[0].details | map(select(.name=="networkInterfaceId"))[0].value')" >> $GITHUB_OUTPUT
|
||||
- name: Get public IP
|
||||
id: get-ip
|
||||
run: |
|
||||
echo "ip=$(aws ec2 describe-network-interfaces --network-interface-ids ${{ steps.get-eni.outputs.eni }} | jq -r '.NetworkInterfaces | first | .Association.PublicIp')" >> $GITHUB_OUTPUT
|
||||
- name: Comment (success)
|
||||
if: ${{ success() }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issue_number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: `@${{ github.actor }} Ephemeral environment spinning up at http://${{ steps.get-ip.outputs.ip }}:8080. Credentials are 'admin'/'admin'. Please allow several minutes for bootstrapping and startup.`
|
||||
});
|
||||
- name: Comment (failure)
|
||||
if: ${{ failure() }}
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{github.token}}
|
||||
script: |
|
||||
const issue_number = context.payload.inputs?.issue_number || context.issue.number;
|
||||
github.rest.issues.createComment({
|
||||
issue_number: issue_number,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
body: '@${{ github.event.inputs.user_login || github.event.comment.user.login }} Ephemeral environment creation failed. Please check the Actions logs for details.'
|
||||
})
|
||||
@@ -6,8 +6,7 @@ on:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
types: [synchronize, opened, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -16,19 +15,12 @@ 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
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
with:
|
||||
node-version: '20'
|
||||
|
||||
@@ -37,6 +29,3 @@ jobs:
|
||||
|
||||
- name: Run Script
|
||||
run: bash .github/workflows/github-action-validator.sh
|
||||
|
||||
- name: Check for security issues on GHA workflows
|
||||
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||
- uses: actions/labeler@v6
|
||||
with:
|
||||
sync-labels: true
|
||||
|
||||
|
||||
@@ -20,9 +20,7 @@ jobs:
|
||||
- name: Check for latest tag
|
||||
id: latest-tag
|
||||
run: |
|
||||
source ./scripts/tag_latest_release.sh $(echo ${GITHUB_EVENT_RELEASE_TAG_NAME}) --dry-run
|
||||
env:
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
source ./scripts/tag_latest_release.sh $(echo ${{ github.event.release.tag_name }}) --dry-run
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
|
||||
@@ -6,9 +6,6 @@ on:
|
||||
- "master"
|
||||
- "[0-9].[0-9]*"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -30,12 +27,9 @@ jobs:
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: Bump version and publish package(s)
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# pulls all commits (needed for lerna / semantic release to correctly version)
|
||||
fetch-depth: 0
|
||||
- name: Get tags and filter trigger tags
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -27,9 +27,6 @@ concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -48,13 +45,7 @@ jobs:
|
||||
SUPERSET_SITE_BUILD: ${{ (secrets.SUPERSET_SITE_BUILD != '' && secrets.SUPERSET_SITE_BUILD != '') || '' }}
|
||||
build-deploy:
|
||||
needs: config
|
||||
# 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)
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
|
||||
@@ -16,9 +16,6 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.workflow_run.head_sha || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
linkinator:
|
||||
# See docs here: https://github.com/marketplace/actions/linkinator
|
||||
@@ -28,8 +25,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
# Do not bump this linkinator-action version without opening
|
||||
# an ASF Infra ticket to allow the new version first!
|
||||
- uses: JustinBeckwith/linkinator-action@af984b9f30f63e796ae2ea5be5e07cb587f1bbd9 # v2.3
|
||||
@@ -102,8 +97,7 @@ jobs:
|
||||
# Only runs if integration tests succeeded
|
||||
if: >
|
||||
github.event_name == 'workflow_run' &&
|
||||
github.event.workflow_run.conclusion == 'success' &&
|
||||
github.event.workflow_run.head_repository.full_name == github.repository
|
||||
github.event.workflow_run.conclusion == 'success'
|
||||
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 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
@@ -16,9 +16,6 @@ concurrency:
|
||||
env:
|
||||
TAG: apache/superset:GHA-${{ github.run_id }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
frontend-build:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -131,7 +128,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -70,7 +70,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -164,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -219,7 +219,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -150,7 +150,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v5
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
@@ -21,9 +21,6 @@ on:
|
||||
options:
|
||||
- 'true'
|
||||
- 'false'
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
@@ -45,8 +42,6 @@ jobs:
|
||||
if: needs.config.outputs.has-secrets
|
||||
name: docker-release
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
matrix:
|
||||
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
|
||||
@@ -56,7 +51,6 @@ jobs:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Docker Environment
|
||||
@@ -83,9 +77,8 @@ jobs:
|
||||
INPUT_RELEASE: ${{ github.event.inputs.release }}
|
||||
INPUT_FORCE_LATEST: ${{ github.event.inputs.force-latest }}
|
||||
INPUT_GIT_REF: ${{ github.event.inputs.git-ref }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
RELEASE="${{ github.event.release.tag_name }}"
|
||||
FORCE_LATEST=""
|
||||
EVENT="${{github.event_name}}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
@@ -121,7 +114,6 @@ jobs:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Use Node.js 20
|
||||
@@ -136,12 +128,11 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_RELEASE: ${{ github.event.inputs.release }}
|
||||
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
|
||||
run: |
|
||||
export GITHUB_ACTOR=""
|
||||
git fetch --all --tags
|
||||
git checkout master
|
||||
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
|
||||
RELEASE="${{ github.event.release.tag_name }}"
|
||||
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
|
||||
# in the case of a manually-triggered run, read release from input
|
||||
RELEASE="${INPUT_RELEASE}"
|
||||
|
||||
@@ -33,8 +33,6 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
|
||||
|
||||
@@ -12,16 +12,11 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Welcome Message
|
||||
uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
|
||||
uses: actions/first-interaction@v3
|
||||
continue-on-error: true
|
||||
with:
|
||||
repo_token: ${{ github.token }}
|
||||
issue_message: |-
|
||||
Congrats on opening your first issue and thank you for contributing to Superset! :tada: :heart:
|
||||
|
||||
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
|
||||
pr_message: |-
|
||||
repo-token: ${{ github.token }}
|
||||
pr-message: |-
|
||||
Congrats on making your first PR and thank you for contributing to Superset! :tada: :heart:
|
||||
|
||||
Please read our [New Contributor Welcome & Expectations](https://github.com/apache/superset/wiki/New-Contributor-Welcome-&-Expectations) guide.
|
||||
|
||||
We hope to see you in our [Slack](https://apache-superset.slack.com/) community too! Not signed up? Use our [Slack App](http://bit.ly/join-superset-slack) to self-register.
|
||||
|
||||
@@ -158,14 +158,3 @@ 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/]
|
||||
|
||||
+1
-1
@@ -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/
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||
RUN pip install --no-cache-dir --upgrade uv
|
||||
|
||||
# Using uv as it's faster/simpler than pip
|
||||
RUN uv venv /app/.venv
|
||||
|
||||
+354
@@ -24,6 +24,120 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Entity version history for charts, dashboards, and datasets
|
||||
|
||||
Saves of charts, dashboards, and datasets now automatically produce a version history — browsable and restorable via new API endpoints. No frontend UI in this release; the backend plumbing is the deliverable.
|
||||
|
||||
**New endpoints** (per entity type — same pattern for `chart`, `dashboard`, and `dataset`):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/{resource}/<uuid>/versions/` | List the entity's version history (0-based `version_number`, `version_uuid`, `issued_at`, `changed_by`) |
|
||||
| `GET` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/` | Get a single version snapshot (scalar fields at that version; plus `columns` / `metrics` for datasets) |
|
||||
| `POST` | `/api/v1/{resource}/<uuid>/versions/<version_uuid>/restore` | Restore the entity to the state captured by that version |
|
||||
|
||||
`<version_uuid>` is a deterministic `UUIDv5` derived from the entity's UUID and the Continuum transaction id — stable across replicas and retention pruning. Authorisation reuses the resource's existing `can_write` permission; workspace admins can list/restore any entity.
|
||||
|
||||
**Version response shape — `changes` array:**
|
||||
|
||||
Each entry returned by `GET /api/v1/{resource}/<uuid>/versions/` and `GET .../versions/<version_uuid>/` includes a `changes` array describing what changed relative to the previous version:
|
||||
|
||||
```json
|
||||
"changes": [
|
||||
{"kind": "field", "path": "slice_name", "from_value": "Old", "to_value": "New"}
|
||||
]
|
||||
```
|
||||
|
||||
The array is empty for baseline (`operation_type=0`) transactions. `kind` enumerates structured record types (`field`, layout-walker records for dashboards, dataset child diffs for `TableColumn` / `SqlMetric`); `path` is a dotted JSON-pointer-style locator; `from_value` / `to_value` are JSON-safe scalars or compact records.
|
||||
|
||||
**Save-response and ETag headers:**
|
||||
|
||||
- Save responses (`PUT /api/v1/{resource}/<pk>`) include `old_version_uuid` and `new_version_uuid` body fields so the client can correlate a save with its resulting version row.
|
||||
- All entity GETs (`GET /api/v1/{chart,dashboard,dataset}/<pk>`), version-list GETs, single-version GETs, and save responses emit an `ETag: "<version_uuid>"` header reflecting the entity's current live version. The default `CORS_OPTIONS` now sets `expose_headers: ["ETag"]` so cross-origin browser clients can read the header. **No `If-Match` enforcement in v1** — `ETag` is informational; concurrent-edit detection is deferred to a follow-up SIP.
|
||||
- **Operators overriding `CORS_OPTIONS` in `superset_config.py` MUST include `"expose_headers": ["ETag"]`** (or merge with the default) for cross-origin clients to read the ETag. A bare `CORS_OPTIONS = {"origins": [...]}` will silently drop the expose-headers default.
|
||||
|
||||
**Behaviour changes on save:**
|
||||
|
||||
- Every save of a chart, dashboard, or dataset produces one new version row. Rows preserve the full post-save state (scalar fields for all three entity types; `TableColumn` / `SqlMetric` children for datasets; `dashboard_slices` chart membership for dashboards — children versioned via SQLAlchemy-Continuum shadow tables `table_columns_version`, `sql_metrics_version`, and `dashboard_slices_version`).
|
||||
- First save after an entity already exists in the DB creates a retroactive baseline version so the UI can show "what this looked like before I edited it."
|
||||
- Tags, owners, and roles are **not** versioned in v1 (ADR-005). A restore leaves those at their live values.
|
||||
|
||||
**New config key:**
|
||||
|
||||
| Key | Default | Purpose |
|
||||
|---|---|---|
|
||||
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Versions older than this many days are pruned by a nightly Celery beat task (`superset.tasks.version_history_retention.prune_old_versions`). Each entity's live row (`end_transaction_id IS NULL`) is always preserved; closed historical rows including the baseline age out with the rest. Set to `0` to disable retention entirely. |
|
||||
|
||||
**Impact on external integrations:**
|
||||
|
||||
- New tables populated on every save — `dashboards_version`, `slices_version`, `tables_version` (parent shadow tables for the three entity types), `table_columns_version`, `sql_metrics_version`, `dashboard_slices_version` (child shadow tables), plus the shared `version_transaction` and `version_changes` tables. External tooling that queries Superset's DB directly will see writes to these tables proportional to save traffic.
|
||||
- Existing entity endpoints (`GET`/`PUT /api/v1/{chart,dashboard,dataset}/<pk>`) gain an `ETag` response header and the save response gains `old_version_uuid` / `new_version_uuid` body fields. No existing fields are removed or repurposed.
|
||||
- Version capture is always active — no feature flag.
|
||||
|
||||
### Cross-entity activity stream for charts, dashboards, and datasets
|
||||
|
||||
A read-only companion to the version-history endpoints (above). Each entity type gains an `/activity/` endpoint that returns a chronological stream of edits — the entity's own edits plus, for dashboards and charts, transitive edits to related entities during their association windows.
|
||||
|
||||
**New endpoints** (per entity type):
|
||||
|
||||
| Method | Path | Purpose |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/v1/dashboard/<uuid>/activity/` | Dashboard own edits + edits to charts attached during their dashboard window + edits to datasets those charts pointed at during their chart window |
|
||||
| `GET` | `/api/v1/chart/<uuid>/activity/` | Chart own edits + edits to datasets the chart pointed at during association |
|
||||
| `GET` | `/api/v1/dataset/<uuid>/activity/` | Dataset own edits only (no transitive layer in V2) |
|
||||
|
||||
**Query parameters** (all optional):
|
||||
|
||||
| Param | Type | Default | Purpose |
|
||||
|---|---|---|---|
|
||||
| `since` | ISO 8601 datetime | — | Lower bound on `issued_at` |
|
||||
| `until` | ISO 8601 datetime | — | Upper bound on `issued_at` |
|
||||
| `include` | `self` \| `related` \| `all` | `all` | Filter to only the entity's own edits, only related edits, or both |
|
||||
| `page` | integer ≥ 0 | `0` | 0-based page index |
|
||||
| `page_size` | integer in `[1, 200]` | `25` | Records per page (clamped silently to 200) |
|
||||
|
||||
**Response shape:**
|
||||
|
||||
```json
|
||||
{
|
||||
"result": [
|
||||
{
|
||||
"version_uuid": "...",
|
||||
"entity_kind": "chart",
|
||||
"entity_uuid": "...",
|
||||
"entity_name": "Top 10 Girls",
|
||||
"entity_deleted": false,
|
||||
"entity_deletion_state": null,
|
||||
"source": "related",
|
||||
"transaction_id": 1234,
|
||||
"issued_at": "2026-05-26T12:00:00",
|
||||
"changed_by": {"id": 5, "first_name": "Mike", "last_name": "Bridge"},
|
||||
"kind": "filter",
|
||||
"path": ["params", "adhoc_filters", "country"],
|
||||
"from_value": null,
|
||||
"to_value": "US",
|
||||
"summary": "Chart filter changed: Top 10 Girls",
|
||||
"impact": null
|
||||
}
|
||||
],
|
||||
"count": 47
|
||||
}
|
||||
```
|
||||
|
||||
`count` is the total record count *after* the silent permission filter (see below), not the raw query size.
|
||||
|
||||
**Authorisation:** reuses the resource's existing `can_write` permission. Workspace admins can read any entity's activity stream. The endpoint runs `raise_for_ownership` on the path entity — non-owners get `403`.
|
||||
|
||||
**Silent permission filter (AV-008):** records whose source entity the requesting user can't read are silently dropped — no placeholder, no count contribution. The frontend cannot distinguish "no activity" from "you can't see this activity."
|
||||
|
||||
**Tombstones (AV-009 / D-15):** when an activity record references a hard-deleted source entity, the record still appears with `entity_deleted: true`, `entity_uuid: null`, and `entity_name` recovered from the last shadow row.
|
||||
|
||||
**Impact on external integrations:**
|
||||
|
||||
- Pure read-only. No new tables, no new columns, no migrations. Reads sc-103156's shadow tables and the `version_changes` table.
|
||||
- No new save-path code paths — perf-validation gate confirms the activity-view branch does not regress sc-103156's SC-004 50ms-overhead budget.
|
||||
- No feature flag; the endpoints are always available once sc-103156's version-history feature is enabled.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
@@ -310,6 +424,246 @@ See `superset/mcp_service/PRODUCTION.md` for deployment guides.
|
||||
}
|
||||
```
|
||||
|
||||
### Composite primary keys on many-to-many association tables
|
||||
|
||||
The eight M:N association tables listed below have been changed from a synthetic surrogate `id INTEGER PRIMARY KEY` to a composite `PRIMARY KEY (fk1, fk2)` on the two foreign-key columns. The `id` column is dropped, and the two tables that previously carried a redundant `UNIQUE (fk1, fk2)` constraint have that constraint removed (it is now subsumed by the composite primary key).
|
||||
|
||||
**Affected tables and their composite-PK column pairs:**
|
||||
|
||||
| Table | Composite PK |
|
||||
|---|---|
|
||||
| `dashboard_roles` | `(dashboard_id, role_id)` |
|
||||
| `dashboard_slices` | `(dashboard_id, slice_id)` |
|
||||
| `dashboard_user` | `(user_id, dashboard_id)` |
|
||||
| `report_schedule_user` | `(user_id, report_schedule_id)` |
|
||||
| `rls_filter_roles` | `(role_id, rls_filter_id)` |
|
||||
| `rls_filter_tables` | `(table_id, rls_filter_id)` |
|
||||
| `slice_user` | `(user_id, slice_id)` |
|
||||
| `sqlatable_user` | `(user_id, table_id)` |
|
||||
|
||||
**Impact on external readers:** Any BI tool, custom report, backup script, or external integration that references these tables by their old surrogate `id` column (e.g., `SELECT id FROM dashboard_slices WHERE …`, `WHERE dashboard_slices.id IN (…)`) will break. Update such queries to project or filter on the FK pair (`dashboard_id, slice_id`) instead. The FK columns themselves are unchanged.
|
||||
|
||||
**Pre-flight inventory queries.** Before applying the upgrade, operators are encouraged to run the queries below against their database to assess what the migration will change. Two classes of pre-existing data are not preserved by the migration: duplicate `(fk1, fk2)` rows (the migration keeps `MIN(id)` and deletes the rest) and rows with `NULL` in either FK column (the migration deletes them, since FK columns are promoted to `NOT NULL` for the composite PK). Compliance- or audit-sensitive operators should also `\copy` (Postgres) or `SELECT … INTO OUTFILE` (MySQL) the affected rows for their own records before upgrading.
|
||||
|
||||
```sql
|
||||
-- Duplicate (fk1, fk2) pairs (the migration will keep MIN(id) per group, delete the rest)
|
||||
SELECT dashboard_id, role_id, COUNT(*) FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1;
|
||||
SELECT dashboard_id, slice_id, COUNT(*) FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, dashboard_id, COUNT(*) FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, report_schedule_id, COUNT(*) FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1;
|
||||
SELECT role_id, rls_filter_id, COUNT(*) FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1;
|
||||
SELECT table_id, rls_filter_id, COUNT(*) FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, slice_id, COUNT(*) FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1;
|
||||
SELECT user_id, table_id, COUNT(*) FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1;
|
||||
|
||||
-- Rows with a NULL in either FK (the migration will delete these)
|
||||
SELECT COUNT(*) FROM dashboard_roles WHERE dashboard_id IS NULL OR role_id IS NULL;
|
||||
SELECT COUNT(*) FROM dashboard_slices WHERE dashboard_id IS NULL OR slice_id IS NULL;
|
||||
SELECT COUNT(*) FROM dashboard_user WHERE user_id IS NULL OR dashboard_id IS NULL;
|
||||
SELECT COUNT(*) FROM report_schedule_user WHERE user_id IS NULL OR report_schedule_id IS NULL;
|
||||
SELECT COUNT(*) FROM rls_filter_roles WHERE role_id IS NULL OR rls_filter_id IS NULL;
|
||||
SELECT COUNT(*) FROM rls_filter_tables WHERE table_id IS NULL OR rls_filter_id IS NULL;
|
||||
SELECT COUNT(*) FROM slice_user WHERE user_id IS NULL OR slice_id IS NULL;
|
||||
SELECT COUNT(*) FROM sqlatable_user WHERE user_id IS NULL OR table_id IS NULL;
|
||||
```
|
||||
|
||||
**Sizing the maintenance window on PostgreSQL.** The queries above are dialect-portable but only count rows. Operators on PostgreSQL can run the diagnostic queries below to characterize the migration's runtime cost ahead of time: per-table row count and on-disk size, an aggregated duplicate roll-up, the external-FK pre-flight check (the migration runs the same check and aborts if it returns rows), and a rewrite-time estimate for the two tables that go through the slower full-table-rebuild path.
|
||||
|
||||
```sql
|
||||
-- Per-table size, row count, and which migration path each will take.
|
||||
-- Two tables ("dashboard_slices", "report_schedule_user") have a
|
||||
-- redundant UNIQUE constraint that the migration drops via a full
|
||||
-- table rewrite (op.batch_alter_table(recreate="always")). The other
|
||||
-- six use direct ALTER TABLE, which is much cheaper.
|
||||
WITH affected(name, has_unique) AS (
|
||||
VALUES
|
||||
('dashboard_roles', false),
|
||||
('dashboard_slices', true),
|
||||
('dashboard_user', false),
|
||||
('report_schedule_user', true),
|
||||
('rls_filter_roles', false),
|
||||
('rls_filter_tables', false),
|
||||
('slice_user', false),
|
||||
('sqlatable_user', false)
|
||||
)
|
||||
SELECT
|
||||
a.name AS table_name,
|
||||
CASE WHEN a.has_unique THEN 'recreate (full rewrite)'
|
||||
ELSE 'direct ALTER' END AS migration_path,
|
||||
c.reltuples::bigint AS estimated_rows,
|
||||
pg_size_pretty(pg_total_relation_size(c.oid)) AS total_size,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
|
||||
pg_size_pretty(pg_indexes_size(c.oid)) AS index_size
|
||||
FROM affected a
|
||||
JOIN pg_class c ON c.relname = a.name AND c.relkind = 'r'
|
||||
ORDER BY pg_total_relation_size(c.oid) DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Aggregated duplicate-row roll-up.
|
||||
-- "dup_groups" is the number of (fk1, fk2) pairs that appear more
|
||||
-- than once; "rows_dropped" is the total number of rows the
|
||||
-- migration will delete during the dedupe pass (it keeps MIN(id) per
|
||||
-- group and discards the rest).
|
||||
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
|
||||
ORDER BY rows_dropped DESC NULLS LAST;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- External-FK pre-flight check.
|
||||
-- The migration runs the equivalent check at upgrade time and aborts
|
||||
-- if any external FK references one of the soon-to-be-removed `id`
|
||||
-- columns. Running it ahead of time lets you discover (and migrate)
|
||||
-- any such reference before the maintenance window. On a stock
|
||||
-- Superset install this should return zero rows. (Default schema
|
||||
-- only; multi-schema deployments need to broaden the lookup.)
|
||||
SELECT
|
||||
rc.constraint_name,
|
||||
kcu.table_schema || '.' || kcu.table_name AS referencing_table,
|
||||
kcu.column_name AS referencing_column,
|
||||
ccu.table_name AS referenced_table,
|
||||
ccu.column_name AS referenced_column
|
||||
FROM information_schema.referential_constraints rc
|
||||
JOIN information_schema.key_column_usage kcu
|
||||
ON kcu.constraint_name = rc.constraint_name
|
||||
AND kcu.constraint_schema = rc.constraint_schema
|
||||
JOIN information_schema.constraint_column_usage ccu
|
||||
ON ccu.constraint_name = rc.constraint_name
|
||||
AND ccu.constraint_schema = rc.constraint_schema
|
||||
WHERE ccu.table_name IN (
|
||||
'dashboard_roles','dashboard_slices','dashboard_user',
|
||||
'report_schedule_user','rls_filter_roles','rls_filter_tables',
|
||||
'slice_user','sqlatable_user')
|
||||
AND ccu.column_name = 'id';
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Lock-window estimate for the two full-rewrite tables.
|
||||
-- recreate="always" takes ACCESS EXCLUSIVE on the table for the full
|
||||
-- rewrite. Use heap size combined with your hardware's effective
|
||||
-- write throughput (~100-200 MB/s on commodity SSD; faster on NVMe)
|
||||
-- to size the maintenance window. The other six tables use direct
|
||||
-- ALTER and are dominated by composite-index build time, typically
|
||||
-- seconds for tables in the low millions of rows.
|
||||
SELECT
|
||||
c.relname AS table_name,
|
||||
pg_size_pretty(pg_relation_size(c.oid)) AS heap_size,
|
||||
pg_relation_size(c.oid) / 1024 / 1024 AS heap_size_mb,
|
||||
ROUND(pg_relation_size(c.oid) / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
|
||||
FROM pg_class c
|
||||
WHERE c.relname IN ('dashboard_slices', 'report_schedule_user');
|
||||
```
|
||||
|
||||
**Sizing the maintenance window on MySQL.** Equivalent diagnostic queries for MySQL/InnoDB. One important difference from PostgreSQL: InnoDB rebuilds the clustered index on every PK change, so *all eight* tables undergo a full table rebuild on MySQL — not just the two that go through the explicit `recreate="always"` path. The lock-window estimate query below therefore covers all eight tables.
|
||||
|
||||
```sql
|
||||
-- Per-table size, row count, and which migration path each will take.
|
||||
-- TABLE_ROWS is an InnoDB estimate (analogous to PostgreSQL's reltuples);
|
||||
-- run SELECT COUNT(*) per table for an exact count if needed.
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
CASE WHEN TABLE_NAME IN ('dashboard_slices', 'report_schedule_user')
|
||||
THEN 'recreate (explicit, drops UNIQUE)'
|
||||
ELSE 'direct ALTER (still rebuilds InnoDB clustered index)'
|
||||
END AS migration_path,
|
||||
TABLE_ROWS AS estimated_rows,
|
||||
CONCAT(ROUND((DATA_LENGTH + INDEX_LENGTH) / 1024 / 1024, 1), ' MB') AS total_size,
|
||||
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
|
||||
CONCAT(ROUND(INDEX_LENGTH / 1024 / 1024, 1), ' MB') AS index_size
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
ORDER BY (DATA_LENGTH + INDEX_LENGTH) DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Aggregated duplicate-row roll-up. Same SQL as the PostgreSQL version
|
||||
-- (standard SQL); included here for copy-paste convenience.
|
||||
SELECT 'dashboard_roles' AS t, COUNT(*) AS dup_groups, SUM(c) - COUNT(*) AS rows_dropped
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_roles GROUP BY dashboard_id, role_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_slices', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_slices GROUP BY dashboard_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'dashboard_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM dashboard_user GROUP BY user_id, dashboard_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'report_schedule_user',COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM report_schedule_user GROUP BY user_id, report_schedule_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_roles', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_roles GROUP BY role_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'rls_filter_tables', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM rls_filter_tables GROUP BY table_id, rls_filter_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'slice_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM slice_user GROUP BY user_id, slice_id HAVING COUNT(*) > 1) g
|
||||
UNION ALL SELECT 'sqlatable_user', COUNT(*), SUM(c) - COUNT(*)
|
||||
FROM (SELECT COUNT(*) c FROM sqlatable_user GROUP BY user_id, table_id HAVING COUNT(*) > 1) g
|
||||
ORDER BY rows_dropped DESC;
|
||||
```
|
||||
|
||||
```sql
|
||||
-- External-FK pre-flight check. KEY_COLUMN_USAGE on MySQL carries
|
||||
-- both sides of the FK in a single row, so this is simpler than the
|
||||
-- PostgreSQL version. Should return zero rows on a stock install.
|
||||
SELECT
|
||||
CONSTRAINT_NAME,
|
||||
CONCAT(TABLE_SCHEMA, '.', TABLE_NAME) AS referencing_table,
|
||||
COLUMN_NAME AS referencing_column,
|
||||
REFERENCED_TABLE_NAME AS referenced_table,
|
||||
REFERENCED_COLUMN_NAME AS referenced_column
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND REFERENCED_TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
AND REFERENCED_COLUMN_NAME = 'id';
|
||||
```
|
||||
|
||||
```sql
|
||||
-- Lock-window estimate for ALL EIGHT tables (InnoDB rebuilds the
|
||||
-- clustered index on PK change, so even "direct ALTER" is a rewrite).
|
||||
-- ADD PRIMARY KEY is INPLACE but not LOCK=NONE — it allows concurrent
|
||||
-- reads but blocks writes. Use heap size combined with your effective
|
||||
-- rebuild throughput (~100-200 MB/s on commodity SSD; higher on NVMe).
|
||||
SELECT
|
||||
TABLE_NAME AS table_name,
|
||||
CONCAT(ROUND(DATA_LENGTH / 1024 / 1024, 1), ' MB') AS heap_size,
|
||||
ROUND(DATA_LENGTH / 1024 / 1024, 1) AS heap_size_mb,
|
||||
ROUND(DATA_LENGTH / 1024 / 1024 / 100.0, 1) AS est_rewrite_seconds_at_100mbs
|
||||
FROM information_schema.TABLES
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME IN (
|
||||
'dashboard_roles', 'dashboard_slices', 'dashboard_user',
|
||||
'report_schedule_user', 'rls_filter_roles', 'rls_filter_tables',
|
||||
'slice_user', 'sqlatable_user'
|
||||
)
|
||||
ORDER BY DATA_LENGTH DESC;
|
||||
```
|
||||
|
||||
**Restoring an old `pg_dump` (or equivalent) against the new schema.** A dump taken before the migration includes `INSERT` statements that populate the now-removed `id` column. Restoring such a dump against the post-migration schema will fail. The supported workaround is to dump only the schema and reference data, then re-create the M:N associations from application data after restore — for example with `pg_dump --exclude-table-data` (or per-table `--exclude-table-data=dashboard_slices` etc.) for the eight junction tables, restore the rest, then run a one-shot script that re-INSERTs `(fk1, fk2)` pairs derived from your application export. Operators who need to restore an old dump verbatim should restore against a pre-migration Superset and then re-run the upgrade.
|
||||
|
||||
**Intentional downgrade asymmetry.** The migration's `downgrade()` restores the surrogate `id` column and (for `dashboard_slices` and `report_schedule_user`) the original `UNIQUE (fk1, fk2)` constraint, but it does **not** restore the original `NULL`-allowed state on the FK columns — they remain `NOT NULL`. This is intentional: under SQLAlchemy's `secondary=` semantics, a `NULL` in either FK column of a junction table is meaningless (it cannot participate in either side of the relationship). Operators downgrading are not expected to need this restored. The asymmetry is documented for completeness so that round-trip schema diffs are not mistaken for migration bugs.
|
||||
|
||||
**Constraint-name divergence between upgrade and downgrade.** The composite primary key created on upgrade is named `pk_<table>` (Alembic's default for `op.create_primary_key("pk_<table>", ...)`), while the surrogate `id` primary key restored on downgrade is named `<table>_pkey` (PostgreSQL's default convention for `PrimaryKeyConstraint("id")`). The two names alternate so that a round-trip (upgrade → downgrade → upgrade) does not collide on a pre-existing constraint name. Operators using schema-comparison tools (e.g. `pg_diff`, `migra`) against a downgraded database may see this as drift versus a fresh-install schema. It is cosmetic — no application code references either constraint name.
|
||||
|
||||
## 6.0.0
|
||||
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
|
||||
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
# 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.
|
||||
#
|
||||
# Compose override that swaps the default Postgres metadata DB for MySQL 8.
|
||||
# Useful for evaluating dialect-specific behaviour (e.g., DDL-migration
|
||||
# cost on a deployment whose production metadata DB is MySQL).
|
||||
#
|
||||
# Usage:
|
||||
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml up
|
||||
# docker compose -f docker-compose.yml -f docker-compose-mysql.yml down
|
||||
#
|
||||
# To switch back to Postgres, just drop the second `-f` flag — the MySQL
|
||||
# data lives in a separate volume (`db_home_mysql`) so neither side is
|
||||
# corrupted by switching dialects.
|
||||
#
|
||||
# Notes:
|
||||
# - Mirrors the connection settings used by CI's `test-mysql` shard:
|
||||
# dialect ``mysql+mysqldb``, charset utf8mb4 with binary_prefix.
|
||||
# - Host port 13306 (configurable via DATABASE_PORT_MYSQL) to avoid
|
||||
# colliding with a native MySQL install on 3306.
|
||||
# - The Postgres-specific init scripts under
|
||||
# docker/docker-entrypoint-initdb.d/ are not mounted (they are
|
||||
# postgres-only); examples / cypress fixtures still load via
|
||||
# `superset-init`'s post-startup steps.
|
||||
|
||||
# Shared environment override applied to every Superset-side service that
|
||||
# connects to the metadata DB. ``environment:`` takes precedence over the
|
||||
# values inherited from the env_file in docker-compose.yml.
|
||||
x-mysql-env: &mysql-env
|
||||
DATABASE_DIALECT: mysql+mysqldb
|
||||
DATABASE_HOST: db
|
||||
DATABASE_PORT: "3306"
|
||||
DATABASE_DB: superset
|
||||
DATABASE_USER: superset
|
||||
DATABASE_PASSWORD: superset
|
||||
SQLALCHEMY_DATABASE_URI: "mysql+mysqldb://superset:superset@db:3306/superset?charset=utf8mb4&binary_prefix=true"
|
||||
# Override the analytics-examples DB connection too. ``EXAMPLES_PORT``
|
||||
# in docker/.env is hardcoded to 5432 (the Postgres port); without
|
||||
# this override the examples connection would try MySQL on 5432 and
|
||||
# fail. The examples user/DB are created by docker/mysql-init/
|
||||
# examples-init.sql on first MySQL boot.
|
||||
EXAMPLES_HOST: db
|
||||
EXAMPLES_PORT: "3306"
|
||||
EXAMPLES_DB: examples
|
||||
EXAMPLES_USER: examples
|
||||
EXAMPLES_PASSWORD: examples
|
||||
SUPERSET__SQLALCHEMY_EXAMPLES_URI: "mysql+mysqldb://examples:examples@db:3306/examples?charset=utf8mb4&binary_prefix=true"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: mysql:8.0
|
||||
environment:
|
||||
MYSQL_DATABASE: superset
|
||||
MYSQL_USER: superset
|
||||
MYSQL_PASSWORD: superset
|
||||
MYSQL_ROOT_PASSWORD: root
|
||||
# The original 5432 port mapping is harmless on a MySQL container
|
||||
# (nothing listens on 5432 inside it) but we add 13306->3306 so the
|
||||
# MySQL port is reachable from the host without colliding with a
|
||||
# native MySQL on 3306. Compose merges port lists.
|
||||
ports:
|
||||
- "127.0.0.1:${DATABASE_PORT_MYSQL:-13306}:3306"
|
||||
# Override the init-scripts mount by re-binding the same target path
|
||||
# to a MySQL-compatible directory. Compose merges volume lists by
|
||||
# target path; later definitions win on conflict, so this displaces
|
||||
# the Postgres-specific ``./docker/docker-entrypoint-initdb.d`` mount
|
||||
# from docker-compose.yml. Without this, MySQL would try to run
|
||||
# ``cypress-init.sh`` (which invokes ``psql``, not in the MySQL
|
||||
# image), abort the init phase, and never create the ``examples``
|
||||
# database. Add the MySQL data volume separately.
|
||||
volumes:
|
||||
- db_home_mysql:/var/lib/mysql
|
||||
- ./docker/mysql-init:/docker-entrypoint-initdb.d
|
||||
command:
|
||||
- --default-authentication-plugin=caching_sha2_password
|
||||
- --character-set-server=utf8mb4
|
||||
- --collation-server=utf8mb4_0900_ai_ci
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uroot -proot --silent"]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 20
|
||||
|
||||
superset:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-init:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-worker:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-worker-beat:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-node:
|
||||
environment: *mysql-env
|
||||
|
||||
superset-tests-worker:
|
||||
environment: *mysql-env
|
||||
|
||||
volumes:
|
||||
db_home_mysql:
|
||||
@@ -0,0 +1,32 @@
|
||||
-- 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.
|
||||
|
||||
-- MySQL counterpart to docker/docker-entrypoint-initdb.d/examples-init.sh.
|
||||
-- Creates the analytics-examples database and user that Superset's
|
||||
-- ``load-examples`` command writes to. Mounted by docker-compose-mysql.yml
|
||||
-- at /docker-entrypoint-initdb.d/ so the MySQL image's first-boot
|
||||
-- entrypoint runs it automatically. (The Postgres init scripts under
|
||||
-- docker/docker-entrypoint-initdb.d/ are NOT mounted on the MySQL
|
||||
-- service — they invoke psql, which doesn't exist in the MySQL image.)
|
||||
|
||||
CREATE DATABASE IF NOT EXISTS examples
|
||||
CHARACTER SET utf8mb4
|
||||
COLLATE utf8mb4_0900_ai_ci;
|
||||
|
||||
CREATE USER IF NOT EXISTS 'examples'@'%' IDENTIFIED BY 'examples';
|
||||
GRANT ALL PRIVILEGES ON examples.* TO 'examples'@'%';
|
||||
FLUSH PRIVILEGES;
|
||||
+3
-7
@@ -70,9 +70,9 @@
|
||||
"@storybook/preview-api": "^8.6.18",
|
||||
"@storybook/theming": "^8.6.15",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
@@ -128,11 +128,7 @@
|
||||
"react-redux": "^9.2.0",
|
||||
"@reduxjs/toolkit": "^2.5.0",
|
||||
"baseline-browser-mapping": "^2.9.19",
|
||||
"swagger-client": "3.37.3",
|
||||
"lodash": "4.18.1",
|
||||
"lodash-es": "4.18.1",
|
||||
"yaml": "1.10.3",
|
||||
"uuid": "11.1.1"
|
||||
"swagger-client": "3.37.3"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
|
||||
}
|
||||
|
||||
+96
-81
@@ -4033,86 +4033,86 @@
|
||||
dependencies:
|
||||
apg-lite "^1.0.4"
|
||||
|
||||
"@swc/core-darwin-arm64@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz#b05d715b04c4fd47baf59288233da85a683cc0bc"
|
||||
integrity sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==
|
||||
"@swc/core-darwin-arm64@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz#d84134fb80417d41128739f0b9014542e3ed9dd3"
|
||||
integrity sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==
|
||||
|
||||
"@swc/core-darwin-x64@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz#3180daef5c1e47b435f8edd084509e0a5c0d883b"
|
||||
integrity sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==
|
||||
"@swc/core-darwin-x64@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz#0badb9834071f1c6005986571d4a96359c1d7cd0"
|
||||
integrity sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==
|
||||
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz#18fcd3c70e48fdfae07c9f18751b1409ce1e5e84"
|
||||
integrity sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==
|
||||
"@swc/core-linux-arm-gnueabihf@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz#b7577a825b59d98b6a9a5c991d842046efe1c34a"
|
||||
integrity sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==
|
||||
|
||||
"@swc/core-linux-arm64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz#26304933922f2a8e3194770e404403fc25a19c89"
|
||||
integrity sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==
|
||||
"@swc/core-linux-arm64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz#304c48321494a18c67b2913c273b08674ee70d8c"
|
||||
integrity sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==
|
||||
|
||||
"@swc/core-linux-arm64-musl@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz#3402dfba04ba7b8ea81f243e2f8fa2c336b54d03"
|
||||
integrity sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==
|
||||
"@swc/core-linux-arm64-musl@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz#d116cbc04ccb4f4ee810da6bca79d4423605dbcd"
|
||||
integrity sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==
|
||||
|
||||
"@swc/core-linux-ppc64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz#b3df9065cad352328c1eeef08a28fc9fe98785aa"
|
||||
integrity sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==
|
||||
"@swc/core-linux-ppc64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz#f5354dba36db9414305bab344c817d57b8b457c2"
|
||||
integrity sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==
|
||||
|
||||
"@swc/core-linux-s390x-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz#58e5b601f641dde81b30626ef66a668701ec918f"
|
||||
integrity sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==
|
||||
"@swc/core-linux-s390x-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz#016df9f4c9d7fd65b85ca9c558c5aec341f06da0"
|
||||
integrity sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==
|
||||
|
||||
"@swc/core-linux-x64-gnu@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz#cf057dce0c148c53f2d30152baaf60ea29e5d59c"
|
||||
integrity sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==
|
||||
"@swc/core-linux-x64-gnu@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz#49f36558ede072e71999aa37f123367daed2a662"
|
||||
integrity sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==
|
||||
|
||||
"@swc/core-linux-x64-musl@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz#21fb1a4d0193e9bbcd1469ecd36166d2e96e4006"
|
||||
integrity sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==
|
||||
"@swc/core-linux-x64-musl@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz#b096665f5cfeee2612325f301da5c1590b10d8f3"
|
||||
integrity sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==
|
||||
|
||||
"@swc/core-win32-arm64-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz#1dba23b2b0db86b3d6d65da2abd627cc607a1fbc"
|
||||
integrity sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==
|
||||
"@swc/core-win32-arm64-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz#f3101263a0dbaa173ec47638c9719d0b89838bd2"
|
||||
integrity sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==
|
||||
|
||||
"@swc/core-win32-ia32-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz#b2da1e33165d469467b1046a2189db468da488eb"
|
||||
integrity sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==
|
||||
"@swc/core-win32-ia32-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz#eb981ef5613d42c9220559bdb0c8bc58cf6c3eb9"
|
||||
integrity sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==
|
||||
|
||||
"@swc/core-win32-x64-msvc@1.15.40":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz#3563f7e8ce8708f5fda43eb8e0956ef11e0da320"
|
||||
integrity sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==
|
||||
"@swc/core-win32-x64-msvc@1.15.33":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz#a2fed9956933027ceb368857bac4bb4ee203d47c"
|
||||
integrity sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==
|
||||
|
||||
"@swc/core@^1.15.40", "@swc/core@^1.7.39":
|
||||
version "1.15.40"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.40.tgz#941c949aa88c0d8d291f102f519f3c2c77701b90"
|
||||
integrity sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==
|
||||
"@swc/core@^1.15.33", "@swc/core@^1.7.39":
|
||||
version "1.15.33"
|
||||
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.33.tgz#2a6571c8aca961925f14beae52b3f43c18370fc6"
|
||||
integrity sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==
|
||||
dependencies:
|
||||
"@swc/counter" "^0.1.3"
|
||||
"@swc/types" "^0.1.26"
|
||||
optionalDependencies:
|
||||
"@swc/core-darwin-arm64" "1.15.40"
|
||||
"@swc/core-darwin-x64" "1.15.40"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.40"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.40"
|
||||
"@swc/core-linux-arm64-musl" "1.15.40"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.40"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.40"
|
||||
"@swc/core-linux-x64-gnu" "1.15.40"
|
||||
"@swc/core-linux-x64-musl" "1.15.40"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.40"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.40"
|
||||
"@swc/core-win32-x64-msvc" "1.15.40"
|
||||
"@swc/core-darwin-arm64" "1.15.33"
|
||||
"@swc/core-darwin-x64" "1.15.33"
|
||||
"@swc/core-linux-arm-gnueabihf" "1.15.33"
|
||||
"@swc/core-linux-arm64-gnu" "1.15.33"
|
||||
"@swc/core-linux-arm64-musl" "1.15.33"
|
||||
"@swc/core-linux-ppc64-gnu" "1.15.33"
|
||||
"@swc/core-linux-s390x-gnu" "1.15.33"
|
||||
"@swc/core-linux-x64-gnu" "1.15.33"
|
||||
"@swc/core-linux-x64-musl" "1.15.33"
|
||||
"@swc/core-win32-arm64-msvc" "1.15.33"
|
||||
"@swc/core-win32-ia32-msvc" "1.15.33"
|
||||
"@swc/core-win32-x64-msvc" "1.15.33"
|
||||
|
||||
"@swc/counter@^0.1.3":
|
||||
version "0.1.3"
|
||||
@@ -5568,10 +5568,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.32, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.32"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz#b6b553a4285fdd606327a617de36a5351e3aaa64"
|
||||
integrity sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==
|
||||
baseline-browser-mapping@^2.10.31, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.31"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz#9c6825f052601ce6974a90dd49683b1726887b0b"
|
||||
integrity sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -9676,10 +9676,10 @@ locate-path@^7.1.0:
|
||||
dependencies:
|
||||
p-locate "^6.0.0"
|
||||
|
||||
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-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.debounce@^4, lodash.debounce@^4.0.8:
|
||||
version "4.0.8"
|
||||
@@ -9701,7 +9701,12 @@ 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, 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:
|
||||
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:
|
||||
version "4.18.1"
|
||||
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c"
|
||||
integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==
|
||||
@@ -14721,10 +14726,15 @@ utils-merge@1.0.1:
|
||||
resolved "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz"
|
||||
integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==
|
||||
|
||||
uuid@11.1.1, uuid@8.3.2, "uuid@^11.1.0 || ^12 || ^13 || ^14.0.0", uuid@^8.3.2:
|
||||
version "11.1.1"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.1.tgz#f6d81d2e1c65d00762e5e29b16c5d2d995e208ad"
|
||||
integrity sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==
|
||||
uuid@8.3.2, uuid@^8.3.2:
|
||||
version "8.3.2"
|
||||
resolved "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz"
|
||||
integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
|
||||
"uuid@^11.1.0 || ^12 || ^13 || ^14.0.0":
|
||||
version "14.0.0"
|
||||
resolved "https://registry.yarnpkg.com/uuid/-/uuid-14.0.0.tgz#0af883220163d264ffe0c084f6b8a89b9666966d"
|
||||
integrity sha512-Qo+uWgilfSmAhXCMav1uYFynlQO7fMFiMVZsQqZRMIXp0O7rR7qjkj+cPvBHLgBqi960QCoo/PH2/6ZtVqKvrg==
|
||||
|
||||
uvu@^0.5.0:
|
||||
version "0.5.6"
|
||||
@@ -15129,9 +15139,9 @@ ws@^7.3.1:
|
||||
integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==
|
||||
|
||||
ws@^8.18.0, ws@^8.2.3:
|
||||
version "8.20.1"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz"
|
||||
integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==
|
||||
version "8.18.3"
|
||||
resolved "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz"
|
||||
integrity sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==
|
||||
|
||||
wsl-utils@^0.1.0:
|
||||
version "0.1.0"
|
||||
@@ -15199,7 +15209,12 @@ 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, yaml@1.10.3, yaml@^1.10.0:
|
||||
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:
|
||||
version "1.10.3"
|
||||
resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.3.tgz#76e407ed95c42684fb8e14641e5de62fe65bbcb3"
|
||||
integrity sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==
|
||||
|
||||
@@ -100,6 +100,7 @@ dependencies = [
|
||||
"simplejson>=3.15.0",
|
||||
"slack_sdk>=3.19.0, <4",
|
||||
"sqlalchemy>=1.4, <2",
|
||||
"sqlalchemy-continuum>=1.6.0, <2.0.0",
|
||||
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
|
||||
"sqlglot>=30.8.0, <31",
|
||||
# newer pandas needs 0.9+
|
||||
|
||||
@@ -208,7 +208,7 @@ kombu==5.5.3
|
||||
# via celery
|
||||
limits==5.1.0
|
||||
# via flask-limiter
|
||||
mako==1.3.12
|
||||
mako==1.3.11
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -409,7 +409,10 @@ sqlalchemy==1.4.54
|
||||
# flask-sqlalchemy
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-continuum==1.6.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
@@ -451,7 +454,7 @@ tzdata==2025.2
|
||||
# pandas
|
||||
url-normalize==2.2.1
|
||||
# via requests-cache
|
||||
urllib3==2.7.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# requests
|
||||
|
||||
@@ -346,7 +346,6 @@ google-auth==2.43.0
|
||||
# google-api-core
|
||||
# google-auth-oauthlib
|
||||
# google-cloud-bigquery
|
||||
# google-cloud-bigquery-storage
|
||||
# google-cloud-core
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
@@ -361,7 +360,7 @@ google-cloud-bigquery==3.27.0
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
# sqlalchemy-bigquery
|
||||
google-cloud-bigquery-storage==2.26.0
|
||||
google-cloud-bigquery-storage==2.19.1
|
||||
# via pandas-gbq
|
||||
google-cloud-core==2.4.1
|
||||
# via google-cloud-bigquery
|
||||
@@ -507,7 +506,7 @@ limits==5.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-limiter
|
||||
mako==1.3.12
|
||||
mako==1.3.11
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
@@ -702,7 +701,7 @@ proto-plus==1.25.0
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-bigquery-storage
|
||||
protobuf==5.29.6
|
||||
protobuf==4.25.8
|
||||
# via
|
||||
# google-api-core
|
||||
# google-cloud-bigquery-storage
|
||||
@@ -840,7 +839,7 @@ python-dotenv==1.2.2
|
||||
# pydantic-settings
|
||||
python-ldap==3.4.4
|
||||
# via apache-superset
|
||||
python-multipart==0.0.29
|
||||
python-multipart==0.0.20
|
||||
# via mcp
|
||||
pytz==2025.2
|
||||
# via
|
||||
@@ -979,9 +978,14 @@ sqlalchemy==1.4.54
|
||||
# marshmallow-sqlalchemy
|
||||
# shillelagh
|
||||
# sqlalchemy-bigquery
|
||||
# sqlalchemy-continuum
|
||||
# sqlalchemy-utils
|
||||
sqlalchemy-bigquery==1.15.0
|
||||
# via apache-superset
|
||||
sqlalchemy-continuum==1.6.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
sqlalchemy-utils==0.42.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -1072,7 +1076,7 @@ url-normalize==2.2.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# requests-cache
|
||||
urllib3==2.7.0
|
||||
urllib3==2.6.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# botocore
|
||||
|
||||
@@ -33,7 +33,6 @@ PATTERNS = {
|
||||
r"^setup\.py",
|
||||
r"^pyproject\.toml$",
|
||||
r"^requirements/.+\.txt",
|
||||
r"^pyproject\.toml",
|
||||
r"^.pylintrc",
|
||||
],
|
||||
"frontend": [
|
||||
@@ -157,7 +156,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: S603, S607
|
||||
["git", "rev-parse", "HEAD"] # noqa: S607
|
||||
).strip().decode("utf-8")
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ if [ ${#js_ts_files[@]} -gt 0 ]; then
|
||||
echo "$output" >&2
|
||||
exit 1
|
||||
}
|
||||
if [ -n "$output" ]; then echo "$output"; fi
|
||||
[ -n "$output" ] && echo "$output"
|
||||
else
|
||||
echo "No JavaScript/TypeScript files to lint"
|
||||
fi
|
||||
|
||||
@@ -0,0 +1,682 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
#
|
||||
# ----------------------------------------------------------------------
|
||||
# Stress-test data generator for the composite-PK migration (sc-105349).
|
||||
#
|
||||
# Bulk-inserts synthetic parent rows and many-to-many junction rows for
|
||||
# the eight association tables that the composite-PK migration touches.
|
||||
# Useful for measuring migration runtime at varying scales — run this at
|
||||
# 100K / 1M / 5M / 10M rows and time the migration at each scale to
|
||||
# verify the O(N log N) extrapolation.
|
||||
#
|
||||
# Idempotent: rerunning with the same target is a no-op; rerunning with
|
||||
# a higher target adds rows up to the new total. Batched bulk INSERTs
|
||||
# (10K rows per statement) make it fast on Postgres, MySQL, and SQLite.
|
||||
#
|
||||
# Usage (inside the Superset container):
|
||||
#
|
||||
# docker exec superset-superset-1 \\
|
||||
# /app/.venv/bin/python /app/scripts/seed_junction_load.py \\
|
||||
# --dashboard-slices 1000000 \\
|
||||
# --slice-user 100000 \\
|
||||
# --dashboard-user 100000
|
||||
#
|
||||
# Run with no flags for the defaults shown below. Use ``--dry-run`` to
|
||||
# print the planned inserts without writing anything.
|
||||
#
|
||||
# The script connects via Superset's standard ``DATABASE_*`` env vars
|
||||
# (or ``SUPERSET__SQLALCHEMY_DATABASE_URI`` if set), so it works
|
||||
# automatically inside the Superset container regardless of which
|
||||
# metadata DB backend is in use.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from contextlib import contextmanager
|
||||
from typing import Iterator
|
||||
from uuid import uuid4
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.engine import Connection, Engine
|
||||
|
||||
logger = logging.getLogger("seed_junction_load")
|
||||
|
||||
# Bulk INSERT batch size. Larger values = fewer statements but more memory.
|
||||
BATCH = 10_000
|
||||
|
||||
# Default per-junction-table target row counts. Tuned to mimic the shape
|
||||
# of a large multi-team Superset install. Override via CLI flags.
|
||||
DEFAULTS: dict[str, int] = {
|
||||
"dashboard_slices": 1_000_000,
|
||||
"slice_user": 100_000,
|
||||
"dashboard_user": 100_000,
|
||||
"dashboard_roles": 10_000,
|
||||
}
|
||||
|
||||
# (junction_table, fk1_col, fk2_col, parent1_table, parent2_table)
|
||||
# parents reference id columns; we generate (fk1, fk2) pairs by sampling
|
||||
# from the parents' existing IDs.
|
||||
JUNCTIONS: list[tuple[str, str, str, str, str]] = [
|
||||
("dashboard_slices", "dashboard_id", "slice_id", "dashboards", "slices"),
|
||||
("slice_user", "user_id", "slice_id", "ab_user", "slices"),
|
||||
("dashboard_user", "user_id", "dashboard_id", "ab_user", "dashboards"),
|
||||
("dashboard_roles", "dashboard_id", "role_id", "dashboards", "ab_role"),
|
||||
]
|
||||
|
||||
# Junction tables that originally carried ``UNIQUE(fk1, fk2)`` and therefore
|
||||
# cannot accept duplicate ``(fk1, fk2)`` pairs even on the pre-migration
|
||||
# (downgrade) schema. The other JUNCTIONS allow duplicates pre-migration.
|
||||
JUNCTIONS_WITH_UNIQUE: set[str] = {"dashboard_slices", "report_schedule_user"}
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Connection setup
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_engine() -> Engine:
|
||||
"""Build a SQLAlchemy engine from Superset env vars."""
|
||||
if uri := os.environ.get("SUPERSET__SQLALCHEMY_DATABASE_URI"):
|
||||
logger.info("Using SUPERSET__SQLALCHEMY_DATABASE_URI from env")
|
||||
return sa.create_engine(uri)
|
||||
|
||||
try:
|
||||
dialect = os.environ["DATABASE_DIALECT"]
|
||||
user = os.environ["DATABASE_USER"]
|
||||
password = os.environ["DATABASE_PASSWORD"]
|
||||
host = os.environ["DATABASE_HOST"]
|
||||
port = os.environ["DATABASE_PORT"]
|
||||
db = os.environ["DATABASE_DB"]
|
||||
except KeyError as exc:
|
||||
sys.exit(
|
||||
f"Missing env var {exc}; either set DATABASE_DIALECT/USER/PASSWORD/"
|
||||
f"HOST/PORT/DB or SUPERSET__SQLALCHEMY_DATABASE_URI before running."
|
||||
)
|
||||
|
||||
uri = f"{dialect}://{user}:{password}@{host}:{port}/{db}"
|
||||
logger.info(
|
||||
"Built URI from DATABASE_* env vars (dialect=%s, host=%s)", dialect, host
|
||||
)
|
||||
return sa.create_engine(uri)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def uuid_value(dialect_name: str) -> bytes | str:
|
||||
"""Return a UUID in the form the active dialect expects.
|
||||
|
||||
MySQL stores UUIDs as ``BINARY(16)`` (16 raw bytes); Postgres has a
|
||||
native ``UUID`` type that accepts strings; SQLite stores them as
|
||||
BLOB/TEXT and accepts either. Branching here keeps the seed script
|
||||
backend-agnostic without depending on Superset's custom column types.
|
||||
"""
|
||||
if dialect_name.startswith("mysql"):
|
||||
return uuid4().bytes
|
||||
return str(uuid4())
|
||||
|
||||
|
||||
@contextmanager
|
||||
def time_phase(name: str) -> Iterator[None]:
|
||||
"""Log elapsed wall time for a named phase."""
|
||||
start = time.monotonic()
|
||||
logger.info("[%s] starting", name)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
elapsed = time.monotonic() - start
|
||||
logger.info("[%s] done in %.2fs", name, elapsed)
|
||||
|
||||
|
||||
def count_rows(conn: Connection, table: str) -> int:
|
||||
return conn.scalar(sa.text(f"SELECT COUNT(*) FROM {table}")) or 0 # noqa: S608
|
||||
|
||||
|
||||
def existing_ids(conn: Connection, table: str, limit: int | None = None) -> list[int]:
|
||||
sql = f"SELECT id FROM {table} ORDER BY id" # noqa: S608
|
||||
if limit is not None:
|
||||
sql += f" LIMIT {limit}"
|
||||
return [row[0] for row in conn.execute(sa.text(sql))]
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Parent seeders
|
||||
#
|
||||
# Each function ensures the named parent table has at least ``target``
|
||||
# rows by inserting synthetic ones with minimal-but-valid columns.
|
||||
# Returns nothing; subsequent code reads back IDs via ``existing_ids``.
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def seed_dashboards(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "dashboards")
|
||||
if current >= target:
|
||||
logger.info(
|
||||
"dashboards: %d rows (target %d) — no insert needed", current, target
|
||||
)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("dashboards: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO dashboards (uuid, dashboard_title, slug, published) "
|
||||
"VALUES (:uuid, :title, :slug, :published)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"title": f"seed_dashboard_{current + i}",
|
||||
"slug": f"seed-dashboard-{current + i}-{uuid4().hex[:8]}",
|
||||
"published": False,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" dashboards: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_dbs(conn: Connection, dry_run: bool) -> int:
|
||||
"""Ensure at least one row exists in ``dbs`` (parent of ``tables``).
|
||||
Returns the id to use as ``database_id`` when seeding ``tables``."""
|
||||
ids = existing_ids(conn, "dbs", limit=1)
|
||||
if ids:
|
||||
return ids[0]
|
||||
if dry_run:
|
||||
return -1 # placeholder
|
||||
dialect = conn.engine.dialect.name
|
||||
logger.info("dbs: inserting one synthetic database (no rows present)")
|
||||
conn.execute(
|
||||
sa.text(
|
||||
"INSERT INTO dbs (uuid, database_name, sqlalchemy_uri, expose_in_sqllab) "
|
||||
"VALUES (:uuid, :name, :uri, :expose)"
|
||||
),
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_db_{uuid4().hex[:8]}",
|
||||
"uri": "sqlite:///seed.db",
|
||||
"expose": False,
|
||||
},
|
||||
)
|
||||
return existing_ids(conn, "dbs", limit=1)[0]
|
||||
|
||||
|
||||
def seed_tables(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "tables")
|
||||
if current >= target:
|
||||
logger.info("tables: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("tables: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
database_id = seed_dbs(conn, dry_run=False)
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO tables (uuid, table_name, database_id) "
|
||||
"VALUES (:uuid, :name, :db_id)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_table_{current + i}",
|
||||
"db_id": database_id,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" tables: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_slices(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "slices")
|
||||
if current >= target:
|
||||
logger.info("slices: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("slices: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
# Slices reference tables.id; ensure at least one ``tables`` row exists
|
||||
# so the FK is satisfiable (datasource_id is nullable but we set it for
|
||||
# realism). The migration test doesn't care, but a real Superset that
|
||||
# re-renders these slices does.
|
||||
seed_tables(conn, target=1, dry_run=False)
|
||||
table_id = existing_ids(conn, "tables", limit=1)[0]
|
||||
dialect = conn.engine.dialect.name
|
||||
sql = sa.text(
|
||||
"INSERT INTO slices "
|
||||
"(uuid, slice_name, datasource_id, datasource_type, viz_type) "
|
||||
"VALUES (:uuid, :name, :ds_id, :ds_type, :viz)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"uuid": uuid_value(dialect),
|
||||
"name": f"seed_slice_{current + i}",
|
||||
"ds_id": table_id,
|
||||
"ds_type": "table",
|
||||
"viz": "table",
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" slices: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_users(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "ab_user")
|
||||
if current >= target:
|
||||
logger.info("ab_user: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("ab_user: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
sql = sa.text(
|
||||
"INSERT INTO ab_user (first_name, last_name, username, email, active) "
|
||||
"VALUES (:first, :last, :username, :email, :active)"
|
||||
)
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{
|
||||
"first": "seed",
|
||||
"last": f"user_{current + i}",
|
||||
"username": f"seed_user_{current + i}_{uuid4().hex[:8]}",
|
||||
"email": f"seed_user_{current + i}_{uuid4().hex[:8]}@example.invalid",
|
||||
"active": True,
|
||||
}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" ab_user: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
def seed_roles(conn: Connection, target: int, dry_run: bool) -> None:
|
||||
current = count_rows(conn, "ab_role")
|
||||
if current >= target:
|
||||
logger.info("ab_role: %d rows (target %d) — no insert needed", current, target)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("ab_role: %d → %d (+%d)", current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
sql = sa.text("INSERT INTO ab_role (name) VALUES (:name)")
|
||||
for batch_start in range(0, needed, BATCH):
|
||||
rows = [
|
||||
{"name": f"seed_role_{current + i}_{uuid4().hex[:8]}"}
|
||||
for i in range(batch_start, min(batch_start + BATCH, needed))
|
||||
]
|
||||
conn.execute(sql, rows)
|
||||
logger.info(" ab_role: inserted %d / %d", batch_start + len(rows), needed)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Junction seeder
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def _load_existing_pairs(
|
||||
conn: Connection, junction: str, fk1_col: str, fk2_col: str
|
||||
) -> set[tuple[int, int]]:
|
||||
"""Load existing ``(fk1, fk2)`` pairs from a junction table into a set.
|
||||
|
||||
Used so the seeder can skip them when generating new pairs (junction
|
||||
tables enforce uniqueness on the FK pair). Memory is ~32 bytes/tuple
|
||||
on CPython, so 10M existing pairs is ~320MB — acceptable for a dev
|
||||
machine. The junction / column names come from ``JUNCTIONS``, not
|
||||
user input, so the f-string interpolation is safe.
|
||||
"""
|
||||
sql_text = f"SELECT {fk1_col}, {fk2_col} FROM {junction}" # noqa: S608
|
||||
return {(row[0], row[1]) for row in conn.execute(sa.text(sql_text))}
|
||||
|
||||
|
||||
def _generate_new_pairs(
|
||||
p1_ids: list[int],
|
||||
p2_ids: list[int],
|
||||
existing_pairs: set[tuple[int, int]],
|
||||
) -> Iterator[tuple[int, int]]:
|
||||
"""Yield ``(fk1, fk2)`` pairs from the parent1 × parent2 cross-product
|
||||
that are not already in ``existing_pairs``."""
|
||||
for fk1 in p1_ids:
|
||||
for fk2 in p2_ids:
|
||||
if (fk1, fk2) not in existing_pairs:
|
||||
yield (fk1, fk2)
|
||||
|
||||
|
||||
def seed_junction(
|
||||
conn: Connection,
|
||||
junction: str,
|
||||
fk1_col: str,
|
||||
fk2_col: str,
|
||||
parent1: str,
|
||||
parent2: str,
|
||||
target: int,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Bulk-insert junction rows up to ``target`` rows total.
|
||||
|
||||
Generates ``(fk1, fk2)`` pairs by walking the cross-product of
|
||||
parent1 IDs × parent2 IDs in row-major order, skipping pairs that
|
||||
already exist. Walking the cross-product deterministically keeps
|
||||
the script replayable: re-running with the same target is a no-op,
|
||||
and re-running with a higher target appends new pairs in a stable
|
||||
order regardless of how many runs preceded.
|
||||
"""
|
||||
current = count_rows(conn, junction)
|
||||
if current >= target:
|
||||
logger.info(
|
||||
"%s: %d rows (target %d) — no insert needed", junction, current, target
|
||||
)
|
||||
return
|
||||
needed = target - current
|
||||
logger.info("%s: %d → %d (+%d)", junction, current, target, needed)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
p1_ids = existing_ids(conn, parent1)
|
||||
p2_ids = existing_ids(conn, parent2)
|
||||
max_pairs = len(p1_ids) * len(p2_ids)
|
||||
if max_pairs < target:
|
||||
sys.exit(
|
||||
f"Cannot reach {target} rows in {junction}: "
|
||||
f"only {max_pairs} unique pairs available "
|
||||
f"({len(p1_ids)} × {len(p2_ids)}). "
|
||||
f"Increase parent targets and rerun."
|
||||
)
|
||||
|
||||
existing_pairs: set[tuple[int, int]] = (
|
||||
_load_existing_pairs(conn, junction, fk1_col, fk2_col) if current > 0 else set()
|
||||
)
|
||||
if existing_pairs:
|
||||
logger.info(
|
||||
" %s: loaded %d existing pairs into avoidance set",
|
||||
junction,
|
||||
len(existing_pairs),
|
||||
)
|
||||
|
||||
insert_sql = sa.text(
|
||||
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
|
||||
f"VALUES (:fk1, :fk2)"
|
||||
)
|
||||
|
||||
inserted = 0
|
||||
batch: list[dict[str, int]] = []
|
||||
for fk1, fk2 in _generate_new_pairs(p1_ids, p2_ids, existing_pairs):
|
||||
batch.append({"fk1": fk1, "fk2": fk2})
|
||||
inserted += 1
|
||||
if len(batch) == BATCH or inserted == needed:
|
||||
conn.execute(insert_sql, batch)
|
||||
logger.info(" %s: inserted %d / %d", junction, inserted, needed)
|
||||
batch = []
|
||||
if inserted == needed:
|
||||
return
|
||||
if inserted < needed:
|
||||
sys.exit(
|
||||
f"Ran out of unique pairs at {inserted}/{needed} for {junction} "
|
||||
f"(parents have {len(p1_ids)} × {len(p2_ids)} = {max_pairs} pairs, "
|
||||
f"{len(existing_pairs)} already present)"
|
||||
)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# Orchestration
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def required_parent_count(target_pairs: int, other_parent: int) -> int:
|
||||
"""How many rows we need in this parent so that
|
||||
(this_parent × other_parent) ≥ target_pairs."""
|
||||
if other_parent == 0:
|
||||
# Bootstrapping: assume we'll create at least 1
|
||||
other_parent = 1
|
||||
return -(-target_pairs // other_parent) # ceil(target_pairs / other_parent)
|
||||
|
||||
|
||||
def _compute_parent_requirements(targets: dict[str, int]) -> dict[str, int]:
|
||||
"""For each parent table, return the minimum row count needed so that
|
||||
parent1 × parent2 ≥ target for every junction it participates in.
|
||||
|
||||
Allocates ceil(sqrt(target)) rows per parent, balanced across the two
|
||||
parents of each junction. The actual junction seeder will then walk
|
||||
the cross-product to produce the target number of unique pairs.
|
||||
"""
|
||||
parent_req: dict[str, int] = {}
|
||||
for junction, _, _, p1, p2 in JUNCTIONS:
|
||||
target = targets.get(junction, 0)
|
||||
if target == 0:
|
||||
continue
|
||||
sqrt_n = int(target**0.5) + 1
|
||||
parent_req[p1] = max(parent_req.get(p1, 0), sqrt_n)
|
||||
parent_req[p2] = max(parent_req.get(p2, 0), sqrt_n)
|
||||
return parent_req
|
||||
|
||||
|
||||
def _seed_parents(conn: Connection, parent_req: dict[str, int], dry_run: bool) -> None:
|
||||
"""Seed parent tables in dependency order:
|
||||
independent parents (ab_user, ab_role) first, then dashboards / slices /
|
||||
tables (which transitively depend on dbs, seeded inside seed_tables)."""
|
||||
if "ab_user" in parent_req:
|
||||
seed_users(conn, parent_req["ab_user"], dry_run)
|
||||
if "ab_role" in parent_req:
|
||||
seed_roles(conn, parent_req["ab_role"], dry_run)
|
||||
if "dashboards" in parent_req:
|
||||
seed_dashboards(conn, parent_req["dashboards"], dry_run)
|
||||
if "slices" in parent_req:
|
||||
seed_slices(conn, parent_req["slices"], dry_run)
|
||||
if "tables" in parent_req:
|
||||
seed_tables(conn, parent_req["tables"], dry_run)
|
||||
|
||||
|
||||
def _seed_all_junctions(
|
||||
conn: Connection, targets: dict[str, int], dry_run: bool
|
||||
) -> None:
|
||||
for junction, fk1, fk2, p1, p2 in JUNCTIONS:
|
||||
target = targets.get(junction, 0)
|
||||
if target == 0:
|
||||
continue
|
||||
with time_phase(f"junction:{junction}"):
|
||||
seed_junction(conn, junction, fk1, fk2, p1, p2, target, dry_run)
|
||||
|
||||
|
||||
def inject_duplicates(
|
||||
conn: Connection,
|
||||
junction: str,
|
||||
fk1_col: str,
|
||||
fk2_col: str,
|
||||
pct: float,
|
||||
dry_run: bool,
|
||||
) -> None:
|
||||
"""Insert duplicate ``(fk1, fk2)`` rows on a non-UNIQUE junction table.
|
||||
|
||||
Used to stress-test the migration's ``_dedupe_by_min_id`` phase, which
|
||||
is otherwise a no-op on cleanly-seeded data. Computes ``count =
|
||||
current_rows * pct / 100`` and inserts that many rows by re-sampling
|
||||
existing ``(fk1, fk2)`` pairs in row-major order. The synthetic
|
||||
duplicates land on top of distinct existing pairs (one duplicate per
|
||||
distinct pair, then wraps), so the migration's dedupe finds and
|
||||
deletes them.
|
||||
|
||||
**Pre-condition: the table must NOT have UNIQUE on (fk1, fk2)**, i.e.,
|
||||
the schema must be the pre-migration shape (after running
|
||||
``superset db downgrade``). On the post-migration schema the composite
|
||||
PK rejects duplicates and this function will error.
|
||||
"""
|
||||
if pct == 0:
|
||||
return
|
||||
current = count_rows(conn, junction)
|
||||
count = int(current * pct / 100)
|
||||
if count == 0:
|
||||
logger.info(
|
||||
"%s: 0 duplicates to inject (current=%d, pct=%g)",
|
||||
junction,
|
||||
current,
|
||||
pct,
|
||||
)
|
||||
return
|
||||
logger.info(
|
||||
"%s: injecting %d duplicate rows (%g%% of %d existing)",
|
||||
junction,
|
||||
count,
|
||||
pct,
|
||||
current,
|
||||
)
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
select_sql = sa.text(
|
||||
f"SELECT {fk1_col}, {fk2_col} FROM {junction} ORDER BY id LIMIT :n" # noqa: S608
|
||||
)
|
||||
sample = conn.execute(select_sql, {"n": count}).fetchall()
|
||||
if not sample:
|
||||
logger.warning("%s: no rows to duplicate (table is empty)", junction)
|
||||
return
|
||||
|
||||
insert_sql = sa.text(
|
||||
f"INSERT INTO {junction} ({fk1_col}, {fk2_col}) " # noqa: S608
|
||||
f"VALUES (:fk1, :fk2)"
|
||||
)
|
||||
inserted = 0
|
||||
while inserted < count:
|
||||
batch: list[dict[str, int]] = []
|
||||
while len(batch) < BATCH and inserted < count:
|
||||
row = sample[inserted % len(sample)]
|
||||
batch.append({"fk1": row[0], "fk2": row[1]})
|
||||
inserted += 1
|
||||
conn.execute(insert_sql, batch)
|
||||
logger.info(" %s: injected %d / %d duplicates", junction, inserted, count)
|
||||
|
||||
|
||||
def _inject_dirty_data(conn: Connection, dirty_pct: float, dry_run: bool) -> None:
|
||||
"""Inject duplicate rows on every non-UNIQUE seeded junction.
|
||||
|
||||
The two tables that originally carried ``UNIQUE(fk1, fk2)`` are
|
||||
skipped because their composite-PK successor (and their pre-migration
|
||||
UNIQUE constraint) both reject duplicate inserts.
|
||||
"""
|
||||
if dirty_pct == 0:
|
||||
return
|
||||
for junction, fk1, fk2, _, _ in JUNCTIONS:
|
||||
if junction in JUNCTIONS_WITH_UNIQUE:
|
||||
logger.info(
|
||||
"%s: skipping duplicate injection (table has UNIQUE on FK pair)",
|
||||
junction,
|
||||
)
|
||||
continue
|
||||
with time_phase(f"dirty:{junction}"):
|
||||
inject_duplicates(conn, junction, fk1, fk2, dirty_pct, dry_run)
|
||||
|
||||
|
||||
def run(targets: dict[str, int], dry_run: bool, dirty_duplicates_pct: float) -> None:
|
||||
engine = build_engine()
|
||||
with engine.begin() as conn:
|
||||
parent_req = _compute_parent_requirements(targets)
|
||||
logger.info("Required parent row counts: %s", parent_req)
|
||||
|
||||
with time_phase("parents"):
|
||||
_seed_parents(conn, parent_req, dry_run)
|
||||
|
||||
with time_phase("junctions"):
|
||||
_seed_all_junctions(conn, targets, dry_run)
|
||||
|
||||
if dirty_duplicates_pct > 0:
|
||||
with time_phase("dirty-duplicates"):
|
||||
_inject_dirty_data(conn, dirty_duplicates_pct, dry_run)
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# CLI
|
||||
# ----------------------------------------------------------------------
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__,
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
)
|
||||
for table, default in DEFAULTS.items():
|
||||
parser.add_argument(
|
||||
f"--{table.replace('_', '-')}",
|
||||
type=int,
|
||||
default=default,
|
||||
help=f"target row count for {table} (default: {default:,})",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry-run",
|
||||
"-n",
|
||||
action="store_true",
|
||||
help="print planned inserts without writing to the DB",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dirty-duplicates-pct",
|
||||
type=float,
|
||||
default=0,
|
||||
help=(
|
||||
"after seeding distinct pairs, inject this percentage of duplicate "
|
||||
"rows on each non-UNIQUE junction (slice_user, dashboard_user, "
|
||||
"dashboard_roles). Stress-tests the migration's _dedupe_by_min_id "
|
||||
"phase. Requires the DB to be at the pre-migration revision "
|
||||
"(33d7e0e21daa) — the post-migration composite PK rejects "
|
||||
"duplicates and this will error. Default: 0 (no duplicates)."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="store_true",
|
||||
help="increase log verbosity",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%H:%M:%S",
|
||||
)
|
||||
|
||||
targets = {table: getattr(args, table) for table in DEFAULTS}
|
||||
|
||||
logger.info("Targets: %s", targets)
|
||||
logger.info("Dry run: %s", args.dry_run)
|
||||
logger.info("Dirty duplicates pct: %g", args.dirty_duplicates_pct)
|
||||
|
||||
with time_phase("total"):
|
||||
run(
|
||||
targets,
|
||||
dry_run=args.dry_run,
|
||||
dirty_duplicates_pct=args.dirty_duplicates_pct,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,24 +0,0 @@
|
||||
/*
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
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)"],
|
||||
};
|
||||
@@ -1,91 +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 {
|
||||
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,9 +50,7 @@ export type UiConfigType = {
|
||||
};
|
||||
|
||||
export type EmbedDashboardParams = {
|
||||
/** 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. */
|
||||
/** The id provided by the embed configuration UI in Superset */
|
||||
id: string;
|
||||
/** The domain where Superset can be located, with protocol, such as: https://superset.example.com */
|
||||
supersetDomain: string;
|
||||
@@ -114,48 +112,6 @@ 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.
|
||||
*/
|
||||
@@ -172,8 +128,6 @@ 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);
|
||||
@@ -185,7 +139,6 @@ export async function embedDashboard({
|
||||
if (supersetDomain.endsWith('/')) {
|
||||
supersetDomain = supersetDomain.slice(0, -1);
|
||||
}
|
||||
validateSupersetDomain(supersetDomain);
|
||||
|
||||
function calculateConfig() {
|
||||
let configNumber = 0;
|
||||
@@ -242,13 +195,6 @@ 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);
|
||||
});
|
||||
@@ -270,9 +216,7 @@ 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' },
|
||||
// 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,
|
||||
supersetDomain,
|
||||
[theirPort],
|
||||
);
|
||||
log('sent message channel to the iframe');
|
||||
|
||||
+6
-6
@@ -8020,9 +8020,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
|
||||
"engines": {
|
||||
"node": ">=14.14"
|
||||
}
|
||||
@@ -14601,9 +14601,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw=="
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
|
||||
Generated
+129
-129
@@ -194,7 +194,7 @@
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
@@ -229,7 +229,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -249,7 +249,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -296,7 +296,7 @@
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -7768,9 +7768,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/arborist/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8034,9 +8034,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/map-workspaces/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8194,9 +8194,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@npmcli/package-json/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -8470,9 +8470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@nx/devkit/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -12310,9 +12310,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.40.tgz",
|
||||
"integrity": "sha512-2kwzJikRvgtNAG7MwVZY2vEzZjTxKIq5jXOihuSV/8U+Hej8Va22t65aKnJZs3P+NwojZvR8Mf8kyM7O+V8sQg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core/-/core-1.15.33.tgz",
|
||||
"integrity": "sha512-jOlwnFV2xhuuZeAUILGFULeR6vDPfijEJ57evfocwznQldLU3w2cZ9bSDryY9ip+AsM3r1NJKzf47V2NXebkeQ==",
|
||||
"devOptional": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "Apache-2.0",
|
||||
@@ -12328,18 +12328,18 @@
|
||||
"url": "https://opencollective.com/swc"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@swc/core-darwin-arm64": "1.15.40",
|
||||
"@swc/core-darwin-x64": "1.15.40",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.40",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.40",
|
||||
"@swc/core-linux-arm64-musl": "1.15.40",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.40",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.40",
|
||||
"@swc/core-linux-x64-gnu": "1.15.40",
|
||||
"@swc/core-linux-x64-musl": "1.15.40",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.40",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.40",
|
||||
"@swc/core-win32-x64-msvc": "1.15.40"
|
||||
"@swc/core-darwin-arm64": "1.15.33",
|
||||
"@swc/core-darwin-x64": "1.15.33",
|
||||
"@swc/core-linux-arm-gnueabihf": "1.15.33",
|
||||
"@swc/core-linux-arm64-gnu": "1.15.33",
|
||||
"@swc/core-linux-arm64-musl": "1.15.33",
|
||||
"@swc/core-linux-ppc64-gnu": "1.15.33",
|
||||
"@swc/core-linux-s390x-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-gnu": "1.15.33",
|
||||
"@swc/core-linux-x64-musl": "1.15.33",
|
||||
"@swc/core-win32-arm64-msvc": "1.15.33",
|
||||
"@swc/core-win32-ia32-msvc": "1.15.33",
|
||||
"@swc/core-win32-x64-msvc": "1.15.33"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@swc/helpers": ">=0.5.17"
|
||||
@@ -12351,9 +12351,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-arm64": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.40.tgz",
|
||||
"integrity": "sha512-PaYyclfmQ++77D8ityYvmmVzHv9aG8ROwt2GfG6/ccloy4Hgf80qtOnzb9VYvPsUT7Ty1uhuDRhv3XYpf62qhQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.33.tgz",
|
||||
"integrity": "sha512-N+L0uXhuO7FIfzqwgxmzv0zIpV0qEp8wPX3QQs2p4atjMoywup2JTeDlXPw+z9pWJGCae3JjM+tZ6myclI+2gA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12367,9 +12367,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-darwin-x64": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.40.tgz",
|
||||
"integrity": "sha512-HbbPzvfLBUXjIB1Ezks+//lNUjmLjfyd63XSwprJgrZaXYdm70kohXPJUWdqKZozolFxbPaO+xtBaiUp6BoueA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-darwin-x64/-/core-darwin-x64-1.15.33.tgz",
|
||||
"integrity": "sha512-/Il4QHSOhV4FekbsDtkrNmKbsX26oSysvgrRswa/RYOHXAkwXDbB4jaeKq6PsJLSPkzJ2KzQ061gtBnk0vNHfA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12383,9 +12383,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm-gnueabihf": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.40.tgz",
|
||||
"integrity": "sha512-SlRZsCjOCPR2LvFs0Ri/Xrx/5o5TCt8vl4gW6mX1hEZOG0a625RxzRHpHdAQNGykmAN/7IeaFAJG+QnNmxlHcA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.33.tgz",
|
||||
"integrity": "sha512-C64hBnBxq4viOPQ8hlx+2lJ23bzZBGnjw7ryALmS+0Q3zHmwO8lw1/DArLENw4Q18/0w5wdEO1k3m1wWNtKGqQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@@ -12399,9 +12399,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-gnu": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-Q8byxJt2fh8CR3EUX6snBpy47AoBVm+In/+Z3rjDHMjC38ZvR9/gtUUNCT0tfrn4EdVsO8/QPi59nxrxvqxvBQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-TRJfnJbX3jqpxRDRoieMzRiCBS5jOmXNb3iQXmcgjFEHKLnAgK1RZRU8Cq1MsPqO4jAJp/ld1G4O3fXuxv85uw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12415,9 +12415,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-arm64-musl": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.40.tgz",
|
||||
"integrity": "sha512-4z0MgHU+7M0pZDqBN1El7mFXDI1SBwinfcUkAyA4v8QrhOIUOZltySt2aStQLZGrdXVXM4Y4ylfiTC04ED+MoQ==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-il7tYM+CpUNzieQbwAjFT1P8zqAhmGWNAGhQZBnxurXZ0aNn+5nqYFTEUKNZl7QibtT0uQXzTZrNGHCIj6Y1Og==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12431,9 +12431,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-ppc64-gnu": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-fLI4iUgeSZu0eRWUXwe6YzPFx9gHbFiPkl8Rp3mJfP8OpNR3nTQCGPvHdDh9xniW7mVvgMY4ni7A4VzqI1KrpA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-ZtNBwN0Z7CFj9Il0FcPaKdjgP7URyKu/3RfH46vq+0paOBqLj4NYldD6Qo//Duif/7IOtAraUfDOmp0PLAufog==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@@ -12447,9 +12447,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-s390x-gnu": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-YqeKMAb7d4nQSGMJQ454IlaCENpzcDqhvBE9+CPfdnYpnUXxd+BSrB6Xk0YjW8UyoEhUj4p6quATCxbsp6J3jg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-De1IyajoOmhOYYjw/lx66bKlyDpHZTueqwpDrWgf5O7T6d1ODeJJO9/OqMBmrBQc5C+dNnlmIufHsp4QVCWufA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@@ -12463,9 +12463,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-gnu": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.40.tgz",
|
||||
"integrity": "sha512-7HOuS1iGcme/j/TuL1TfmmLGiMQrjv/GmjyZeydl00FKPtpGXEldwqfI56xgd1YzrzoB2svWjxbGGyQ0TEASxg==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.33.tgz",
|
||||
"integrity": "sha512-mGTH0YxmUN+x6vRN/I6NOk5X0ogNktkwPnJ94IMvR7QjhRDwL0O8RXEDhyUM0YtwWrryBOqaJQBX4zruxEPRGw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12479,9 +12479,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-linux-x64-musl": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.40.tgz",
|
||||
"integrity": "sha512-h4kZYHc7dpc9P9u4brRJaS8Pl7tPVHAeiLSzw7T5RfIJgAoSdaCMKzI/2Uay9gFhaw8uyCDl0L5q37r0EpAfIA==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.33.tgz",
|
||||
"integrity": "sha512-hj628ZkSEJf6zMf5VMbYrG2O6QqyTIp2qwY6VlCjvIa9lAEZ5c2lfPblCLVGYubTeLJDxadLB/CxqQYOQABeEQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12495,9 +12495,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-arm64-msvc": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-+mQgKZXSj6mV38Zh05QaxSjUDmGP/R2JWlXZTDLSPkDzHU6p3GxN9eeSf5dfyDVU86946fmCvSzyl/ucImx8+A==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-GV2oohtN2/5+KSccl86VULu3aT+LrISC8uzgSq0FRnikpD+Zwc+sBlXmoKQ+Db6jI57ITUOIB8jRkdGMABC29g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@@ -12511,9 +12511,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-ia32-msvc": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-yvwdPLGd25mcj/mNatjNQ0lZujtQD6psH3v9PNmMb+fSzjbNG8KIDxjFWrcV+fsFVLOkyOmdJsFmX7NAFjVyPw==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-gtyvzSNR8DHKfFEA2uqb8Ld1myqi6uEg2jyeUq3ikn5ytYs7H8RpZYC8mdy4NXr8hfcdJfCLXPlYaqqfBXpoEQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
@@ -12527,9 +12527,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@swc/core-win32-x64-msvc": {
|
||||
"version": "1.15.40",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.40.tgz",
|
||||
"integrity": "sha512-OXtKsLU1bVtInzzDEAY2sYiF/rl4tvAnLLLpuMp3HzAOQZ5A+i69AKDhA1YLQTaMAqO3vzyYNVAYVRMPtSYD4w==",
|
||||
"version": "1.15.33",
|
||||
"resolved": "https://registry.npmjs.org/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.33.tgz",
|
||||
"integrity": "sha512-d6fRqQSkJI+kmMEBWaDQ7TMl8+YjLYbwRUPZQ9DY0ORBJeTzOrG0twvfvlZ2xgw6jA0ScQKgfBm4vHLSLl5Hqg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@@ -12775,9 +12775,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tufjs/models/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17209,9 +17209,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
||||
"version": "2.10.31",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.31.tgz",
|
||||
"integrity": "sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -17402,9 +17402,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.5",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
|
||||
"integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -17416,7 +17416,7 @@
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
@@ -17793,9 +17793,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cacache/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22661,9 +22661,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/eslint-plugin-react-you-might-not-need-an-effect": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.2.tgz",
|
||||
"integrity": "sha512-cqm9DXcsISYZHnFXT5zPH+ITsMx/bYscmq6zIsbtYvei1vj4dZ+BxN9LgoMmjEdm7sTaWxKVRY5IqQRQvau/GQ==",
|
||||
"version": "0.10.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-plugin-react-you-might-not-need-an-effect/-/eslint-plugin-react-you-might-not-need-an-effect-0.10.1.tgz",
|
||||
"integrity": "sha512-IK0s/+ShN0bkur5moKCu/lfx2D/9uIeozje8Wv2/XnYdmswa17pDg02aUuytEPb8Gf0eueiQFf/QsvOHHcvujg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -22848,9 +22848,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint-plugin-testing-library/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -23258,15 +23258,15 @@
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.2",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz",
|
||||
"integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.5",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
@@ -23285,7 +23285,7 @@
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.15.1",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
@@ -26595,9 +26595,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ignore-walk/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -26856,9 +26856,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz",
|
||||
"integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==",
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -35946,9 +35946,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/multimatch/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -36615,9 +36615,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nx/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -38409,9 +38409,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"version": "8.5.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.1.tgz",
|
||||
"integrity": "sha512-6oz2beyjc5VMn/KV1pPw8fliQkhBXrVn1Z3TVyqZxU8kZpzEKhBdmCFqI6ZbmGtamQvQGuU1sgPTk8ZrXDD7jQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -38429,7 +38429,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.8",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -39002,9 +39002,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/postcss/node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -39423,9 +39423,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz",
|
||||
"integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
|
||||
"version": "6.14.2",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
|
||||
"integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
@@ -44969,9 +44969,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz",
|
||||
"integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==",
|
||||
"version": "0.2.5",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz",
|
||||
"integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -47104,9 +47104,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/vm2": {
|
||||
"version": "3.11.5",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.5.tgz",
|
||||
"integrity": "sha512-RSrkBiwrj6FRU+QdqNs6KG0XdlvJCjpQ4GXiqmMbrhmwfu5k/XIMpAer0L8f6iuf0uJ3a4T1xJN126Q8yf0VIA==",
|
||||
"version": "3.11.3",
|
||||
"resolved": "https://registry.npmjs.org/vm2/-/vm2-3.11.3.tgz",
|
||||
"integrity": "sha512-DO1TTKuOc+veL11VNOvJwRab80mghFKE40Av3bl6pdXs11bdiDMuR73owy+dS2EsTZEvRUeBkkBuDVRjV/RgEw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
@@ -47889,9 +47889,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webpack-sources": {
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.5.0.tgz",
|
||||
"integrity": "sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==",
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.1.tgz",
|
||||
"integrity": "sha512-eACpxRN02yaawnt+uUNIF7Qje6A9zArxBbcAJjK1PK3S9Ycg5jIuJ8pW4q8EMnwNZCEGltcjkRx1QzOxOkKD8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -48303,9 +48303,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
|
||||
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -48924,9 +48924,9 @@
|
||||
}
|
||||
},
|
||||
"packages/generator-superset/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"balanced-match": "^4.0.2"
|
||||
@@ -50174,7 +50174,7 @@
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/d3-scale": "^4.0.9",
|
||||
"d3-cloud": "^1.2.9",
|
||||
"d3-cloud": "^1.2.8",
|
||||
"d3-scale": "^4.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -277,7 +277,7 @@
|
||||
"@storybook/test": "^8.6.18",
|
||||
"@storybook/test-runner": "^0.17.0",
|
||||
"@svgr/webpack": "^8.1.0",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@swc/core": "^1.15.33",
|
||||
"@swc/plugin-emotion": "^14.10.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
@@ -312,7 +312,7 @@
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.32",
|
||||
"baseline-browser-mapping": "^2.10.31",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^9.2.1",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -332,7 +332,7 @@
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.2",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.1",
|
||||
"eslint-plugin-storybook": "^0.8.0",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -379,7 +379,7 @@
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-sources": "^3.4.1",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"dependencies": {
|
||||
"chalk": "^5.6.2",
|
||||
"lodash-es": "^4.18.1",
|
||||
"yeoman-generator": "^8.2.2",
|
||||
"yeoman-generator": "^8.1.2",
|
||||
"yosay": "^3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-markdown": "^8.0.7",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
|
||||
@@ -152,33 +152,3 @@ export async function selectOption(option: string, selectName?: string) {
|
||||
);
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select an option from a compact pill filter (new UI that replaced comboboxes).
|
||||
* Clicks the pill button matching the label, then clicks the option in the panel.
|
||||
*/
|
||||
export async function selectPillOption(option: string, pillLabel?: string) {
|
||||
let pill: HTMLElement;
|
||||
if (pillLabel) {
|
||||
// Find the pill whose text content includes the label
|
||||
pill = await waitFor(() => {
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const match = pills.find(p => p.textContent?.includes(pillLabel));
|
||||
if (!match)
|
||||
throw new Error(`Could not find pill with label "${pillLabel}"`);
|
||||
return match;
|
||||
});
|
||||
} else {
|
||||
pill = await screen.findByTestId('compact-filter-pill');
|
||||
}
|
||||
await userEvent.click(pill);
|
||||
// Wait for the option list to appear and click the item
|
||||
const item = await waitFor(() => {
|
||||
const listbox = document.querySelector('[role="listbox"]');
|
||||
if (!listbox) throw new Error('No listbox found');
|
||||
const opt = within(listbox as HTMLElement).getByText(option);
|
||||
if (!opt) throw new Error(`Option "${option}" not found`);
|
||||
return opt;
|
||||
});
|
||||
await userEvent.click(item);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ClientErrorObject, SupersetError } from '@superset-ui/core';
|
||||
import { FC } from 'react';
|
||||
import { useChartOwnerNames } from 'src/hooks/apiResources';
|
||||
@@ -33,7 +32,7 @@ export type Props = {
|
||||
stackTrace?: string;
|
||||
} & Omit<ClientErrorObject, 'error'>;
|
||||
|
||||
const DEFAULT_CHART_ERROR = t('Data error');
|
||||
const DEFAULT_CHART_ERROR = 'Data error';
|
||||
|
||||
export const ChartErrorMessage: FC<Props> = ({ chartId, error, ...props }) => {
|
||||
// fetches the chart owners and adds them to the extra data of the error message
|
||||
|
||||
@@ -817,11 +817,8 @@ export function exploreJSON(
|
||||
),
|
||||
);
|
||||
(queriesResponse as QueryData[]).forEach(response => {
|
||||
const { warning } = response as QueryData & {
|
||||
warning?: string | null;
|
||||
};
|
||||
if (warning) {
|
||||
dispatch(addWarningToast(warning, { noDuplicate: true }));
|
||||
if (response.warning) {
|
||||
dispatch(addWarningToast(response.warning, { noDuplicate: true }));
|
||||
}
|
||||
});
|
||||
return dispatch(
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { CardSortSelect } from './CardSortSelect';
|
||||
|
||||
const options = [
|
||||
{ desc: false, id: 'title', label: 'Alphabetical', value: 'alphabetical' },
|
||||
{
|
||||
desc: true,
|
||||
id: 'changed_on',
|
||||
label: 'Recently modified',
|
||||
value: 'recently_modified',
|
||||
},
|
||||
{
|
||||
desc: false,
|
||||
id: 'changed_on',
|
||||
label: 'Least recently modified',
|
||||
value: 'least_recently_modified',
|
||||
},
|
||||
];
|
||||
|
||||
test('pill always shows "Sort" label with no value suffix and no clear button', () => {
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={jest.fn()}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/sort.*alphabetical/i)).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('no clear button even when a non-default sort is active', () => {
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={jest.fn()}
|
||||
initialSort={[{ id: 'changed_on', desc: true }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clicking a sort option from the panel calls onChange with the correct id and desc', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('compact-filter-pill'));
|
||||
expect(screen.getByText('Recently modified')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('Recently modified'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: true }]);
|
||||
// Pill label stays "Sort" — value is in tooltip, not the label
|
||||
expect(screen.getByText('Sort')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('selecting a different option from the panel calls onChange with correct args', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CardSortSelect
|
||||
options={options}
|
||||
onChange={onChange}
|
||||
initialSort={[{ id: 'title', desc: false }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByTestId('compact-filter-pill'));
|
||||
await userEvent.click(screen.getByText('Least recently modified'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([{ id: 'changed_on', desc: false }]);
|
||||
});
|
||||
@@ -16,13 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useRef, useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import type { SelectOption } from './types';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { FormLabel, Select } from '@superset-ui/core/components';
|
||||
import { SELECT_WIDTH } from './utils';
|
||||
import { CardSortSelectOption, SortColumn } from './types';
|
||||
import CompactFilterTrigger from './Filters/CompactFilterTrigger';
|
||||
import CompactSelectPanel from './Filters/CompactSelectPanel';
|
||||
import type { FilterHandler } from './Filters/types';
|
||||
|
||||
const SortContainer = styled.div`
|
||||
display: inline-flex;
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
width: ${SELECT_WIDTH}px;
|
||||
`;
|
||||
|
||||
interface CardViewSelectSortProps {
|
||||
onChange: (value: SortColumn[]) => void;
|
||||
@@ -35,8 +42,6 @@ export const CardSortSelect = ({
|
||||
onChange,
|
||||
options,
|
||||
}: CardViewSelectSortProps) => {
|
||||
const panelRef = useRef<FilterHandler>(null);
|
||||
|
||||
const defaultSort =
|
||||
(initialSort &&
|
||||
options.find(
|
||||
@@ -45,41 +50,44 @@ export const CardSortSelect = ({
|
||||
)) ||
|
||||
options[0];
|
||||
|
||||
const [currentValue, setCurrentValue] = useState<SelectOption>({
|
||||
const [value, setValue] = useState({
|
||||
label: defaultSort.label,
|
||||
value: defaultSort.value,
|
||||
});
|
||||
|
||||
const selectOptions = options.map(o => ({ label: o.label, value: o.value }));
|
||||
const formattedOptions = useMemo(
|
||||
() => options.map(option => ({ label: option.label, value: option.value })),
|
||||
[options],
|
||||
);
|
||||
|
||||
const handleSelect = (option: SelectOption | undefined) => {
|
||||
if (!option) return;
|
||||
const original = options.find(o => o.value === option.value);
|
||||
if (original) {
|
||||
setCurrentValue({ label: original.label, value: original.value });
|
||||
onChange([{ id: original.id, desc: original.desc }]);
|
||||
const handleOnChange = (selected: { label: string; value: string }) => {
|
||||
setValue(selected);
|
||||
const originalOption = options.find(
|
||||
({ value }) => value === selected.value,
|
||||
);
|
||||
if (originalOption) {
|
||||
const sortBy = [
|
||||
{
|
||||
id: originalOption.id,
|
||||
desc: originalOption.desc,
|
||||
},
|
||||
];
|
||||
onChange(sortBy);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<span data-test="card-sort-select">
|
||||
<CompactFilterTrigger
|
||||
label={t('Sort')}
|
||||
hasValue={false}
|
||||
onClear={() => {}}
|
||||
tooltipTitle={String(currentValue.label)}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
ref={panelRef}
|
||||
selects={selectOptions}
|
||||
value={currentValue}
|
||||
onSelect={handleSelect}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
<SortContainer>
|
||||
<Select
|
||||
ariaLabel={t('Sort')}
|
||||
header={<FormLabel>{t('Sort')}</FormLabel>}
|
||||
labelInValue
|
||||
onChange={handleOnChange}
|
||||
options={formattedOptions}
|
||||
showSearch
|
||||
value={value}
|
||||
data-test="card-sort-select"
|
||||
/>
|
||||
</SortContainer>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
|
||||
// Base props without children — pass children as JSX to avoid no-children-prop lint rule.
|
||||
const baseProps = {
|
||||
label: 'Owner',
|
||||
hasValue: false,
|
||||
onClear: jest.fn(),
|
||||
};
|
||||
|
||||
const defaultChildren = jest.fn(() => (
|
||||
<div data-testid="filter-content">Filter content</div>
|
||||
));
|
||||
|
||||
function renderTrigger(
|
||||
props: Partial<
|
||||
typeof baseProps & {
|
||||
hasValue: boolean;
|
||||
tooltipTitle?: string;
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
> = {},
|
||||
children = defaultChildren,
|
||||
) {
|
||||
return render(
|
||||
<CompactFilterTrigger {...baseProps} {...props}>
|
||||
{children}
|
||||
</CompactFilterTrigger>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders the label', () => {
|
||||
renderTrigger();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders as inactive pill with down chevron when hasValue is false', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
// No clear button when inactive
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders active state with clear icon when hasValue is true', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('clear icon has descriptive aria-label matching the filter name', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(clearIcon).toHaveAttribute('aria-label', 'Clear Owner filter');
|
||||
});
|
||||
|
||||
test('clear icon is rendered inside the pill button', () => {
|
||||
renderTrigger({ hasValue: true });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(pill).toContainElement(clearIcon);
|
||||
});
|
||||
|
||||
test('toggles aria-expanded when pill is clicked', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
});
|
||||
|
||||
test('calls onClear when clear icon is clicked', async () => {
|
||||
const onClear = jest.fn();
|
||||
renderTrigger({ hasValue: true, onClear } as any);
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
await userEvent.click(clearIcon);
|
||||
expect(onClear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('does not render tooltip wrapper when tooltipTitle is absent', () => {
|
||||
const { container } = renderTrigger();
|
||||
expect(container.querySelector('.ant-tooltip')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows active state indicators when hasValue and tooltipTitle are set', () => {
|
||||
renderTrigger({ hasValue: true, tooltipTitle: 'Some Owner' });
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('compact-filter-pill')).toHaveAttribute(
|
||||
'aria-expanded',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('calls children render prop with isOpen and onClose', async () => {
|
||||
const children = jest.fn(() => <div data-testid="panel-content">panel</div>);
|
||||
renderTrigger({}, children);
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(children).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isOpen: true, onClose: expect.any(Function) }),
|
||||
);
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to listbox by default', () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'listbox');
|
||||
});
|
||||
|
||||
test('sets aria-haspopup to dialog when popupType is dialog', () => {
|
||||
renderTrigger({ popupType: 'dialog' });
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
});
|
||||
|
||||
test('closing dropdown resets aria-expanded to false', async () => {
|
||||
renderTrigger();
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'true');
|
||||
await userEvent.click(pill);
|
||||
expect(pill).toHaveAttribute('aria-expanded', 'false');
|
||||
});
|
||||
@@ -1,198 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
type MouseEvent,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
export type FilterPanelRenderProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
interface CompactFilterTriggerProps {
|
||||
label: ReactNode;
|
||||
hasValue: boolean;
|
||||
onClear: () => void;
|
||||
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
|
||||
children: (props: FilterPanelRenderProps) => ReactNode;
|
||||
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
|
||||
tooltipTitle?: string;
|
||||
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
|
||||
* 'dialog' for form panels (date range, numerical range). */
|
||||
popupType?: 'listbox' | 'dialog';
|
||||
}
|
||||
|
||||
const FilterPill = styled.button<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
height: ${theme.controlHeight}px;
|
||||
padding: 0 ${theme.sizeUnit * 3}px;
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
|
||||
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
|
||||
color: ${$active ? theme.colorPrimary : theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
font-weight: ${$active ? 600 : 400};
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: 1;
|
||||
transition:
|
||||
border-color 0.2s,
|
||||
background 0.2s,
|
||||
color 0.2s;
|
||||
|
||||
/* AntD anticon spans carry vertical-align: -0.125em from global styles.
|
||||
align-self centers the span within the pill; the inner flex+align-items
|
||||
centers the svg within the span. */
|
||||
.anticon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border-color: ${theme.colorPrimary};
|
||||
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: 2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ActiveDot = styled.span`
|
||||
${({ theme }) => css`
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: ${theme.colorPrimary};
|
||||
flex-shrink: 0;
|
||||
`}
|
||||
`;
|
||||
|
||||
export default function CompactFilterTrigger({
|
||||
label,
|
||||
hasValue,
|
||||
onClear,
|
||||
children,
|
||||
tooltipTitle,
|
||||
popupType = 'listbox',
|
||||
}: CompactFilterTriggerProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [tooltipOpen, setTooltipOpen] = useState(false);
|
||||
const theme = useTheme();
|
||||
// Tracks whether tooltip should be suppressed after dropdown close.
|
||||
// Brave (and some other browsers) fire a synthetic mouseover on newly-exposed
|
||||
// elements when a popup disappears, triggering Tooltip onOpenChange(true)
|
||||
// without real user intent. We suppress until the cursor actually leaves the
|
||||
// pill (onMouseLeave), which is the first reliable "hover reset" signal.
|
||||
const tooltipSuppressedRef = useRef(false);
|
||||
|
||||
// Close dropdown on window resize — AntD Dropdown doesn't reposition
|
||||
// itself on resize so the panel ends up detached from the pill.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleResize = () => setOpen(false);
|
||||
window.addEventListener('resize', handleResize);
|
||||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [open]);
|
||||
|
||||
const handleClear = (e: MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onClear();
|
||||
setOpen(false);
|
||||
tooltipSuppressedRef.current = true;
|
||||
setTooltipOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
onOpenChange={visible => {
|
||||
setOpen(visible);
|
||||
if (!visible) {
|
||||
tooltipSuppressedRef.current = true;
|
||||
setTooltipOpen(false);
|
||||
}
|
||||
}}
|
||||
trigger={['click']}
|
||||
popupRender={() =>
|
||||
children({ isOpen: open, onClose: () => setOpen(false) })
|
||||
}
|
||||
placement="bottomLeft"
|
||||
destroyPopupOnHide
|
||||
>
|
||||
<Tooltip
|
||||
title={tooltipTitle}
|
||||
open={!!tooltipTitle && !open && tooltipOpen}
|
||||
onOpenChange={visible => {
|
||||
if (visible && tooltipSuppressedRef.current) return;
|
||||
setTooltipOpen(visible && !!tooltipTitle && !open);
|
||||
}}
|
||||
mouseEnterDelay={0.5}
|
||||
mouseLeaveDelay={0}
|
||||
>
|
||||
<FilterPill
|
||||
$active={hasValue}
|
||||
type="button"
|
||||
data-test="compact-filter-pill"
|
||||
aria-haspopup={popupType}
|
||||
aria-expanded={open}
|
||||
aria-label={typeof label === 'string' ? label : undefined}
|
||||
onMouseLeave={() => {
|
||||
tooltipSuppressedRef.current = false;
|
||||
}}
|
||||
>
|
||||
{hasValue && <ActiveDot />}
|
||||
<span>{label}</span>
|
||||
{hasValue ? (
|
||||
<Icons.CloseOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorPrimary}
|
||||
onClick={handleClear}
|
||||
data-test="compact-filter-clear"
|
||||
aria-label={
|
||||
typeof label === 'string'
|
||||
? t('Clear %s filter', label)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Icons.DownOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorTextSecondary}
|
||||
/>
|
||||
)}
|
||||
</FilterPill>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -1,339 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, act } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const SMALL_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
];
|
||||
|
||||
const LARGE_SELECTS = [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
{ label: 'Charlie', value: 3 },
|
||||
{ label: 'David', value: 4 },
|
||||
{ label: 'Eve', value: 5 },
|
||||
{ label: 'Frank', value: 6 },
|
||||
{ label: 'Grace', value: 7 },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders options from selects prop', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.getByText('Bob')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charlie')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides search input when selects.length is 6 or fewer', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when selects.length exceeds 6', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows search input when fetchSelects is provided', () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('filters static options by search term', async () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={LARGE_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
|
||||
expect(screen.getByText('Alice')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onSelect with normalized option when an option is clicked', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('shows checkmark icon on selected option', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen
|
||||
.getByText('Alice')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
|
||||
});
|
||||
|
||||
test('unselected options have aria-selected false', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const bobOption = screen
|
||||
.getByText('Bob')
|
||||
.closest('[role="option"]') as HTMLElement;
|
||||
expect(bobOption).toHaveAttribute('aria-selected', 'false');
|
||||
});
|
||||
|
||||
test('calls onClose after a selection is made', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
await userEvent.click(screen.getByText('Alice'));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
|
||||
const onSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(undefined, true);
|
||||
// Component is fully controlled — visual deselection follows when the
|
||||
// parent passes value={undefined} after receiving the onSelect callback.
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
ref={ref}
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
|
||||
test('shows Loading text when loading prop is true', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
loading
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows No results when displayOptions is empty', () => {
|
||||
render(
|
||||
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
|
||||
);
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders options list with listbox role and accessible label', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const listbox = screen.getByRole('listbox');
|
||||
expect(listbox).toBeInTheDocument();
|
||||
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
|
||||
});
|
||||
|
||||
test('option items have option role', () => {
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
const options = screen.getAllByRole('option');
|
||||
expect(options).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('fetches and displays remote options via fetchSelects on mount', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [{ label: 'Remote User', value: 99 }],
|
||||
totalCount: 1,
|
||||
});
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Remote User')).toBeInTheDocument();
|
||||
});
|
||||
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects returns empty data', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('shows No results when fetchSelects rejects', async () => {
|
||||
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
fetchSelects={fetchSelects}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No results')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('selects option via keyboard Enter key', async () => {
|
||||
const onSelect = jest.fn();
|
||||
render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={onSelect}
|
||||
/>,
|
||||
);
|
||||
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
|
||||
await userEvent.type(aliceOption, '{Enter}');
|
||||
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
|
||||
});
|
||||
|
||||
test('syncs selected state when external value prop changes', () => {
|
||||
const { rerender } = render(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={{ label: 'Alice', value: 1 }}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true',
|
||||
);
|
||||
|
||||
rerender(
|
||||
<CompactSelectPanel
|
||||
selects={SMALL_SELECTS}
|
||||
value={undefined}
|
||||
onSelect={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'false',
|
||||
);
|
||||
});
|
||||
@@ -1,318 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
type CSSProperties,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme, styled, css } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Icons,
|
||||
Input,
|
||||
Constants,
|
||||
type InputRef,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { SelectOption, ListViewFilter as Filter } from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
// Show search box when there are more than this many static options.
|
||||
const SEARCH_THRESHOLD = 6;
|
||||
|
||||
// Page size for async select fetches — large enough to avoid most pagination
|
||||
// issues while still being a bounded request. Full infinite-load pagination
|
||||
// is a future improvement.
|
||||
const ASYNC_PAGE_SIZE = 200;
|
||||
|
||||
interface CompactSelectPanelProps {
|
||||
selects?: Filter['selects'];
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
value?: SelectOption;
|
||||
onSelect: (option: SelectOption | undefined, isClear?: boolean) => void;
|
||||
onClose?: () => void;
|
||||
isOpen?: boolean;
|
||||
/** Forwarded from the filter config's popupStyle for per-filter width overrides */
|
||||
panelStyle?: CSSProperties;
|
||||
/** External loading state from filter config */
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const PanelContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
min-width: 220px;
|
||||
max-width: 320px;
|
||||
max-height: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
padding: 0 0 ${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const SearchRow = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 2}px
|
||||
${theme.paddingXXS}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionList = styled.ul`
|
||||
${({ theme }) => css`
|
||||
margin: 0;
|
||||
padding: ${theme.paddingXXS}px 0;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
list-style: none;
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionItem = styled.li<{ $active: boolean }>`
|
||||
${({ theme, $active }) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: ${(theme.controlHeight - theme.fontSize * theme.lineHeight) / 2}px
|
||||
${theme.controlPaddingHorizontal}px;
|
||||
line-height: ${theme.lineHeight};
|
||||
cursor: pointer;
|
||||
font-size: ${theme.fontSize}px;
|
||||
color: ${theme.colorText};
|
||||
border-radius: ${theme.borderRadiusSM}px;
|
||||
background: ${$active ? theme.colorPrimaryBg : 'transparent'};
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover {
|
||||
background: ${$active
|
||||
? theme.colorPrimaryBgHover
|
||||
: theme.colorFillTertiary};
|
||||
outline: 2px solid ${theme.colorPrimary};
|
||||
outline-offset: -2px;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const OptionLabel = styled.span`
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 240px;
|
||||
`;
|
||||
|
||||
const StatusText = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px ${theme.sizeUnit * 3}px;
|
||||
text-align: center;
|
||||
color: ${theme.colorTextDisabled};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
`;
|
||||
|
||||
function CompactSelectPanel(
|
||||
{
|
||||
selects = [],
|
||||
fetchSelects,
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
isOpen,
|
||||
loading: externalLoading,
|
||||
panelStyle,
|
||||
}: CompactSelectPanelProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const theme = useTheme();
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const [search, setSearch] = useState('');
|
||||
const [debouncedSearch, setDebouncedSearch] = useState('');
|
||||
const [remoteOptions, setRemoteOptions] = useState<SelectOption[]>([]);
|
||||
const [internalLoading, setInternalLoading] = useState(false);
|
||||
|
||||
const isLoading = externalLoading || internalLoading;
|
||||
|
||||
const debouncedSetSearch = useMemo(
|
||||
() => debounce(setDebouncedSearch, Constants.FAST_DEBOUNCE),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
debouncedSetSearch.cancel();
|
||||
},
|
||||
[debouncedSetSearch],
|
||||
);
|
||||
|
||||
// Focus search input when dropdown opens; reset search when it closes
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (isOpen) {
|
||||
timeoutId = setTimeout(() => {
|
||||
inputRef.current?.input?.focus({ preventScroll: true });
|
||||
}, 100);
|
||||
} else {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
}
|
||||
return () => {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch remote options when debounced search changes
|
||||
useEffect(() => {
|
||||
if (!fetchSelects) return;
|
||||
let cancelled = false;
|
||||
setInternalLoading(true);
|
||||
fetchSelects(debouncedSearch, 0, ASYNC_PAGE_SIZE)
|
||||
.then(result => {
|
||||
if (!cancelled) setRemoteOptions(result?.data ?? []);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setRemoteOptions([]);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setInternalLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [debouncedSearch, fetchSelects]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setSearch('');
|
||||
setDebouncedSearch('');
|
||||
onSelect(undefined, true);
|
||||
},
|
||||
}));
|
||||
|
||||
const displayOptions = (
|
||||
fetchSelects
|
||||
? remoteOptions
|
||||
: selects.filter(o => {
|
||||
const label = typeof o.label === 'string' ? o.label : String(o.value);
|
||||
return label.toLowerCase().includes(search.toLowerCase());
|
||||
})
|
||||
).filter(o => o != null);
|
||||
|
||||
const showSearch = !!fetchSelects || selects.length > SEARCH_THRESHOLD;
|
||||
|
||||
const handleSelect = (opt: SelectOption, displayText?: string) => {
|
||||
const isDeselect = value?.value === opt.value;
|
||||
// Normalize to a plain string label for URL serialization:
|
||||
// 1. String labels pass through unchanged.
|
||||
// 2. ReactNode labels with a `title` field use that (set by callers for
|
||||
// options like owner-select where label contains name + email JSX).
|
||||
// 3. Fall back to DOM text content, then stringified value.
|
||||
const label =
|
||||
typeof opt.label === 'string'
|
||||
? opt.label
|
||||
: (opt.title ?? displayText ?? String(opt.value ?? ''));
|
||||
const next = isDeselect ? undefined : { label, value: opt.value };
|
||||
onSelect(next, isDeselect);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<PanelContainer style={panelStyle}>
|
||||
{showSearch && (
|
||||
<SearchRow>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
prefix={
|
||||
<Icons.SearchOutlined iconSize="l" iconColor={theme.colorIcon} />
|
||||
}
|
||||
placeholder={t('Search')}
|
||||
value={search}
|
||||
onChange={e => {
|
||||
setSearch(e.target.value);
|
||||
debouncedSetSearch(e.target.value);
|
||||
}}
|
||||
allowClear
|
||||
css={css`
|
||||
width: 100%;
|
||||
box-shadow: none;
|
||||
`}
|
||||
/>
|
||||
</SearchRow>
|
||||
)}
|
||||
<OptionList role="listbox" aria-label={t('Filter options')}>
|
||||
{isLoading ? (
|
||||
<StatusText>{t('Loading...')}</StatusText>
|
||||
) : displayOptions.length === 0 ? (
|
||||
<StatusText>{t('No results')}</StatusText>
|
||||
) : (
|
||||
displayOptions.map((opt, i) => {
|
||||
const isActive = value?.value === opt.value;
|
||||
const getDisplayText = (el: HTMLElement) =>
|
||||
el.textContent?.trim() || undefined;
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === displayOptions.length - 1;
|
||||
return (
|
||||
<OptionItem
|
||||
key={opt.value}
|
||||
$active={isActive}
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
tabIndex={0}
|
||||
onClick={e =>
|
||||
handleSelect(opt, getDisplayText(e.currentTarget))
|
||||
}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
handleSelect(opt, getDisplayText(e.currentTarget));
|
||||
} else if (e.key === 'ArrowDown' && !isLast) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget.nextElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
} else if (e.key === 'ArrowUp' && !isFirst) {
|
||||
e.preventDefault();
|
||||
(
|
||||
e.currentTarget
|
||||
.previousElementSibling as HTMLElement | null
|
||||
)?.focus();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<OptionLabel>{opt.label}</OptionLabel>
|
||||
{isActive && (
|
||||
<Icons.CheckOutlined
|
||||
iconSize="s"
|
||||
iconColor={theme.colorPrimary}
|
||||
/>
|
||||
)}
|
||||
</OptionItem>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</OptionList>
|
||||
</PanelContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(CompactSelectPanel);
|
||||
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { useLocale } from 'src/hooks/useLocale';
|
||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
||||
import {
|
||||
AntdThemeProvider,
|
||||
Loading,
|
||||
FormLabel,
|
||||
RangePicker,
|
||||
} from '@superset-ui/core/components';
|
||||
import type { BaseFilter, FilterHandler } from './types';
|
||||
import { FilterContainer } from './Base';
|
||||
import { RANGE_WIDTH } from '../utils';
|
||||
|
||||
interface DateRangeFilterProps extends BaseFilter {
|
||||
onSubmit: (val: number[] | string[]) => void;
|
||||
name: string;
|
||||
dateFilterValueType?: 'unix' | 'iso';
|
||||
}
|
||||
|
||||
type ValueState = [number, number] | [string, string] | null;
|
||||
|
||||
function DateRangeFilter(
|
||||
{
|
||||
Header,
|
||||
initialValue,
|
||||
onSubmit,
|
||||
dateFilterValueType = 'unix',
|
||||
}: DateRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [value, setValue] = useState<ValueState | null>(initialValue ?? null);
|
||||
const dayjsValue = useMemo((): [Dayjs, Dayjs] | null => {
|
||||
if (!value || (Array.isArray(value) && !value.length)) return null;
|
||||
return [extendedDayjs(value[0]), extendedDayjs(value[1])];
|
||||
}, [value]);
|
||||
|
||||
const locale = useLocale();
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
setValue(null);
|
||||
onSubmit([]);
|
||||
},
|
||||
}));
|
||||
|
||||
if (locale === null) {
|
||||
return <Loading position="inline-centered" />;
|
||||
}
|
||||
return (
|
||||
<AntdThemeProvider locale={locale}>
|
||||
<FilterContainer
|
||||
data-test="date-range-filter-container"
|
||||
vertical
|
||||
justify="center"
|
||||
align="start"
|
||||
width={RANGE_WIDTH}
|
||||
>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
<RangePicker
|
||||
placeholder={[t('Start date'), t('End date')]}
|
||||
showTime
|
||||
value={dayjsValue}
|
||||
onCalendarChange={(dayjsRange: [Dayjs, Dayjs]) => {
|
||||
if (!dayjsRange?.[0]?.valueOf() || !dayjsRange?.[1]?.valueOf()) {
|
||||
setValue(null);
|
||||
onSubmit([]);
|
||||
return;
|
||||
}
|
||||
const changeValue =
|
||||
dateFilterValueType === 'iso'
|
||||
? [dayjsRange[0].toISOString(), dayjsRange[1].toISOString()]
|
||||
: [
|
||||
dayjsRange[0]?.valueOf() ?? 0,
|
||||
dayjsRange[1]?.valueOf() ?? 0,
|
||||
];
|
||||
setValue(changeValue as ValueState);
|
||||
onSubmit(changeValue);
|
||||
}}
|
||||
/>
|
||||
</FilterContainer>
|
||||
</AntdThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(DateRangeFilter);
|
||||
@@ -1,80 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders children inside the wrapper', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div data-test="inner-content">Inner content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByTestId('inner-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the Apply button', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onClose when Apply button is clicked', async () => {
|
||||
const onClose = jest.fn();
|
||||
render(
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders without onClose and clicking Apply does not throw', async () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<div>content</div>
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
// No onClose prop — click should not throw
|
||||
await userEvent.click(screen.getByRole('button', { name: /apply/i }));
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('visually hides label elements so pills remain accessible', () => {
|
||||
render(
|
||||
<FilterPopoverContent>
|
||||
<label htmlFor="input">Date range</label>
|
||||
<input id="input" />
|
||||
</FilterPopoverContent>,
|
||||
);
|
||||
const label = screen.getByText('Date range');
|
||||
// The label must be in the DOM for screen readers but visually hidden via CSS
|
||||
expect(label).toBeInTheDocument();
|
||||
const computedStyle = window.getComputedStyle(label);
|
||||
// clip / overflow hidden pattern applied; position absolute is the key indicator
|
||||
expect(computedStyle.position).toBe('absolute');
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ReactNode } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { styled, css } from '@apache-superset/core/theme';
|
||||
import { Button } from '@superset-ui/core/components';
|
||||
|
||||
interface FilterPopoverContentProps {
|
||||
children: ReactNode;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const Wrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 2}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
|
||||
/* Visually hide the redundant label — the pill already shows it, but keep it
|
||||
accessible to screen readers so filter inputs have a named context. */
|
||||
label {
|
||||
position: absolute !important;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const Footer = styled.div`
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
export default function FilterPopoverContent({
|
||||
children,
|
||||
onClose,
|
||||
}: FilterPopoverContentProps) {
|
||||
return (
|
||||
<Wrapper>
|
||||
{children}
|
||||
<Footer>
|
||||
<Button size="small" buttonStyle="primary" onClick={onClose}>
|
||||
{t('Apply')}
|
||||
</Button>
|
||||
</Footer>
|
||||
</Wrapper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,267 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
selectOption,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
import SelectFilter from './Select';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
const mockUpdateFilterValue = jest.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mockUpdateFilterValue.mockClear();
|
||||
});
|
||||
|
||||
test('select filter with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Regression for sc-104554: the chart-list Owner filter renders options
|
||||
// with ReactNode labels (name + email). The value passed to
|
||||
// updateFilterValue is serialized into URL / filter state and re-used to
|
||||
// render the filter pill on return. It must carry the plain-text name
|
||||
// (from `title`) and not fall back to the numeric user id.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>John Doe</span>
|
||||
<span>john@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: ReactNodeLabel,
|
||||
value: 42,
|
||||
title: 'John Doe',
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('John Doe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'John Doe',
|
||||
value: 42,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('select filter falls back to stringified value when no string label or title is available', async () => {
|
||||
const fetchSelects = jest.fn().mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
label: <span>123</span>,
|
||||
value: 123,
|
||||
},
|
||||
],
|
||||
totalCount: 1,
|
||||
});
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Something',
|
||||
key: 'something',
|
||||
id: 'something',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('123', 'Something');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: '123',
|
||||
value: 123,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with string label passes label through unchanged', async () => {
|
||||
// Happy-path coverage for the typeof-string branch in onChange, exercised
|
||||
// through the non-async Select wrapper (selects array, no fetchSelects).
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Status',
|
||||
key: 'status',
|
||||
id: 'status',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.Equals,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [
|
||||
{ label: 'Published', value: 7 },
|
||||
{ label: 'Draft', value: 8 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Published',
|
||||
value: 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('plain select with ReactNode label uses option title when serializing selection', async () => {
|
||||
// Parallel coverage to the AsyncSelect ReactNode-with-title test, against
|
||||
// the non-async Select wrapper. Guards against the two wrappers ever
|
||||
// diverging on antd's two-arg onChange shape.
|
||||
const ReactNodeLabel = (
|
||||
<div>
|
||||
<span>Jane Roe</span>
|
||||
<span>jane@example.com</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
selects: [{ label: ReactNodeLabel, value: 99, title: 'Jane Roe' }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await selectOption('Jane Roe', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockUpdateFilterValue).toHaveBeenCalledWith(0, {
|
||||
label: 'Jane Roe',
|
||||
value: 99,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('clearFilter notifies onSelect with undefined and isClear=true', () => {
|
||||
// The isClear flag is what allows the parent (Filters/index) to suppress
|
||||
// onFilterUpdate side-effects when the user clears the filter rather than
|
||||
// picking a new value. Lock that contract in.
|
||||
const mockOnSelect = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<SelectFilter
|
||||
Header="Owner"
|
||||
initialValue={{ label: 'John Doe', value: 42 }}
|
||||
onSelect={mockOnSelect}
|
||||
selects={[{ label: 'John Doe', value: 42, title: 'John Doe' }]}
|
||||
ref={ref}
|
||||
/>,
|
||||
);
|
||||
|
||||
ref.current?.clearFilter();
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(undefined, true);
|
||||
});
|
||||
|
||||
test('rehydrates filter pill from initialValue with plain-string label', async () => {
|
||||
// The user-visible regression: after URL/state rehydration the filter pill
|
||||
// must render the human-readable name, not the numeric user id. The fix
|
||||
// ensures the persisted label is a string; this test asserts that string
|
||||
// is what surfaces in the rendered combobox selection.
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owners',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
unfilteredLabel: 'All',
|
||||
fetchSelects: jest.fn().mockResolvedValue({ data: [], totalCount: 0 }),
|
||||
paginate: true,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owners',
|
||||
operator: ListViewFilterOperator.RelationManyMany,
|
||||
value: { label: 'John Doe', value: 42 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('John Doe')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
useState,
|
||||
useMemo,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Select, AsyncSelect, FormLabel } from '@superset-ui/core/components';
|
||||
import { ListViewFilter as Filter, SelectOption } from '../types';
|
||||
import type { BaseFilter, FilterHandler } from './types';
|
||||
import { FilterContainer } from './Base';
|
||||
import { SELECT_WIDTH } from '../utils';
|
||||
|
||||
interface SelectFilterProps extends BaseFilter {
|
||||
fetchSelects?: Filter['fetchSelects'];
|
||||
name?: string;
|
||||
onSelect: (selected: SelectOption | undefined, isClear?: boolean) => void;
|
||||
optionFilterProps?: string[];
|
||||
paginate?: boolean;
|
||||
selects: Filter['selects'];
|
||||
loading?: boolean;
|
||||
dropdownStyle?: React.CSSProperties;
|
||||
}
|
||||
|
||||
function SelectFilter(
|
||||
{
|
||||
Header,
|
||||
name,
|
||||
fetchSelects,
|
||||
initialValue,
|
||||
onSelect,
|
||||
optionFilterProps,
|
||||
selects = [],
|
||||
loading = false,
|
||||
dropdownStyle,
|
||||
}: SelectFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const [selectedOption, setSelectedOption] = useState(initialValue);
|
||||
|
||||
const onChange = (selected: SelectOption, option?: SelectOption) => {
|
||||
// antd's `onChange` (with `labelInValue`) passes the `{label, value}`
|
||||
// labeled-value as the first arg and the full option (which carries
|
||||
// `title` and any other fields) as the second. Options may supply a
|
||||
// ReactNode label (e.g. OwnerSelectLabel for the chart list Owner
|
||||
// filter). Since this object is serialized into the URL and rehydrated
|
||||
// as the filter pill on return, we need a plain string. Prefer `title`
|
||||
// (set by callers to the human-readable name) before falling back to
|
||||
// the value.
|
||||
onSelect(
|
||||
selected
|
||||
? {
|
||||
label:
|
||||
typeof selected.label === 'string'
|
||||
? selected.label
|
||||
: (option?.title ?? String(selected.value)),
|
||||
value: selected.value,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setSelectedOption(selected);
|
||||
};
|
||||
|
||||
const onClear = () => {
|
||||
onSelect(undefined, true);
|
||||
setSelectedOption(undefined);
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
onClear();
|
||||
},
|
||||
}));
|
||||
|
||||
const fetchAndFormatSelects = useMemo(
|
||||
() => async (inputValue: string, page: number, pageSize: number) => {
|
||||
if (fetchSelects) {
|
||||
const selectValues = await fetchSelects(inputValue, page, pageSize);
|
||||
return {
|
||||
data: selectValues.data,
|
||||
totalCount: selectValues.totalCount,
|
||||
};
|
||||
}
|
||||
return {
|
||||
data: [],
|
||||
totalCount: 0,
|
||||
};
|
||||
},
|
||||
[fetchSelects],
|
||||
);
|
||||
const placeholder = t('Choose...');
|
||||
return (
|
||||
<FilterContainer
|
||||
data-test="select-filter-container"
|
||||
width={SELECT_WIDTH}
|
||||
vertical
|
||||
justify="center"
|
||||
align="start"
|
||||
>
|
||||
<FormLabel>{Header}</FormLabel>
|
||||
{fetchSelects ? (
|
||||
<AsyncSelect
|
||||
allowClear
|
||||
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||
data-test="filters-select"
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
options={fetchAndFormatSelects}
|
||||
optionFilterProps={optionFilterProps}
|
||||
placeholder={placeholder}
|
||||
dropdownStyle={dropdownStyle}
|
||||
showSearch
|
||||
value={selectedOption}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
allowClear
|
||||
ariaLabel={typeof Header === 'string' ? Header : name || t('Filter')}
|
||||
data-test="filters-select"
|
||||
labelInValue
|
||||
onChange={onChange}
|
||||
onClear={onClear}
|
||||
options={selects}
|
||||
placeholder={placeholder}
|
||||
dropdownStyle={dropdownStyle}
|
||||
showSearch
|
||||
value={selectedOption}
|
||||
loading={loading}
|
||||
/>
|
||||
)}
|
||||
</FilterContainer>
|
||||
);
|
||||
}
|
||||
export default forwardRef(SelectFilter);
|
||||
@@ -1,251 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { createRef, act } from 'react';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
|
||||
import TimeRangeFilter from './TimeRange';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
// Suppress debounced evaluation — the initial useEffect handles the committed
|
||||
// value; the debounced path is an optimistic UX enhancement, not a contract.
|
||||
jest.mock('src/explore/exploreUtils', () => ({
|
||||
...jest.requireActual('src/explore/exploreUtils'),
|
||||
useDebouncedEffect: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
|
||||
FRAME_OPTIONS: [
|
||||
{ label: 'No filter', value: 'No filter' },
|
||||
{ label: 'Custom', value: 'Custom' },
|
||||
],
|
||||
guessFrame: jest.fn().mockReturnValue('Custom'),
|
||||
// 'No filter' is the string value of NO_TIME_RANGE constant
|
||||
useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/controls/DateFilterControl/components',
|
||||
() => ({
|
||||
AdvancedFrame: () => <div data-test="advanced-frame" />,
|
||||
CalendarFrame: () => <div data-test="calendar-frame" />,
|
||||
CommonFrame: () => <div data-test="common-frame" />,
|
||||
CustomFrame: ({ value }: { value: string }) => (
|
||||
<div data-test="custom-frame">{value}</div>
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock(
|
||||
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
|
||||
() => ({
|
||||
CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
|
||||
}),
|
||||
);
|
||||
|
||||
const VALID_RANGE = '2024-01-01 : 2024-01-31';
|
||||
|
||||
// Default successful response that fetchTimeRange and the Apply handler both use
|
||||
const MOCK_TIME_RANGE_RESULT = {
|
||||
json: {
|
||||
result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
|
||||
},
|
||||
};
|
||||
|
||||
let getSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
getSpy = jest
|
||||
.spyOn(SupersetClient, 'get')
|
||||
.mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
getSpy.mockRestore();
|
||||
});
|
||||
|
||||
function renderFilter(
|
||||
props: Partial<{
|
||||
value: string;
|
||||
onSubmit: jest.Mock;
|
||||
onClose: jest.Mock;
|
||||
}> = {},
|
||||
) {
|
||||
const onSubmit = props.onSubmit ?? jest.fn();
|
||||
const onClose = props.onClose ?? jest.fn();
|
||||
return render(
|
||||
<TimeRangeFilter
|
||||
value={props.value ?? VALID_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
test('renders range type label, actual time range section, and footer buttons', () => {
|
||||
renderFilter();
|
||||
expect(screen.getByText('Range type')).toBeInTheDocument();
|
||||
expect(screen.getByText('Actual time range')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the custom frame when guessFrame returns Custom', () => {
|
||||
renderFilter();
|
||||
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Apply is disabled until the API validates the initial value', async () => {
|
||||
// Block resolution so we can observe disabled state
|
||||
let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
|
||||
getSpy.mockReturnValue(
|
||||
new Promise(res => {
|
||||
resolve = res;
|
||||
}),
|
||||
);
|
||||
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
expect(apply).toBeDisabled();
|
||||
|
||||
act(() => {
|
||||
resolve!(MOCK_TIME_RANGE_RESULT);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply is enabled when the API returns a valid result', async () => {
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Apply is disabled when the API returns an error response', async () => {
|
||||
getSpy.mockRejectedValue(new Error('Bad request'));
|
||||
renderFilter();
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
// Give fetchTimeRange time to reject and set validTimeRange=false
|
||||
await waitFor(() => {
|
||||
expect(apply).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
test('Cancel button calls onClose', async () => {
|
||||
const onClose = jest.fn();
|
||||
renderFilter({ onClose });
|
||||
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Apply calls onSubmit([since, until]) and onClose when API succeeds', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
renderFilter({ onSubmit, onClose });
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(apply);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onSubmit).toHaveBeenCalledWith([
|
||||
'2024-01-01T00:00:00',
|
||||
'2024-01-31T23:59:59',
|
||||
]);
|
||||
});
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Apply calls onClose but not onSubmit when the API call throws', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
// fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
|
||||
getSpy
|
||||
.mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in useEffect
|
||||
.mockRejectedValueOnce(new Error('network')); // Apply button API call
|
||||
|
||||
renderFilter({ onSubmit, onClose });
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
await userEvent.click(apply);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without an API call', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const onClose = jest.fn();
|
||||
|
||||
render(
|
||||
<TimeRangeFilter
|
||||
value={NO_TIME_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={onClose}
|
||||
/>,
|
||||
);
|
||||
|
||||
const apply = screen.getByRole('button', { name: /apply/i });
|
||||
await waitFor(() => {
|
||||
expect(apply).not.toBeDisabled();
|
||||
});
|
||||
|
||||
const callsBefore = getSpy.mock.calls.length;
|
||||
await userEvent.click(apply);
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(undefined);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
// No extra API call for NO_TIME_RANGE — the button short-circuits
|
||||
expect(getSpy.mock.calls.length).toBe(callsBefore);
|
||||
});
|
||||
|
||||
test('clearFilter via ref calls onSubmit(undefined)', async () => {
|
||||
const onSubmit = jest.fn();
|
||||
const ref = createRef<FilterHandler>();
|
||||
|
||||
render(
|
||||
<TimeRangeFilter
|
||||
ref={ref}
|
||||
value={VALID_RANGE}
|
||||
onSubmit={onSubmit}
|
||||
onClose={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
act(() => {
|
||||
ref.current?.clearFilter();
|
||||
});
|
||||
|
||||
expect(onSubmit).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
@@ -1,291 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
forwardRef,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
type RefObject,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
NO_TIME_RANGE,
|
||||
SupersetClient,
|
||||
fetchTimeRange,
|
||||
} from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
Button,
|
||||
Constants,
|
||||
Divider,
|
||||
Icons,
|
||||
Select,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useDebouncedEffect } from 'src/explore/exploreUtils';
|
||||
import {
|
||||
FRAME_OPTIONS,
|
||||
guessFrame,
|
||||
useDefaultTimeFilter,
|
||||
} from 'src/explore/components/controls/DateFilterControl/utils';
|
||||
import {
|
||||
AdvancedFrame,
|
||||
CalendarFrame,
|
||||
CommonFrame,
|
||||
CustomFrame,
|
||||
} from 'src/explore/components/controls/DateFilterControl/components';
|
||||
import { CurrentCalendarFrame } from 'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
|
||||
import type { FrameType } from 'src/explore/components/controls/DateFilterControl/types';
|
||||
import type { FilterHandler } from './types';
|
||||
|
||||
interface TimeRangeFilterProps {
|
||||
value?: string;
|
||||
onSubmit: (value: [string, string] | undefined) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const StyledRangeType = styled(Select)`
|
||||
width: 272px;
|
||||
`;
|
||||
|
||||
const ContentWrapper = styled.div`
|
||||
${({ theme }) => css`
|
||||
width: 600px;
|
||||
padding: ${theme.sizeUnit * 3}px;
|
||||
background: ${theme.colorBgElevated};
|
||||
border-radius: ${theme.borderRadiusLG}px;
|
||||
box-shadow: ${theme.boxShadowSecondary};
|
||||
|
||||
.ant-row {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.ant-picker {
|
||||
padding: 4px 17px 4px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.ant-divider-horizontal {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
.control-label {
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
line-height: 16px;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-style: normal;
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: 15px;
|
||||
line-height: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.control-anchor-to {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.control-anchor-to-datetime {
|
||||
width: 217px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: right;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
span {
|
||||
margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.text {
|
||||
vertical-align: middle;
|
||||
}
|
||||
.error {
|
||||
color: ${({ theme }) => theme.colorError};
|
||||
}
|
||||
`;
|
||||
|
||||
function TimeRangeFilter(
|
||||
{ value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
|
||||
ref: RefObject<FilterHandler>,
|
||||
) {
|
||||
const defaultTimeFilter = useDefaultTimeFilter();
|
||||
const value = valueProp ?? defaultTimeFilter;
|
||||
const theme = useTheme();
|
||||
|
||||
// guessedFrame is only used for the initial useState — value is stable at
|
||||
// mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
|
||||
// always mounts fresh with the current committed value.
|
||||
const guessedFrame = useMemo(() => guessFrame(value), [value]);
|
||||
const [frame, setFrame] = useState<FrameType>(guessedFrame);
|
||||
const [timeRangeValue, setTimeRangeValue] = useState(value);
|
||||
const [evalResponse, setEvalResponse] = useState(value);
|
||||
const [validTimeRange, setValidTimeRange] = useState(false);
|
||||
const [lastFetched, setLastFetched] = useState(value);
|
||||
|
||||
// Evaluate the committed value shown in "Actual time range".
|
||||
useEffect(() => {
|
||||
if (value === NO_TIME_RANGE) {
|
||||
setEvalResponse(NO_TIME_RANGE);
|
||||
setValidTimeRange(true);
|
||||
return;
|
||||
}
|
||||
fetchTimeRange(value).then(({ value: actual, error }) => {
|
||||
if (error) {
|
||||
setEvalResponse(error ?? '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalResponse(actual ?? value);
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
setLastFetched(value);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
// Debounced evaluation of the in-progress selection (drives "Actual time range").
|
||||
useDebouncedEffect(
|
||||
() => {
|
||||
if (timeRangeValue === NO_TIME_RANGE) {
|
||||
setEvalResponse(NO_TIME_RANGE);
|
||||
setLastFetched(NO_TIME_RANGE);
|
||||
setValidTimeRange(true);
|
||||
return;
|
||||
}
|
||||
if (lastFetched !== timeRangeValue) {
|
||||
fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
|
||||
if (error) {
|
||||
setEvalResponse(error ?? '');
|
||||
setValidTimeRange(false);
|
||||
} else {
|
||||
setEvalResponse(actual ?? '');
|
||||
setValidTimeRange(true);
|
||||
}
|
||||
setLastFetched(timeRangeValue);
|
||||
});
|
||||
}
|
||||
},
|
||||
Constants.SLOW_DEBOUNCE,
|
||||
[timeRangeValue],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilter: () => {
|
||||
onSubmit(undefined);
|
||||
},
|
||||
}));
|
||||
|
||||
function onChangeFrame(val: FrameType) {
|
||||
if (val === NO_TIME_RANGE) {
|
||||
setTimeRangeValue(NO_TIME_RANGE);
|
||||
}
|
||||
setFrame(val);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContentWrapper>
|
||||
<div className="control-label">{t('Range type')}</div>
|
||||
<StyledRangeType
|
||||
ariaLabel={t('Range type')}
|
||||
options={FRAME_OPTIONS}
|
||||
value={frame}
|
||||
onChange={onChangeFrame}
|
||||
/>
|
||||
{frame !== 'No filter' && <Divider />}
|
||||
{frame === 'Common' && (
|
||||
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Calendar' && (
|
||||
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Current' && (
|
||||
<CurrentCalendarFrame
|
||||
value={timeRangeValue}
|
||||
onChange={setTimeRangeValue}
|
||||
/>
|
||||
)}
|
||||
{frame === 'Advanced' && (
|
||||
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
{frame === 'Custom' && (
|
||||
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
|
||||
)}
|
||||
<Divider />
|
||||
<div>
|
||||
<div className="section-title">{t('Actual time range')}</div>
|
||||
{validTimeRange && (
|
||||
<div>
|
||||
{evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
|
||||
</div>
|
||||
)}
|
||||
{!validTimeRange && (
|
||||
<IconWrapper className="warning">
|
||||
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
|
||||
<span className="text error">{evalResponse}</span>
|
||||
</IconWrapper>
|
||||
)}
|
||||
</div>
|
||||
<Divider />
|
||||
<div className="footer">
|
||||
<Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
|
||||
{t('CANCEL')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonStyle="primary"
|
||||
cta
|
||||
disabled={!validTimeRange}
|
||||
key="apply"
|
||||
onClick={async () => {
|
||||
if (timeRangeValue === NO_TIME_RANGE) {
|
||||
onSubmit(undefined);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
// fetchTimeRange returns a formatted display string ("X ≤ col < Y"),
|
||||
// not the raw since/until strings. Call the API directly to get them.
|
||||
try {
|
||||
const response = await SupersetClient.get({
|
||||
endpoint: `/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
|
||||
});
|
||||
const since: string | undefined =
|
||||
response?.json?.result[0]?.since;
|
||||
const until: string | undefined =
|
||||
response?.json?.result[0]?.until;
|
||||
if (since !== undefined && until !== undefined) {
|
||||
onSubmit([since, until]);
|
||||
}
|
||||
} catch {
|
||||
// leave filter unchanged on error
|
||||
}
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
{t('APPLY')}
|
||||
</Button>
|
||||
</div>
|
||||
</ContentWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(TimeRangeFilter);
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { ListViewFilterOperator } from '../types';
|
||||
import UIFilters from './index';
|
||||
|
||||
@@ -98,335 +97,7 @@ test('search filter passes autoComplete prop correctly', () => {
|
||||
expect(input.autocomplete).toBe('new-password');
|
||||
});
|
||||
|
||||
test('renders a compact pill trigger for select filters', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('compact-filter-pill')).toBeInTheDocument();
|
||||
expect(screen.getByText('Owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill shows active state (clear button) when a value is selected', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [{ label: 'Alice', value: 1 }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { label: 'Alice', value: 1 },
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('select pill tooltip falls back to static selects on cold URL load (no cached label)', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Owner',
|
||||
key: 'owner',
|
||||
id: 'owner',
|
||||
input: 'select' as const,
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
selects: [
|
||||
{ label: 'Alice', value: 1 },
|
||||
{ label: 'Bob', value: 2 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
// Simulate cold URL load: value has only numeric value, no label in cache
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'owner',
|
||||
operator: ListViewFilterOperator.RelationOneMany,
|
||||
value: { value: 1 } as any,
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// The pill should be active (clear button visible) and the static label
|
||||
// should be resolved as the tooltip source
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear owner filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Time range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range pill shows active state when a time range string is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last week',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Clear icon is inside the pill (not a separate button)
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
expect(clearIcon).toBeInTheDocument();
|
||||
expect(pill).toContainElement(clearIcon);
|
||||
});
|
||||
|
||||
test('datetime_range pill is inactive when value is NO_TIME_RANGE', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'No filter',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('compact-filter-clear')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range pill shows the time range string as tooltip title', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last month',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Pill is active and clear icon is inside
|
||||
expect(screen.getByTestId('compact-filter-clear')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range filter renders as CompactFilterTrigger with dialog aria-haspopup', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const pill = screen.getByTestId('compact-filter-pill');
|
||||
expect(pill).toBeInTheDocument();
|
||||
expect(pill).toHaveAttribute('aria-haspopup', 'dialog');
|
||||
expect(screen.getByText('Age range')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('numerical_range pill shows active state when value is set', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'age_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [18, 65],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={mockUpdateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /clear age range filter/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('datetime_range onClear calls updateFilterValue with undefined directly', async () => {
|
||||
const updateFilterValue = jest.fn();
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Time range',
|
||||
key: 'time_range',
|
||||
id: 'time_range',
|
||||
input: 'datetime_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'time_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: 'Last week',
|
||||
},
|
||||
]}
|
||||
updateFilterValue={updateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearIcon = screen.getByTestId('compact-filter-clear');
|
||||
await userEvent.click(clearIcon);
|
||||
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
test('numerical_range onClear calls updateFilterValue with undefined directly', async () => {
|
||||
const updateFilterValue = jest.fn();
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Age range',
|
||||
key: 'age_range',
|
||||
id: 'age_range',
|
||||
input: 'numerical_range' as const,
|
||||
operator: ListViewFilterOperator.Between,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<UIFilters
|
||||
filters={filters}
|
||||
internalFilters={[
|
||||
{
|
||||
id: 'age_range',
|
||||
operator: ListViewFilterOperator.Between,
|
||||
value: [18, 65],
|
||||
},
|
||||
]}
|
||||
updateFilterValue={updateFilterValue}
|
||||
/>,
|
||||
);
|
||||
|
||||
const clearBtn = screen.getByRole('button', {
|
||||
name: /clear age range filter/i,
|
||||
});
|
||||
await userEvent.click(clearBtn);
|
||||
expect(updateFilterValue).toHaveBeenCalledWith(0, undefined);
|
||||
});
|
||||
|
||||
test('renders only the first search filter when multiple search filters are configured', () => {
|
||||
test('renders multiple search filters with different inputName values', () => {
|
||||
const filters = [
|
||||
{
|
||||
Header: 'Name',
|
||||
@@ -454,8 +125,8 @@ test('renders only the first search filter when multiple search filters are conf
|
||||
/>,
|
||||
);
|
||||
|
||||
// Only the first search filter renders — one search box per page
|
||||
const inputs = screen.getAllByTestId('filters-search') as HTMLInputElement[];
|
||||
expect(inputs).toHaveLength(1);
|
||||
expect(inputs).toHaveLength(2);
|
||||
expect(inputs[0].name).toBe('filter_name_search');
|
||||
expect(inputs[1].name).toBe('description');
|
||||
});
|
||||
|
||||
@@ -19,16 +19,12 @@
|
||||
import {
|
||||
createRef,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useState,
|
||||
RefObject,
|
||||
} from 'react';
|
||||
|
||||
import { withTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
|
||||
import type {
|
||||
ListViewFilterValue as FilterValue,
|
||||
@@ -37,13 +33,10 @@ import type {
|
||||
SelectOption,
|
||||
} from '../types';
|
||||
import type { FilterHandler } from './types';
|
||||
import { NO_TIME_RANGE } from '@superset-ui/core';
|
||||
import SearchFilter from './Search';
|
||||
import SelectFilter from './Select';
|
||||
import DateRangeFilter from './DateRange';
|
||||
import NumericalRangeFilter from './NumericalRange';
|
||||
import TimeRangeFilter from './TimeRange';
|
||||
import CompactFilterTrigger from './CompactFilterTrigger';
|
||||
import CompactSelectPanel from './CompactSelectPanel';
|
||||
import FilterPopoverContent from './FilterPopoverContent';
|
||||
|
||||
interface UIFiltersProps {
|
||||
filters: Filters;
|
||||
@@ -53,10 +46,7 @@ interface UIFiltersProps {
|
||||
|
||||
function UIFilters(
|
||||
{ filters, internalFilters = [], updateFilterValue }: UIFiltersProps,
|
||||
ref: RefObject<{
|
||||
clearFilters: () => void;
|
||||
clearFilterById: (id: string) => void;
|
||||
}>,
|
||||
ref: RefObject<{ clearFilters: () => void }>,
|
||||
) {
|
||||
const filterRefs = useMemo(
|
||||
() =>
|
||||
@@ -64,320 +54,125 @@ function UIFilters(
|
||||
[filters.length],
|
||||
);
|
||||
|
||||
// Cache display labels for select filters so tooltip works after URL round-trip
|
||||
// (URL serialization strips the label, leaving only the value).
|
||||
const [tooltipLabels, setTooltipLabels] = useState<Record<number, string>>(
|
||||
{},
|
||||
);
|
||||
|
||||
// Evaluated human-readable labels for datetime_range pills (e.g. "2024-05-01 : 2024-05-31").
|
||||
const [timeRangeTooltips, setTimeRangeTooltips] = useState<
|
||||
Record<number, string>
|
||||
>({});
|
||||
|
||||
// On cold load, URL params restore values but not labels for fetchSelects filters.
|
||||
// Fetch the first page of options and cache the matching label so the tooltip works.
|
||||
useEffect(() => {
|
||||
filters.forEach((filter, index) => {
|
||||
if (filter.input !== 'select' || !filter.fetchSelects) return;
|
||||
if (tooltipLabels[index]) return;
|
||||
const val = internalFilters?.[index]?.value as SelectOption | undefined;
|
||||
if (!val?.value) return;
|
||||
filter.fetchSelects('', 0, 500).then(result => {
|
||||
const match = result?.data?.find(
|
||||
(s: SelectOption) => s.value === val.value,
|
||||
);
|
||||
if (match) {
|
||||
const lbl =
|
||||
typeof match.label === 'string'
|
||||
? match.label
|
||||
: String(match.value ?? '');
|
||||
setTooltipLabels(prev => ({ ...prev, [index]: lbl }));
|
||||
}
|
||||
});
|
||||
});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [internalFilters]);
|
||||
|
||||
// Build datetime_range tooltips from the resolved [start, end] array value.
|
||||
// Handles both ISO strings and unix-ms numbers.
|
||||
useEffect(() => {
|
||||
filters.forEach((filter, index) => {
|
||||
if (filter.input !== 'datetime_range') return;
|
||||
const val = internalFilters?.[index]?.value;
|
||||
if (Array.isArray(val) && val.length === 2) {
|
||||
const fmt = (v: unknown) => {
|
||||
const d = new Date(v as string | number);
|
||||
return isNaN(d.getTime())
|
||||
? String(v)
|
||||
: d.toISOString().replace('T', ' ').slice(0, 19);
|
||||
};
|
||||
const tooltip = `${fmt(val[0])} – ${fmt(val[1])}`;
|
||||
setTimeRangeTooltips(prev =>
|
||||
prev[index] === tooltip ? prev : { ...prev, [index]: tooltip },
|
||||
);
|
||||
} else {
|
||||
setTimeRangeTooltips(prev => {
|
||||
if (!(index in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, [filters, internalFilters]);
|
||||
|
||||
const clearFilterAtIndex = useCallback(
|
||||
(index: number) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
setTooltipLabels(prev => {
|
||||
const next = { ...prev };
|
||||
delete next[index];
|
||||
return next;
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[updateFilterValue],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
clearFilters: () => {
|
||||
filterRefs.forEach((_, index) => {
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
updateFilterValue(index, undefined);
|
||||
filterRefs.forEach((filter: any) => {
|
||||
filter.current?.clearFilter?.();
|
||||
});
|
||||
setTooltipLabels({});
|
||||
setTimeRangeTooltips({});
|
||||
},
|
||||
clearFilterById: (id: string) => {
|
||||
const index = filters.findIndex(f => f.id === id);
|
||||
if (index >= 0) {
|
||||
clearFilterAtIndex(index);
|
||||
filterRefs[index]?.current?.clearFilter?.();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Search always leads the filter bar regardless of declaration order.
|
||||
// Only the first search filter renders; subsequent ones are skipped (see note below).
|
||||
// NOTE: This means secondary search fields (e.g. Email/Username on Users,
|
||||
// Group Key on RLS) are not currently accessible via the filter bar. Those
|
||||
// pages previously relied on multiple inline inputs. This is a known UX
|
||||
// trade-off — revisit if admin workflows require additional search fields.
|
||||
let searchFilterRendered = false;
|
||||
|
||||
// Render in two passes: search first, then all other filter types.
|
||||
const renderFilter = (_: (typeof filters)[number], index: number) => {
|
||||
const {
|
||||
Header,
|
||||
fetchSelects,
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
loading,
|
||||
min,
|
||||
max,
|
||||
autoComplete,
|
||||
inputName,
|
||||
popupStyle,
|
||||
dateFilterValueType,
|
||||
} = filters[index];
|
||||
const initialValue = internalFilters?.[index]?.value;
|
||||
if (input === 'select') {
|
||||
const selectValue = initialValue as SelectOption | undefined;
|
||||
// Prefer cached label (survives URL round-trips where only the value
|
||||
// is preserved). Fall back to the static selects list for cold loads.
|
||||
const cachedLabel = tooltipLabels[index];
|
||||
const staticFallback = cachedLabel
|
||||
? undefined
|
||||
: selects?.find(s => s.value === selectValue?.value)?.label;
|
||||
const tooltipTitle = !!selectValue
|
||||
? cachedLabel ||
|
||||
(typeof staticFallback === 'string' ? staticFallback : undefined)
|
||||
: t('Choose...');
|
||||
return (
|
||||
<span key={key} data-test="select-filter-container">
|
||||
<CompactFilterTrigger
|
||||
label={Header}
|
||||
hasValue={!!selectValue}
|
||||
tooltipTitle={tooltipTitle}
|
||||
onClear={() => clearFilterAtIndex(index)}
|
||||
>
|
||||
{({ isOpen, onClose }) => (
|
||||
<CompactSelectPanel
|
||||
return (
|
||||
<>
|
||||
{filters.map(
|
||||
(
|
||||
{
|
||||
Header,
|
||||
fetchSelects,
|
||||
key,
|
||||
id,
|
||||
input,
|
||||
optionFilterProps,
|
||||
paginate,
|
||||
selects,
|
||||
toolTipDescription,
|
||||
onFilterUpdate,
|
||||
loading,
|
||||
dateFilterValueType,
|
||||
min,
|
||||
max,
|
||||
popupStyle,
|
||||
autoComplete,
|
||||
inputName,
|
||||
},
|
||||
index,
|
||||
) => {
|
||||
const initialValue = internalFilters?.[index]?.value;
|
||||
if (input === 'select') {
|
||||
return (
|
||||
<SelectFilter
|
||||
ref={filterRefs[index]}
|
||||
selects={selects}
|
||||
Header={Header}
|
||||
fetchSelects={fetchSelects}
|
||||
value={initialValue as SelectOption | undefined}
|
||||
loading={loading ?? false}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
panelStyle={popupStyle}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={id}
|
||||
onSelect={(
|
||||
option: SelectOption | undefined,
|
||||
isClear?: boolean,
|
||||
) => {
|
||||
if (option && !isClear) {
|
||||
setTooltipLabels(prev => ({
|
||||
...prev,
|
||||
[index]:
|
||||
typeof option.label === 'string'
|
||||
? option.label
|
||||
: String(option.value ?? ''),
|
||||
}));
|
||||
}
|
||||
if (onFilterUpdate && !isClear) {
|
||||
onFilterUpdate(option);
|
||||
if (onFilterUpdate) {
|
||||
// Filter change triggers both onChange AND onClear, only want to track onChange
|
||||
if (!isClear) {
|
||||
onFilterUpdate(option);
|
||||
}
|
||||
}
|
||||
|
||||
updateFilterValue(index, option);
|
||||
}}
|
||||
optionFilterProps={optionFilterProps}
|
||||
paginate={paginate}
|
||||
selects={selects}
|
||||
loading={loading ?? false}
|
||||
dropdownStyle={popupStyle}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
if (searchFilterRendered) return null;
|
||||
searchFilterRendered = true;
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={inputName ?? id}
|
||||
toolTipDescription={toolTipDescription}
|
||||
onSubmit={(value: string) => {
|
||||
if (onFilterUpdate) {
|
||||
onFilterUpdate(value);
|
||||
}
|
||||
|
||||
updateFilterValue(index, value);
|
||||
}}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'datetime_range') {
|
||||
// dateFilterValueType absent or 'unix': column stores unix ms (e.g. Query History start_time).
|
||||
// 'iso': column stores ISO date strings (e.g. UsersList created_on, ActionLog dttm).
|
||||
const isUnixType = !dateFilterValueType || dateFilterValueType === 'unix';
|
||||
|
||||
// initialValue may be [ms, ms] (unix), ["iso","iso"] (iso), or legacy string.
|
||||
// Always reconstruct panelValue as "ISO : ISO" so the TimeRange panel
|
||||
// can parse it as a Custom date range regardless of storage type.
|
||||
let resolvedIsoRange: [string, string] | null = null;
|
||||
if (Array.isArray(initialValue) && initialValue.length === 2) {
|
||||
if (typeof initialValue[0] === 'number') {
|
||||
resolvedIsoRange = [
|
||||
new Date(initialValue[0]).toISOString(),
|
||||
new Date(initialValue[1] as number).toISOString(),
|
||||
];
|
||||
} else if (typeof initialValue[0] === 'string') {
|
||||
resolvedIsoRange = initialValue as [string, string];
|
||||
}
|
||||
}
|
||||
const legacyStringVal =
|
||||
!resolvedIsoRange &&
|
||||
typeof initialValue === 'string' &&
|
||||
initialValue !== NO_TIME_RANGE
|
||||
? initialValue
|
||||
: null;
|
||||
const hasTimeValue = !!(resolvedIsoRange || legacyStringVal);
|
||||
const panelValue =
|
||||
resolvedIsoRange?.join(' : ') ?? legacyStringVal ?? undefined;
|
||||
return (
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
label={Header}
|
||||
hasValue={hasTimeValue}
|
||||
tooltipTitle={
|
||||
hasTimeValue ? (timeRangeTooltips[index] ?? panelValue) : undefined
|
||||
);
|
||||
}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
updateFilterValue(index, undefined);
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<TimeRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
value={panelValue}
|
||||
onClose={onClose}
|
||||
onSubmit={value => {
|
||||
if (!value) {
|
||||
updateFilterValue(index, undefined);
|
||||
} else if (isUnixType) {
|
||||
// Convert ISO strings to unix ms for numeric columns
|
||||
updateFilterValue(index, [
|
||||
new Date(value[0]).getTime(),
|
||||
new Date(value[1]).getTime(),
|
||||
]);
|
||||
} else {
|
||||
if (input === 'search' && typeof Header === 'string') {
|
||||
return (
|
||||
<SearchFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={inputName ?? id}
|
||||
toolTipDescription={toolTipDescription}
|
||||
onSubmit={(value: string) => {
|
||||
if (onFilterUpdate) {
|
||||
onFilterUpdate(value);
|
||||
}
|
||||
|
||||
updateFilterValue(index, value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
const hasRangeValue =
|
||||
Array.isArray(initialValue) &&
|
||||
initialValue.some(v => v !== null && v !== undefined);
|
||||
const rangeTooltip = hasRangeValue
|
||||
? (initialValue as (number | null | undefined)[])
|
||||
.filter(v => v !== null && v !== undefined)
|
||||
.join(' – ')
|
||||
: undefined;
|
||||
return (
|
||||
<CompactFilterTrigger
|
||||
key={key}
|
||||
label={Header}
|
||||
hasValue={hasRangeValue}
|
||||
tooltipTitle={rangeTooltip}
|
||||
popupType="dialog"
|
||||
onClear={() => {
|
||||
updateFilterValue(index, undefined);
|
||||
}}
|
||||
>
|
||||
{({ onClose }) => (
|
||||
<FilterPopoverContent onClose={onClose}>
|
||||
}}
|
||||
autoComplete={autoComplete}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'datetime_range') {
|
||||
return (
|
||||
<DateRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
dateFilterValueType={dateFilterValueType || 'unix'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (input === 'numerical_range') {
|
||||
return (
|
||||
<NumericalRangeFilter
|
||||
ref={filterRefs[index]}
|
||||
Header={Header}
|
||||
initialValue={initialValue}
|
||||
min={min}
|
||||
max={max}
|
||||
key={key}
|
||||
name={id}
|
||||
onSubmit={value => updateFilterValue(index, value)}
|
||||
/>
|
||||
</FilterPopoverContent>
|
||||
)}
|
||||
</CompactFilterTrigger>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Search first */}
|
||||
{filters.map((_, index) =>
|
||||
filters[index].input === 'search'
|
||||
? renderFilter(filters[index], index)
|
||||
: null,
|
||||
)}
|
||||
{/* Then all other filter types */}
|
||||
{filters.map((_, index) =>
|
||||
filters[index].input !== 'search'
|
||||
? renderFilter(filters[index], index)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -301,19 +301,15 @@ describe('ListView', () => {
|
||||
});
|
||||
|
||||
test('renders UI filters', () => {
|
||||
// select and datetime_range filters render as compact pill buttons;
|
||||
// search filter renders as a text input
|
||||
const filterPills = screen.getAllByTestId('compact-filter-pill');
|
||||
expect(filterPills).toHaveLength(3); // ID, Age, Time
|
||||
const filterControls = screen.getAllByRole('combobox');
|
||||
expect(filterControls).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('calls fetchData on filter', async () => {
|
||||
// Click the ID compact pill to open its option panel
|
||||
const idPill = screen.getByRole('button', { name: 'ID' });
|
||||
await userEvent.click(idPill);
|
||||
|
||||
// Wait for and click the 'foo' option in the dropdown panel
|
||||
const option = await screen.findByRole('option', { name: 'foo' });
|
||||
// Handle select filter
|
||||
const selectFilter = screen.getAllByRole('combobox')[0];
|
||||
await userEvent.click(selectFilter);
|
||||
const option = screen.getByText('foo');
|
||||
await userEvent.click(option);
|
||||
|
||||
// Handle search filter
|
||||
@@ -345,10 +341,7 @@ describe('ListView', () => {
|
||||
initialSort: [{ id: 'something' }],
|
||||
});
|
||||
|
||||
const sortSelectContainer = screen.getByTestId('card-sort-select');
|
||||
const sortSelect = sortSelectContainer.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement;
|
||||
const sortSelect = screen.getByTestId('card-sort-select');
|
||||
await userEvent.click(sortSelect);
|
||||
|
||||
const sortOption = screen.getByText('Alphabetical');
|
||||
|
||||
@@ -65,43 +65,13 @@ const ListViewStyles = styled.div`
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-bottom: ${theme.sizeUnit * 4}px;
|
||||
|
||||
& .controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
column-gap: ${theme.sizeUnit * 2}px;
|
||||
row-gap: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Search input — fixed width/height matching pill height, label hidden */
|
||||
[data-test='search-filter-container'] {
|
||||
width: ${theme.sizeUnit * 44}px;
|
||||
flex-shrink: 0;
|
||||
height: ${theme.controlHeight}px;
|
||||
align-self: center;
|
||||
/* Hide the FormLabel Flex wrapper entirely so it doesn't affect
|
||||
the column's justify-content centering calculation. */
|
||||
> .ant-flex {
|
||||
display: none;
|
||||
}
|
||||
label {
|
||||
display: none;
|
||||
}
|
||||
.ant-input-affix-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Select filter pill wrappers — make them proper flex items so the
|
||||
inline-flex button inside doesn't introduce line-box quirks. */
|
||||
[data-test='select-filter-container'] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-self: center;
|
||||
}
|
||||
column-gap: ${theme.sizeUnit * 7}px;
|
||||
row-gap: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -197,6 +167,7 @@ const bulkSelectColumnConfig = {
|
||||
const ViewModeContainer = styled.div`
|
||||
${({ theme }) => `
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
margin-top: ${theme.sizeUnit * 5 + 1}px;
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
|
||||
@@ -221,29 +192,6 @@ const ViewModeContainer = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const ClearAllButton = styled.button`
|
||||
${({ theme }) => `
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0 ${theme.sizeUnit}px;
|
||||
color: ${theme.colorPrimary};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
line-height: ${theme.controlHeight}px;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
color: ${theme.colorPrimaryHover};
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
color: ${theme.colorTextDisabled};
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const EmptyWrapper = styled.div`
|
||||
${({ theme }) => `
|
||||
padding: ${theme.sizeUnit * 40}px 0;
|
||||
@@ -408,14 +356,6 @@ export function ListView<T extends object = any>({
|
||||
clearFilterById: (id: string) => void;
|
||||
}>(null);
|
||||
|
||||
const hasActiveFilters = internalFilters.some(f => {
|
||||
if (f.value === null || f.value === undefined || f.value === '')
|
||||
return false;
|
||||
if (Array.isArray(f.value))
|
||||
return f.value.some(v => v !== null && v !== undefined && v !== '');
|
||||
return true;
|
||||
});
|
||||
|
||||
// Wire the optional external filtersRef to our internal filterControlsRef.
|
||||
// useLayoutEffect fires synchronously after DOM mutations, guaranteeing the
|
||||
// ref is populated before the first paint and after every update.
|
||||
@@ -481,21 +421,6 @@ export function ListView<T extends object = any>({
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
<span>
|
||||
<ClearAllButton
|
||||
type="button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={() => filterControlsRef.current?.clearFilters()}
|
||||
>
|
||||
{t('Clear all')}
|
||||
</ClearAllButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`body ${rows.length === 0 ? 'empty' : ''} `}>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const useDashboardMetadataBar = (dashboardInfo: DashboardInfo) => {
|
||||
type: MetadataType.Owner as const,
|
||||
createdBy: getOwnerName(dashboardInfo.created_by) || t('Not available'),
|
||||
owners:
|
||||
dashboardInfo.owners.length > 0
|
||||
dashboardInfo.owners && dashboardInfo.owners.length > 0
|
||||
? dashboardInfo.owners.map(getOwnerName)
|
||||
: t('None'),
|
||||
createdOn: dashboardInfo.created_on_delta_humanized,
|
||||
|
||||
@@ -28,7 +28,6 @@ import {
|
||||
Select,
|
||||
AsyncSelect,
|
||||
} from '@superset-ui/core/components';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
import { FormValues, GroupModalProps } from './types';
|
||||
import { createGroup, fetchUserOptions, updateGroup } from './utils';
|
||||
|
||||
@@ -95,7 +94,7 @@ function GroupListModal({
|
||||
users:
|
||||
group?.users?.map(user => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayLabel(user),
|
||||
label: user.username,
|
||||
})) || [],
|
||||
};
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import rison from 'rison';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
import { FormValues } from './types';
|
||||
|
||||
export const createGroup = async (values: FormValues) => {
|
||||
@@ -65,7 +64,7 @@ export const fetchUserOptions = async (
|
||||
return {
|
||||
data: results.map((user: any) => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayLabel(user),
|
||||
label: user.username,
|
||||
})),
|
||||
totalCount: response.json?.count ?? 0,
|
||||
};
|
||||
|
||||
@@ -75,7 +75,7 @@ export const useLanguageMenuItems = ({
|
||||
type: 'submenu' as const,
|
||||
label: (
|
||||
<span className="f16" aria-label={t('Languages')}>
|
||||
<i className={`flag ${languages[locale]?.flag ?? 'us'}`} />
|
||||
<i className={`flag ${languages[locale]?.flag ?? ''}`} />
|
||||
</span>
|
||||
),
|
||||
icon: <Icons.CaretDownOutlined iconSize="xs" />,
|
||||
|
||||
@@ -63,16 +63,6 @@ jest.mock('@superset-ui/core', () => {
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('RoleListEditModal', () => {
|
||||
beforeEach(() => {
|
||||
(SupersetClient.get as jest.Mock).mockResolvedValue({
|
||||
json: { count: 0, result: [] },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockRole = {
|
||||
id: 1,
|
||||
name: 'Admin',
|
||||
@@ -157,8 +147,8 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
// Wait for user hydration to complete so setFieldsValue has populated
|
||||
// the form with the fetched users before submitting.
|
||||
await screen.findByText('John Doe');
|
||||
await screen.findByText('Jane Smith');
|
||||
await screen.findByText('johndoe');
|
||||
await screen.findByText('janesmith');
|
||||
|
||||
fireEvent.change(screen.getByTestId('role-name-input'), {
|
||||
target: { value: 'Updated Role' },
|
||||
@@ -251,19 +241,16 @@ describe('RoleListEditModal', () => {
|
||||
test('preserves missing IDs as numeric fallbacks on partial hydration', async () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
) {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
// Only return permission id=10, not id=20
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
count: 1,
|
||||
result: [
|
||||
{
|
||||
id: 10,
|
||||
permission_name: 'can_read',
|
||||
view_menu_name: 'Dashboard',
|
||||
permission: { name: 'can_read' },
|
||||
view_menu: { name: 'Dashboard' },
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -297,11 +284,7 @@ describe('RoleListEditModal', () => {
|
||||
mockToasts.addDangerToast.mockClear();
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (
|
||||
endpoint?.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
)
|
||||
) {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
return Promise.reject(new Error('network error'));
|
||||
}
|
||||
if (endpoint?.includes('/api/v1/security/groups/')) {
|
||||
@@ -371,26 +354,24 @@ describe('RoleListEditModal', () => {
|
||||
};
|
||||
|
||||
mockGet.mockImplementation(({ endpoint }) => {
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleA.id}/permissions/`)) {
|
||||
if (endpoint?.includes('/api/v1/security/permissions-resources/')) {
|
||||
const query = rison.decode(endpoint.split('?q=')[1]) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
const filters = query.filters as Array<{
|
||||
col: string;
|
||||
opr: string;
|
||||
value: number[];
|
||||
}>;
|
||||
const ids = filters?.[0]?.value || [];
|
||||
const result = ids.map((id: number) => ({
|
||||
id,
|
||||
permission: { name: `perm_${id}` },
|
||||
view_menu: { name: `view_${id}` },
|
||||
}));
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleA.permission_ids.map(pid => ({
|
||||
id: pid,
|
||||
permission_name: `perm_${pid}`,
|
||||
view_menu_name: `view_${pid}`,
|
||||
})),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (endpoint?.includes(`/api/v1/security/roles/${roleB.id}/permissions/`)) {
|
||||
return Promise.resolve({
|
||||
json: {
|
||||
result: roleB.permission_ids.map(pid => ({
|
||||
id: pid,
|
||||
permission_name: `perm_${pid}`,
|
||||
view_menu_name: `view_${pid}`,
|
||||
})),
|
||||
},
|
||||
json: { count: result.length, result },
|
||||
});
|
||||
}
|
||||
return Promise.resolve({ json: { count: 0, result: [] } });
|
||||
@@ -407,7 +388,7 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const permCall = mockGet.mock.calls.find(([c]) =>
|
||||
c.endpoint.includes(`/api/v1/security/roles/${roleA.id}/permissions/`),
|
||||
c.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
);
|
||||
expect(permCall).toBeTruthy();
|
||||
});
|
||||
@@ -427,16 +408,26 @@ describe('RoleListEditModal', () => {
|
||||
|
||||
await waitFor(() => {
|
||||
const permCalls = mockGet.mock.calls.filter(([c]) =>
|
||||
c.endpoint.includes(`/api/v1/security/roles/${roleB.id}/permissions/`),
|
||||
c.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
);
|
||||
expect(permCalls.length).toBeGreaterThan(0);
|
||||
// Should request role B's IDs, not role A's
|
||||
const query = rison.decode(
|
||||
permCalls[0][0].endpoint.split('?q=')[1],
|
||||
) as Record<string, unknown>;
|
||||
const filters = query.filters as Array<{
|
||||
col: string;
|
||||
opr: string;
|
||||
value: number[];
|
||||
}>;
|
||||
expect(filters[0].value).toEqual(roleB.permission_ids);
|
||||
});
|
||||
|
||||
unmount();
|
||||
mockGet.mockReset();
|
||||
});
|
||||
|
||||
test('fetches permissions via role endpoint and groups by id for hydration', async () => {
|
||||
test('fetches permissions and groups by id for hydration', async () => {
|
||||
const mockGet = SupersetClient.get as jest.Mock;
|
||||
mockGet.mockResolvedValue({
|
||||
json: {
|
||||
@@ -451,11 +442,8 @@ describe('RoleListEditModal', () => {
|
||||
expect(mockGet).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Permissions should be fetched via the role's permissions endpoint (no ID list in URL)
|
||||
const permissionCall = mockGet.mock.calls.find(([call]) =>
|
||||
call.endpoint.includes(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
),
|
||||
call.endpoint.includes('/api/v1/security/permissions-resources/'),
|
||||
)?.[0];
|
||||
const groupsCall = mockGet.mock.calls.find(([call]) =>
|
||||
call.endpoint.includes('/api/v1/security/groups/'),
|
||||
@@ -467,17 +455,26 @@ describe('RoleListEditModal', () => {
|
||||
throw new Error('Expected hydration calls to be defined');
|
||||
}
|
||||
|
||||
// Permission endpoint has no query params (role ID is in the path)
|
||||
expect(permissionCall.endpoint).toBe(
|
||||
`/api/v1/security/roles/${mockRole.id}/permissions/`,
|
||||
);
|
||||
|
||||
// Groups still use the id-in filter
|
||||
const permissionQuery = permissionCall.endpoint.match(/\?q=(.+)/);
|
||||
const groupsQuery = groupsCall.endpoint.match(/\?q=(.+)/);
|
||||
expect(permissionQuery).toBeTruthy();
|
||||
expect(groupsQuery).toBeTruthy();
|
||||
if (!groupsQuery) {
|
||||
throw new Error('Expected groups query params to be present');
|
||||
if (!permissionQuery || !groupsQuery) {
|
||||
throw new Error('Expected query params to be present');
|
||||
}
|
||||
|
||||
expect(rison.decode(permissionQuery[1])).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
filters: [
|
||||
{
|
||||
col: 'id',
|
||||
opr: 'in',
|
||||
value: mockRole.permission_ids,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(rison.decode(groupsQuery[1])).toEqual({
|
||||
page_size: 100,
|
||||
page: 0,
|
||||
|
||||
@@ -30,11 +30,9 @@ import {
|
||||
import {
|
||||
BaseModalProps,
|
||||
RoleForm,
|
||||
RolePermissions,
|
||||
SelectOption,
|
||||
} from 'src/features/roles/types';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { fetchPaginatedData } from 'src/utils/fetchOptions';
|
||||
import { type UserObject } from 'src/pages/UsersList/types';
|
||||
import { ModalTitleWithIcon } from 'src/components/ModalTitleWithIcon';
|
||||
@@ -51,7 +49,6 @@ import {
|
||||
updateRoleUsers,
|
||||
formatPermissionLabel,
|
||||
} from './utils';
|
||||
import { getUserDisplayLabel } from 'src/features/users/utils';
|
||||
|
||||
export interface RoleListEditModalProps extends BaseModalProps {
|
||||
role: RoleObject;
|
||||
@@ -165,38 +162,34 @@ function RoleListEditModal({
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setLoadingRolePermissions(true);
|
||||
permissionFetchSucceeded.current = false;
|
||||
const filters = [{ col: 'id', opr: 'in', value: stablePermissionIds }];
|
||||
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/security/roles/${id}/permissions/`,
|
||||
})
|
||||
.then(response => {
|
||||
if (cancelled) return;
|
||||
fetchPaginatedData({
|
||||
endpoint: `/api/v1/security/permissions-resources/`,
|
||||
pageSize: 100,
|
||||
setData: (data: SelectOption[]) => {
|
||||
permissionFetchSucceeded.current = true;
|
||||
const result: RolePermissions[] = response.json.result ?? [];
|
||||
setRolePermissions(
|
||||
result.map(p => ({
|
||||
value: p.id,
|
||||
label: formatPermissionLabel(p.permission_name, p.view_menu_name),
|
||||
})),
|
||||
);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
addDangerToast(t('There was an error loading permissions.'));
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setLoadingRolePermissions(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
setRolePermissions(data);
|
||||
},
|
||||
filters,
|
||||
setLoadingState: (loading: boolean) => setLoadingRolePermissions(loading),
|
||||
loadingKey: 'rolePermissions',
|
||||
addDangerToast,
|
||||
errorMessage: t('There was an error loading permissions.'),
|
||||
mapResult: (permission: {
|
||||
id: number;
|
||||
permission: { name: string };
|
||||
view_menu: { name: string };
|
||||
}) => ({
|
||||
value: permission.id,
|
||||
label: formatPermissionLabel(
|
||||
permission.permission.name,
|
||||
permission.view_menu.name,
|
||||
),
|
||||
}),
|
||||
});
|
||||
}, [addDangerToast, id, stablePermissionIds]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -233,7 +226,7 @@ function RoleListEditModal({
|
||||
if (!loadingRoleUsers && formRef.current) {
|
||||
const userOptions = roleUsers.map(user => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayLabel(user),
|
||||
label: user.username,
|
||||
}));
|
||||
formRef.current.setFieldsValue({
|
||||
roleUsers: userOptions,
|
||||
@@ -322,7 +315,7 @@ function RoleListEditModal({
|
||||
roleUsers:
|
||||
roleUsers?.map(user => ({
|
||||
value: user.id,
|
||||
label: getUserDisplayLabel(user),
|
||||
label: user.username,
|
||||
})) || [],
|
||||
roleGroups: group_ids.map(groupId => ({
|
||||
value: groupId,
|
||||
|
||||
@@ -44,15 +44,6 @@ export const deleteUser = async (userId: number) =>
|
||||
endpoint: `/api/v1/security/users/${userId}`,
|
||||
});
|
||||
|
||||
export const getUserDisplayLabel = (user: {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
}): string =>
|
||||
[user.first_name, user.last_name].filter(Boolean).join(' ') ||
|
||||
user.username ||
|
||||
t('N/A');
|
||||
|
||||
export const atLeastOneRoleOrGroup =
|
||||
(fieldToCheck: 'roles' | 'groups') =>
|
||||
({
|
||||
|
||||
@@ -31,7 +31,6 @@ 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';
|
||||
@@ -51,7 +50,6 @@ 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'),
|
||||
|
||||
@@ -0,0 +1,407 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// Throwaway debug UI for sc-107283 activity-view endpoints. Verify the
|
||||
// JSON responses look right by eye. Delete this directory + the route
|
||||
// entry in views/routes.tsx when the activity-view feature ships.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
Card,
|
||||
Empty,
|
||||
Input,
|
||||
Loading,
|
||||
Radio,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from '@superset-ui/core/components';
|
||||
|
||||
type ResourceKind = 'dashboard' | 'chart' | 'dataset';
|
||||
type IncludeMode = 'self' | 'related' | 'all';
|
||||
type EntityKind = 'dashboard' | 'chart' | 'dataset';
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface ActivityRecord {
|
||||
version_uuid: string;
|
||||
entity_kind: EntityKind;
|
||||
entity_uuid: string | null;
|
||||
entity_name: string;
|
||||
entity_deleted: boolean;
|
||||
entity_deletion_state: string | null;
|
||||
source: 'self' | 'related';
|
||||
transaction_id: number;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
summary: string;
|
||||
impact: { charts?: number; datasets?: number } | null;
|
||||
}
|
||||
|
||||
interface ActivityResponse {
|
||||
result: ActivityRecord[];
|
||||
count: number;
|
||||
}
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 25, 50, 100, 200];
|
||||
|
||||
const KIND_COLOR: Record<string, string> = {
|
||||
filter: 'blue',
|
||||
metric: 'green',
|
||||
dimension: 'cyan',
|
||||
column: 'geekblue',
|
||||
chart: 'purple',
|
||||
time_range: 'gold',
|
||||
color_palette: 'magenta',
|
||||
restore: 'orange',
|
||||
field: 'default',
|
||||
};
|
||||
|
||||
const ENTITY_KIND_COLOR: Record<EntityKind, string> = {
|
||||
dashboard: 'blue',
|
||||
chart: 'purple',
|
||||
dataset: 'green',
|
||||
};
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === null || value === undefined) return 'null';
|
||||
if (typeof value === 'string') return JSON.stringify(value);
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function ChangedByDisplay({ changedBy }: { changedBy: ChangedBy | null }) {
|
||||
if (changedBy === null) {
|
||||
return (
|
||||
<Typography.Text type="secondary" italic>
|
||||
{t('system / unknown')}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Typography.Text>
|
||||
{changedBy.first_name} {changedBy.last_name} (id={changedBy.id})
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
function RecordCard({ record }: { record: ActivityRecord }) {
|
||||
const headerExtra = (
|
||||
<Space size="small">
|
||||
<Tag color={ENTITY_KIND_COLOR[record.entity_kind] || 'default'}>
|
||||
{record.entity_kind}
|
||||
</Tag>
|
||||
<Tag color={record.source === 'self' ? 'default' : 'volcano'}>
|
||||
{record.source}
|
||||
</Tag>
|
||||
<Tag color={KIND_COLOR[record.kind] || 'default'}>{record.kind}</Tag>
|
||||
{record.entity_deleted && <Tag color="red">deleted</Tag>}
|
||||
{record.entity_deletion_state === 'soft_deleted' && (
|
||||
<Tag color="orange">soft-deleted</Tag>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
|
||||
const headline = record.summary
|
||||
? record.summary
|
||||
: `${record.entity_kind}: ${record.entity_name || '(unnamed)'}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
title={
|
||||
<Space direction="vertical" size={0}>
|
||||
<Typography.Text strong>{headline}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{record.issued_at} · tx={record.transaction_id} ·{' '}
|
||||
<ChangedByDisplay changedBy={record.changed_by} />
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
}
|
||||
extra={headerExtra}
|
||||
style={{ marginBottom: 8 }}
|
||||
>
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">{t('entity_name:')}</Typography.Text>{' '}
|
||||
<Typography.Text
|
||||
delete={record.entity_deleted}
|
||||
italic={!record.entity_name}
|
||||
>
|
||||
{record.entity_name || t('(no name)')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">{t('entity_uuid:')}</Typography.Text>{' '}
|
||||
<Typography.Text code>
|
||||
{record.entity_uuid || t('null (tombstoned)')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">
|
||||
{t('version_uuid:')}
|
||||
</Typography.Text>{' '}
|
||||
<Typography.Text code>
|
||||
{record.version_uuid || t('null')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">{t('path:')}</Typography.Text>{' '}
|
||||
<Typography.Text code>{JSON.stringify(record.path)}</Typography.Text>
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">{t('from →')}</Typography.Text>{' '}
|
||||
<Typography.Text code>{formatValue(record.from_value)}</Typography.Text>{' '}
|
||||
<Typography.Text type="secondary">{t('→ to')}</Typography.Text>{' '}
|
||||
<Typography.Text code>{formatValue(record.to_value)}</Typography.Text>
|
||||
</div>
|
||||
{record.impact !== null && (
|
||||
<div>
|
||||
<Typography.Text type="secondary">{t('impact:')}</Typography.Text>{' '}
|
||||
<Typography.Text code>
|
||||
{JSON.stringify(record.impact)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function ActivityDebug() {
|
||||
const { resource: resourceParam, uuid } = useParams<{
|
||||
resource: string;
|
||||
uuid: string;
|
||||
}>();
|
||||
|
||||
const resource = useMemo<ResourceKind | null>(() => {
|
||||
if (
|
||||
resourceParam === 'dashboard' ||
|
||||
resourceParam === 'chart' ||
|
||||
resourceParam === 'dataset'
|
||||
) {
|
||||
return resourceParam;
|
||||
}
|
||||
return null;
|
||||
}, [resourceParam]);
|
||||
|
||||
const [include, setInclude] = useState<IncludeMode>('all');
|
||||
const [page, setPage] = useState(0);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [since, setSince] = useState('');
|
||||
const [until, setUntil] = useState('');
|
||||
const [data, setData] = useState<ActivityResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [reloadCounter, setReloadCounter] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!resource || !uuid) return;
|
||||
const params = new URLSearchParams();
|
||||
params.set('include', include);
|
||||
params.set('page', String(page));
|
||||
params.set('page_size', String(pageSize));
|
||||
if (since) params.set('since', since);
|
||||
if (until) params.set('until', until);
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
SupersetClient.get({
|
||||
endpoint: `/api/v1/${resource}/${uuid}/activity/?${params.toString()}`,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setData(json as ActivityResponse);
|
||||
})
|
||||
.catch(err => {
|
||||
const msg = err?.message || String(err);
|
||||
setError(msg);
|
||||
setData(null);
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, [resource, uuid, include, page, pageSize, since, until, reloadCounter]);
|
||||
|
||||
if (!resource) {
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Typography.Title level={3}>
|
||||
{t('Activity Debug — invalid URL')}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph>
|
||||
{t(
|
||||
'Use /activity-debug/{dashboard|chart|dataset}/{uuid} — e.g. /activity-debug/dashboard/4a8f3c2e-...',
|
||||
)}
|
||||
</Typography.Paragraph>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const records = data?.result ?? [];
|
||||
const totalCount = data?.count ?? 0;
|
||||
const lastPage = Math.max(0, Math.ceil(totalCount / pageSize) - 1);
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<Typography.Title level={3} style={{ marginBottom: 4 }}>
|
||||
{t('Activity Debug')}
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('Throwaway tool for verifying sc-107283 activity-view responses.')}
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 16 }}>
|
||||
<Space direction="vertical" size={12} style={{ width: '100%' }}>
|
||||
<Space wrap>
|
||||
<Typography.Text strong>{t('endpoint')}</Typography.Text>
|
||||
<Typography.Text code>
|
||||
{`GET /api/v1/${resource}/${uuid}/activity/`}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Space wrap size="middle">
|
||||
<Space>
|
||||
<Typography.Text>include</Typography.Text>
|
||||
<Radio.Group
|
||||
value={include}
|
||||
onChange={e => {
|
||||
setInclude(e.target.value as IncludeMode);
|
||||
setPage(0);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
<Radio.Button value="all">all</Radio.Button>
|
||||
<Radio.Button value="self">self</Radio.Button>
|
||||
<Radio.Button value="related">related</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Typography.Text>page_size</Typography.Text>
|
||||
<select
|
||||
aria-label={t('page_size')}
|
||||
value={pageSize}
|
||||
onChange={e => {
|
||||
setPageSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
{PAGE_SIZE_OPTIONS.map(n => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Typography.Text>since</Typography.Text>
|
||||
<Input
|
||||
placeholder={t('ISO 8601')}
|
||||
size="small"
|
||||
value={since}
|
||||
onChange={e => {
|
||||
setSince(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<Space>
|
||||
<Typography.Text>until</Typography.Text>
|
||||
<Input
|
||||
placeholder={t('ISO 8601')}
|
||||
size="small"
|
||||
value={until}
|
||||
onChange={e => {
|
||||
setUntil(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
style={{ width: 220 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setReloadCounter(c => c + 1)}
|
||||
style={{ padding: '4px 12px' }}
|
||||
>
|
||||
{t('reload')}
|
||||
</button>
|
||||
</Space>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Typography.Text>
|
||||
{t('count')}: <Typography.Text strong>{totalCount}</Typography.Text>
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
{t('page')}: {page} / {lastPage}
|
||||
</Typography.Text>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(p => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
>
|
||||
{t('← prev')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
disabled={page >= lastPage}
|
||||
>
|
||||
{t('next →')}
|
||||
</button>
|
||||
</Space>
|
||||
|
||||
{loading && <Loading />}
|
||||
|
||||
{error && (
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Typography.Text type="danger" strong>
|
||||
{t('error')}:
|
||||
</Typography.Text>{' '}
|
||||
<Typography.Text type="danger">{error}</Typography.Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!loading && !error && records.length === 0 && (
|
||||
<Empty description={t('No activity records for this page.')} />
|
||||
)}
|
||||
|
||||
{records.map(record => (
|
||||
<RecordCard
|
||||
key={`${record.transaction_id}-${record.entity_kind}-${record.entity_uuid}-${record.kind}-${record.path.join('.')}`}
|
||||
record={record}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityDebug;
|
||||
@@ -57,12 +57,9 @@ const mockUser = {
|
||||
const findFilterByLabel = (labelText: string) => {
|
||||
const containers = screen.getAllByTestId('select-filter-container');
|
||||
for (const container of containers) {
|
||||
// Compact pill filters show the label as button text
|
||||
const pill = container.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement | null;
|
||||
if (pill && pill.textContent?.includes(labelText)) {
|
||||
return pill;
|
||||
const label = container.querySelector('label');
|
||||
if (label?.textContent === labelText) {
|
||||
return container.querySelector('[role="combobox"], .ant-select');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a chart and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
chartUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id]. Same shape across the three
|
||||
// debug widgets so chart/dataset dropdowns also recognise them — even
|
||||
// though they don't normally produce layout records, the formatter
|
||||
// stays uniform.
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
chartUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/chart/${chartUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [chartUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this chart to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/chart/${chartUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restored. Reload the page to see the change.'));
|
||||
if (onRestored) onRestored();
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[chartUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
// versions is already newest-first, so [0] is the live/current version.
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
// antd's `disabled: true` greys the item and blocks default
|
||||
// click handling; combined with the inner div NOT having an
|
||||
// onClick when current, the row becomes informational only.
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -84,6 +84,8 @@ import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import { Tag } from 'src/components/Tag';
|
||||
import { datasetLabel } from 'src/features/semanticLayers/label';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const FlexRowContainer = styled.div`
|
||||
align-items: center;
|
||||
@@ -576,6 +578,13 @@ function ChartList(props: ChartListProps) {
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
chartUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</StyledActions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -156,16 +156,18 @@ describe('DashboardList Card View Tests', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the sort select by its testId, then the pill button within it
|
||||
// Find the sort select by its testId, then the combobox within it
|
||||
const sortContainer = screen.getByTestId('card-sort-select');
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
const sortPill = sortContainer.querySelector(
|
||||
'[data-test="compact-filter-pill"]',
|
||||
) as HTMLElement;
|
||||
await userEvent.click(sortPill);
|
||||
const sortCombobox = within(sortContainer).getByRole('combobox');
|
||||
await userEvent.click(sortCombobox);
|
||||
|
||||
// Select "Alphabetical" from the dropdown
|
||||
const alphabeticalOption = await screen.findByText('Alphabetical');
|
||||
const alphabeticalOption = await waitFor(() =>
|
||||
within(
|
||||
// eslint-disable-next-line testing-library/no-node-access
|
||||
document.querySelector('.rc-virtual-list')!,
|
||||
).getByText('Alphabetical'),
|
||||
);
|
||||
await userEvent.click(alphabeticalOption);
|
||||
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -20,7 +20,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
screen,
|
||||
selectPillOption,
|
||||
selectOption,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
@@ -88,7 +88,7 @@ test('fetches data', async () => {
|
||||
|
||||
const calls = fetchMock.callHistory.calls(/dashboard\/\?q/);
|
||||
expect(calls[0].url).toMatchInlineSnapshot(
|
||||
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`,
|
||||
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,uuid,dashboard_title,published,url,slug,changed_by,changed_by.id,changed_by.first_name,changed_by.last_name,changed_on_delta_humanized,owners,owners.id,owners.first_name,owners.last_name,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -200,7 +200,7 @@ test('selecting Status filter encodes published=true in API call', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectPillOption('Published', 'Status');
|
||||
await selectOption('Published', 'Status');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
@@ -242,7 +242,7 @@ test('selecting Owner filter encodes rel_m_m owner in API call', async () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectPillOption('Admin User', 'Owner');
|
||||
await selectOption('Admin User', 'Owner');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
@@ -287,7 +287,7 @@ test('selecting Modified by filter encodes rel_o_m changed_by in API call', asyn
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await selectPillOption('Admin User', 'Modified by');
|
||||
await selectOption('Admin User', 'Modified by');
|
||||
|
||||
await waitFor(() => {
|
||||
const latest = getLatestDashboardApiCall();
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a dashboard and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
dashboardUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id].
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
// Layout record (dashboard): path = [verb, kind, id], with payload
|
||||
// carrying ``name`` / ``chartId`` etc.
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
// Dataset child: path = [columns | metrics, <name>]. ``kind`` is
|
||||
// ``column`` / ``metric`` so we can rebuild a readable summary.
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
// Slice membership (mostly folded into layout records server-side,
|
||||
// but may still appear if the layout walk didn't catch a chart).
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
// Scalar field record: path = [field_name] or [json_field, sub_key].
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
// Friendly labels for the most user-visible fields.
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
// If the new value is a short primitive (string/number/bool), show
|
||||
// "Changed <field> to <value>" — much more useful than just naming
|
||||
// the field. Long strings, dicts and arrays fall through to the
|
||||
// generic verb-only summary.
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
// Fallback: kind plus the trailing path segment (if any).
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
dashboardUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [dashboardUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this dashboard to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/dashboard/${dashboardUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
onRestored?.();
|
||||
// Navigate to the dashboard with no URL params. A previous
|
||||
// ``?native_filters_key=…`` (or ``permalink_key`` / ``form_data_key``)
|
||||
// points at a server-cached snapshot from before the restore;
|
||||
// the next page hydration would merge it on top of the freshly
|
||||
// restored ``json_metadata`` and effectively mask the rollback
|
||||
// (e.g. dashboard-level colour scheme changes don't appear).
|
||||
// A clean URL forces hydration from the restored DB state.
|
||||
window.location.href = `/superset/dashboard/${dashboardUuid}/`;
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[dashboardUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
// versions is already newest-first, so [0] is the live/current version.
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
// antd's `disabled: true` greys the item and blocks default
|
||||
// click handling; combined with the inner div NOT having an
|
||||
// onClick when current, the row becomes informational only.
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -77,6 +77,8 @@ import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { navigateTo } from 'src/utils/navigationUtils';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PASSWORDS_NEEDED_MESSAGE = t(
|
||||
@@ -122,6 +124,10 @@ const Actions = styled.div`
|
||||
|
||||
const DASHBOARD_COLUMNS_TO_FETCH = [
|
||||
'id',
|
||||
// TEMP: sc-103156 versioning demo. The version-history dropdown
|
||||
// calls /api/v1/dashboard/<uuid>/versions/, so the row needs `uuid`.
|
||||
// Revert this entry along with the dropdown component.
|
||||
'uuid',
|
||||
'dashboard_title',
|
||||
'published',
|
||||
'url',
|
||||
@@ -504,6 +510,13 @@ function DashboardList(props: DashboardListProps) {
|
||||
)}
|
||||
</ConfirmStatusChange>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
dashboardUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -20,7 +20,7 @@ import { act, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { selectPillOption } from 'spec/helpers/testing-library';
|
||||
import { selectOption } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
setupMocks,
|
||||
renderDatasetList,
|
||||
@@ -102,11 +102,11 @@ test('ListView provider correctly merges filter + sort + pagination state on ref
|
||||
).toBeGreaterThan(callsBeforeSort);
|
||||
});
|
||||
|
||||
// 2. Apply a filter using selectPillOption helper (compact pill UI)
|
||||
// 2. Apply a filter using selectOption helper
|
||||
const beforeFilterCallCount = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
await selectOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -27,7 +27,7 @@ import userEvent from '@testing-library/user-event';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { selectPillOption } from 'spec/helpers/testing-library';
|
||||
import { selectOption } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
setupMocks,
|
||||
renderDatasetList,
|
||||
@@ -1510,8 +1510,11 @@ test('bulk selection clears when filter changes', async () => {
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply a filter using selectPillOption helper (compact pill UI)
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
// Wait for filter combobox to be ready before applying filter
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Apply a filter using selectOption helper
|
||||
await selectOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
@@ -1553,13 +1556,16 @@ test('type filter API call includes correct filter parameter', async () => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for Type filter combobox
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter using compact pill UI
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
// Apply Type filter
|
||||
await selectOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
@@ -1600,13 +1606,16 @@ test('type filter persists after duplicating a dataset', async () => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for Type filter combobox
|
||||
await screen.findByRole('combobox', { name: 'Type' });
|
||||
|
||||
// Snapshot call count before filter
|
||||
const callsBeforeFilter = fetchMock.callHistory.calls(
|
||||
API_ENDPOINTS.DATASOURCE_COMBINED,
|
||||
).length;
|
||||
|
||||
// Apply Type filter using compact pill UI
|
||||
await selectPillOption('Virtual', 'Type');
|
||||
// Apply Type filter
|
||||
await selectOption('Virtual', 'Type');
|
||||
|
||||
// Wait for filter API call to complete
|
||||
await waitFor(() => {
|
||||
|
||||
@@ -200,8 +200,8 @@ test('renders Name search filter', async () => {
|
||||
test('renders Type filter (Virtual/Physical dropdown)', async () => {
|
||||
renderDatasetList(mockAdminUser);
|
||||
|
||||
// Filter pills should be present (compact pill UI)
|
||||
const filters = await screen.findAllByTestId('compact-filter-pill');
|
||||
// Filter dropdowns should be present
|
||||
const filters = await screen.findAllByRole('combobox');
|
||||
expect(filters.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
@@ -445,8 +445,7 @@ test('selecting Database filter triggers API call with database relation filter'
|
||||
|
||||
await waitForDatasetsPageReady();
|
||||
|
||||
// Filter pills should be present (compact pill UI replaces comboboxes)
|
||||
const filtersContainers = screen.getAllByTestId('compact-filter-pill');
|
||||
const filtersContainers = screen.getAllByRole('combobox');
|
||||
expect(filtersContainers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
// TEMP: Demo aid for sc-103156 entity-versioning. Lets a user open a
|
||||
// dropdown of recent versions on a dataset and restore one. Not part
|
||||
// of the merged feature scope (ADR-005 limits v1 to backend); revert
|
||||
// before pushing the versioning branch.
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
|
||||
|
||||
interface Change {
|
||||
kind: string;
|
||||
path: string[];
|
||||
from_value: unknown;
|
||||
to_value: unknown;
|
||||
}
|
||||
|
||||
interface ChangedBy {
|
||||
id: number;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
}
|
||||
|
||||
interface Version {
|
||||
version_uuid: string;
|
||||
version_number: number;
|
||||
transaction_id: number;
|
||||
operation_type: string;
|
||||
issued_at: string;
|
||||
changed_by: ChangedBy | null;
|
||||
changes: Change[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
datasetUuid: string;
|
||||
onRestored?: () => void;
|
||||
}
|
||||
|
||||
// Layout-record path verbs (set by ``diff_dashboard_layout`` on the
|
||||
// backend): path = [verb, kind, id]. Same shape across the three
|
||||
// debug widgets so chart/dataset dropdowns also recognise them — even
|
||||
// though they don't normally produce layout records, the formatter
|
||||
// stays uniform.
|
||||
const LAYOUT_VERBS = new Set(['add', 'remove', 'move', 'edit']);
|
||||
|
||||
// Localized labels for the kinds emitted by the backend (layout walker
|
||||
// + dataset child diff). Defined statically so xgettext can extract them.
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
chart: t('chart'),
|
||||
row: t('row'),
|
||||
column: t('column'),
|
||||
tab: t('tab'),
|
||||
tabs: t('tabs'),
|
||||
header: t('header'),
|
||||
markdown: t('markdown'),
|
||||
divider: t('divider'),
|
||||
metric: t('metric'),
|
||||
};
|
||||
const localizedKind = (k: string): string => KIND_LABELS[k] ?? k;
|
||||
|
||||
function summarizeChange(c: Change): string {
|
||||
if (c.path.length === 3 && LAYOUT_VERBS.has(String(c.path[0]))) {
|
||||
const verb = String(c.path[0]);
|
||||
const kind = localizedKind(String(c.path[1]));
|
||||
const payload =
|
||||
((c.to_value ?? c.from_value) as { name?: string } | null) ?? null;
|
||||
const name = payload?.name;
|
||||
if (verb === 'add') {
|
||||
return name
|
||||
? t('Added %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Added %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'remove') {
|
||||
return name
|
||||
? t('Removed %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Removed %(kind)s', { kind });
|
||||
}
|
||||
if (verb === 'move') {
|
||||
return name
|
||||
? t('Moved %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Moved %(kind)s', { kind });
|
||||
}
|
||||
return name
|
||||
? t('Edited %(kind)s "%(name)s"', { kind, name })
|
||||
: t('Edited %(kind)s', { kind });
|
||||
}
|
||||
|
||||
const isAdd = c.from_value == null && c.to_value != null;
|
||||
const isRemove = c.from_value != null && c.to_value == null;
|
||||
|
||||
if (c.path.length === 2 && (c.kind === 'column' || c.kind === 'metric')) {
|
||||
const kind = localizedKind(c.kind);
|
||||
const name = String(c.path[1]);
|
||||
if (isAdd) return t('Added %(kind)s "%(name)s"', { kind, name });
|
||||
if (isRemove) return t('Removed %(kind)s "%(name)s"', { kind, name });
|
||||
return t('Changed %(kind)s "%(name)s"', { kind, name });
|
||||
}
|
||||
|
||||
if (c.path[0] === 'slices') {
|
||||
const id = String(c.path[1] ?? '');
|
||||
if (isAdd) return t('Added chart %(id)s', { id }).trim();
|
||||
if (isRemove) return t('Removed chart %(id)s', { id }).trim();
|
||||
return t('Changed chart %(id)s', { id }).trim();
|
||||
}
|
||||
|
||||
if (c.kind === 'field') {
|
||||
const fieldName = String(c.path[c.path.length - 1]);
|
||||
const fieldLabel: string =
|
||||
fieldName === 'dashboard_title'
|
||||
? t('title')
|
||||
: fieldName === 'slice_name'
|
||||
? t('chart name')
|
||||
: fieldName === 'table_name'
|
||||
? t('table name')
|
||||
: fieldName;
|
||||
const isShortScalar =
|
||||
c.to_value !== null &&
|
||||
c.to_value !== undefined &&
|
||||
(typeof c.to_value === 'string' ||
|
||||
typeof c.to_value === 'number' ||
|
||||
typeof c.to_value === 'boolean') &&
|
||||
String(c.to_value).length <= 80;
|
||||
if (!isAdd && !isRemove && isShortScalar) {
|
||||
return t('Changed %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isRemove) {
|
||||
return t('Cleared %(field)s', { field: fieldLabel });
|
||||
}
|
||||
if (isAdd && isShortScalar) {
|
||||
return t('Set %(field)s to "%(value)s"', {
|
||||
field: fieldLabel,
|
||||
value: String(c.to_value),
|
||||
});
|
||||
}
|
||||
if (isAdd) return t('Added %(field)s', { field: fieldLabel });
|
||||
if (isRemove) return t('Removed %(field)s', { field: fieldLabel });
|
||||
return t('Changed %(field)s', { field: fieldLabel });
|
||||
}
|
||||
|
||||
const kind = localizedKind(c.kind);
|
||||
if (c.path.length) {
|
||||
const detail = String(c.path[c.path.length - 1]);
|
||||
if (isAdd) return t('Added %(kind)s %(detail)s', { kind, detail });
|
||||
if (isRemove) return t('Removed %(kind)s %(detail)s', { kind, detail });
|
||||
return t('Changed %(kind)s %(detail)s', { kind, detail });
|
||||
}
|
||||
if (isAdd) return t('Added %(kind)s', { kind });
|
||||
if (isRemove) return t('Removed %(kind)s', { kind });
|
||||
return t('Changed %(kind)s', { kind });
|
||||
}
|
||||
|
||||
function formatChangeTitle(changes: Change[]): string {
|
||||
if (!changes.length) return t('Baseline');
|
||||
const first = summarizeChange(changes[0]);
|
||||
if (changes.length === 1) return first;
|
||||
return t('%(first)s (+%(more)s more)', {
|
||||
first,
|
||||
more: changes.length - 1,
|
||||
});
|
||||
}
|
||||
|
||||
function formatUser(by: ChangedBy | null): string {
|
||||
if (!by) return t('system');
|
||||
if (by.first_name || by.last_name) {
|
||||
return `${by.first_name ?? ''} ${by.last_name ?? ''}`.trim();
|
||||
}
|
||||
return by.username;
|
||||
}
|
||||
|
||||
function formatDate(iso: string): string {
|
||||
try {
|
||||
// Match the Superset locale set in src/views/App.tsx on
|
||||
// ``document.documentElement.lang`` rather than the browser default.
|
||||
const lang = document.documentElement.lang || undefined;
|
||||
return new Date(iso).toLocaleString(lang);
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
export default function VersionHistoryDropdown({
|
||||
datasetUuid,
|
||||
onRestored,
|
||||
}: Props) {
|
||||
const [versions, setVersions] = useState<Version[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const loadVersions = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { json } = await SupersetClient.get({
|
||||
endpoint: `/api/v1/dataset/${datasetUuid}/versions/`,
|
||||
});
|
||||
const result = (json as { result: Version[] }).result || [];
|
||||
// Newest first (API returns oldest-first)
|
||||
setVersions([...result].reverse().slice(0, 20));
|
||||
} catch (e) {
|
||||
console.error('Failed to load versions', e);
|
||||
setVersions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [datasetUuid]);
|
||||
|
||||
const handleRestore = useCallback(
|
||||
async (version: Version) => {
|
||||
const summary = formatChangeTitle(version.changes);
|
||||
if (
|
||||
// eslint-disable-next-line no-alert
|
||||
!window.confirm(
|
||||
t(
|
||||
'Restore this dataset to version %(num)s (%(summary)s)? This will overwrite the current state.',
|
||||
{ num: version.version_number, summary },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await SupersetClient.post({
|
||||
endpoint: `/api/v1/dataset/${datasetUuid}/versions/${version.version_uuid}/restore`,
|
||||
});
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restored. Reload the page to see the change.'));
|
||||
if (onRestored) onRestored();
|
||||
} catch (e) {
|
||||
console.error('Restore failed', e);
|
||||
// eslint-disable-next-line no-alert
|
||||
window.alert(t('Restore failed — see browser console for details.'));
|
||||
}
|
||||
},
|
||||
[datasetUuid, onRestored],
|
||||
);
|
||||
|
||||
const items = (() => {
|
||||
if (loading) {
|
||||
return [{ key: 'loading', label: t('Loading…'), disabled: true }];
|
||||
}
|
||||
if (!versions) {
|
||||
return [
|
||||
{ key: 'empty', label: t('Click to load versions'), disabled: true },
|
||||
];
|
||||
}
|
||||
if (versions.length === 0) {
|
||||
return [{ key: 'empty', label: t('No versions yet'), disabled: true }];
|
||||
}
|
||||
return versions.map((v, idx) => {
|
||||
const isCurrent = idx === 0;
|
||||
return {
|
||||
key: String(v.transaction_id),
|
||||
disabled: isCurrent,
|
||||
label: (
|
||||
<div
|
||||
style={{ minWidth: 280, lineHeight: 1.4, padding: '4px 0' }}
|
||||
onClick={isCurrent ? undefined : () => handleRestore(v)}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>
|
||||
#{v.version_number} — {formatChangeTitle(v.changes)}
|
||||
{isCurrent && (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 8,
|
||||
fontWeight: 400,
|
||||
fontSize: 12,
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
{t('(current)')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, opacity: 0.75 }}>
|
||||
{formatUser(v.changed_by)} · {formatDate(v.issued_at)}
|
||||
</div>
|
||||
{v.changes.length > 1 && (
|
||||
<ul
|
||||
style={{
|
||||
margin: '4px 0 0 18px',
|
||||
padding: 0,
|
||||
fontSize: 12,
|
||||
opacity: 0.85,
|
||||
listStyle: 'disc',
|
||||
}}
|
||||
>
|
||||
{v.changes.slice(0, 5).map((c, i) => (
|
||||
<li key={i}>{summarizeChange(c)}</li>
|
||||
))}
|
||||
{v.changes.length > 5 && (
|
||||
<li style={{ opacity: 0.6 }}>
|
||||
{t('+%(n)s more', { n: v.changes.length - 5 })}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
};
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
onOpenChange={open => {
|
||||
if (open && versions === null && !loading) loadVersions();
|
||||
}}
|
||||
>
|
||||
<Tooltip
|
||||
id="version-history-tooltip"
|
||||
title={t('Version history (demo)')}
|
||||
placement="bottom"
|
||||
>
|
||||
<span role="button" tabIndex={0} className="action-button">
|
||||
<Icons.HistoryOutlined iconSize="l" />
|
||||
</span>
|
||||
</Tooltip>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
@@ -99,6 +99,8 @@ import { useSelector } from 'react-redux';
|
||||
import { QueryObjectColumns } from 'src/views/CRUD/types';
|
||||
import { WIDER_DROPDOWN_WIDTH } from 'src/components/ListView/utils';
|
||||
import type { BootstrapData } from 'src/types/bootstrapTypes';
|
||||
// TEMP: sc-103156 versioning demo. Revert before any commit.
|
||||
import VersionHistoryDropdown from './VersionHistoryDropdown';
|
||||
|
||||
const SEMANTIC_LAYERS_FLAG = 'SEMANTIC_LAYERS' as FeatureFlag;
|
||||
type DatasetExtra = {
|
||||
@@ -165,6 +167,7 @@ type Dataset = {
|
||||
source_type?: 'database' | 'semantic_layer';
|
||||
explore_url: string;
|
||||
id: number;
|
||||
uuid?: string;
|
||||
owners: Array<Owner>;
|
||||
schema: string | null;
|
||||
table_name: string;
|
||||
@@ -936,6 +939,13 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* TEMP: sc-103156 versioning demo. Revert before any commit. */}
|
||||
{original.uuid && canEdit && (
|
||||
<VersionHistoryDropdown
|
||||
datasetUuid={original.uuid}
|
||||
onRestored={() => refreshData()}
|
||||
/>
|
||||
)}
|
||||
</Actions>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -121,17 +121,13 @@ describe('GroupsList', () => {
|
||||
|
||||
test('renders the filters correctly', async () => {
|
||||
await renderComponent();
|
||||
const filtersSelect = screen.getAllByTestId('filters-select')[0];
|
||||
|
||||
// The compact filter UI renders the first search filter as an input,
|
||||
// and select filters as pill buttons. Only "Name" search renders inline;
|
||||
// "Label" and "Description" searches are hidden (one search box per page).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
|
||||
// Select filters render as compact pill buttons
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const pillLabels = pills.map(p => p.textContent ?? '');
|
||||
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
|
||||
expect(pillLabels.some(l => /users/i.test(l))).toBe(true);
|
||||
expect(within(filtersSelect).getByText(/name/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/label/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/description/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(filtersSelect).getByText(/users/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders correct columns in the table', async () => {
|
||||
|
||||
@@ -151,11 +151,8 @@ describe('RolesList', () => {
|
||||
test('renders filters options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
// Compact filter UI: one search input for "Name" and 3 select pills
|
||||
// (Users, Permissions, Groups).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
const selectContainers = screen.getAllByTestId('select-filter-container');
|
||||
expect(selectContainers).toHaveLength(3);
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -166,14 +166,11 @@ describe('RuleList RTL', () => {
|
||||
test('renders filter options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
// Compact filter UI: only the first search filter renders (Name),
|
||||
// subsequent search filters (Group Key) are hidden — one search box per page.
|
||||
const searchFilters = screen.queryAllByTestId('filters-search');
|
||||
expect(searchFilters).toHaveLength(1);
|
||||
expect(searchFilters).toHaveLength(2);
|
||||
|
||||
// Select filters render as compact pill buttons (Filter Type, Modified by)
|
||||
const selectContainers = screen.queryAllByTestId('select-filter-container');
|
||||
expect(selectContainers).toHaveLength(2);
|
||||
const typeFilter = screen.queryAllByTestId('filters-select');
|
||||
expect(typeFilter).toHaveLength(3); // Update to expect 3 select filters
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -138,16 +138,16 @@ describe('UsersList', () => {
|
||||
test('renders filters options', async () => {
|
||||
await renderAndWait();
|
||||
|
||||
// The compact filter UI shows: only the first search filter as an input,
|
||||
// and select/datetime filters as pill buttons. Only "First name" search
|
||||
// renders (subsequent search filters are hidden — one search box per page).
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
|
||||
// Select and datetime filters render as compact pill buttons
|
||||
const pills = screen.getAllByTestId('compact-filter-pill');
|
||||
const pillLabels = pills.map(p => p.textContent ?? '');
|
||||
expect(pillLabels.some(l => /roles/i.test(l))).toBe(true);
|
||||
expect(pillLabels.some(l => /is active\?/i.test(l))).toBe(true);
|
||||
const submenu = screen.queryAllByTestId('filters-select')[0];
|
||||
expect(within(submenu).getByText(/first name/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/last name/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/email/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/username/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/roles/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/is active?/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/created on/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/changed on/i)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(/last login/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders correct list columns', async () => {
|
||||
|
||||
@@ -96,21 +96,16 @@ export const fetchPaginatedData = async ({
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(totalItems / pageSize);
|
||||
const concurrencyLimit = 5;
|
||||
const allResults = [...firstPageResults];
|
||||
|
||||
for (let batch = 1; batch < totalPages; batch += concurrencyLimit) {
|
||||
const batchEnd = Math.min(batch + concurrencyLimit, totalPages);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const batchResults = await Promise.all(
|
||||
Array.from({ length: batchEnd - batch }, (_, i) =>
|
||||
fetchPage(batch + i),
|
||||
),
|
||||
);
|
||||
allResults.push(...batchResults.flatMap(res => res.results));
|
||||
}
|
||||
const requests = Array.from({ length: totalPages - 1 }, (_, i) =>
|
||||
fetchPage(i + 1),
|
||||
);
|
||||
const remainingResults = await Promise.all(requests);
|
||||
|
||||
setData(allResults);
|
||||
setData([
|
||||
...firstPageResults,
|
||||
...remainingResults.flatMap(res => res.results),
|
||||
]);
|
||||
} catch (err) {
|
||||
addDangerToast(t(errorMessage));
|
||||
} finally {
|
||||
|
||||
@@ -74,6 +74,14 @@ const DashboardList = lazy(
|
||||
import(/* webpackChunkName: "DashboardList" */ 'src/pages/DashboardList'),
|
||||
);
|
||||
|
||||
// Throwaway: sc-107283 activity-view debug UI. Delete this lazy import +
|
||||
// the route below + the src/pages/ActivityDebug directory when the
|
||||
// feature ships.
|
||||
const ActivityDebug = lazy(
|
||||
() =>
|
||||
import(/* webpackChunkName: "ActivityDebug" */ 'src/pages/ActivityDebug'),
|
||||
);
|
||||
|
||||
const Dashboard = lazy(
|
||||
() => import(/* webpackChunkName: "Dashboard" */ 'src/pages/Dashboard'),
|
||||
);
|
||||
@@ -227,6 +235,11 @@ export const routes: Routes = [
|
||||
path: '/dashboard/list/',
|
||||
Component: DashboardList,
|
||||
},
|
||||
// Throwaway: sc-107283 activity-view debug UI.
|
||||
{
|
||||
path: '/activity-debug/:resource/:uuid',
|
||||
Component: ActivityDebug,
|
||||
},
|
||||
{
|
||||
path: '/superset/dashboard/:idOrSlug/',
|
||||
Component: Dashboard,
|
||||
|
||||
Generated
+7
-7
@@ -15,7 +15,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
@@ -6428,9 +6428,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
@@ -11207,9 +11207,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"ws": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
|
||||
"integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
|
||||
"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==",
|
||||
"requires": {}
|
||||
},
|
||||
"y18n": {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"lodash": "^4.18.1",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.0"
|
||||
"ws": "^8.20.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.1",
|
||||
|
||||
+397
-2
@@ -130,6 +130,10 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
"screenshot",
|
||||
"cache_screenshot",
|
||||
"warm_up_cache",
|
||||
"list_versions",
|
||||
"get_version",
|
||||
"restore_version",
|
||||
"activity",
|
||||
}
|
||||
class_permission_name = "Chart"
|
||||
method_permission_name = MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
@@ -308,7 +312,13 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
try:
|
||||
dash = ChartDAO.get_by_id_or_uuid(id_or_uuid)
|
||||
result = self.chart_get_response_schema.dump(dash)
|
||||
return self.response(200, result=result)
|
||||
from superset.daos.version import VersionDAO
|
||||
from superset.versioning.etag import set_version_etag
|
||||
|
||||
return set_version_etag(
|
||||
self.response(200, result=result),
|
||||
VersionDAO.current_live_version_uuid(Slice, dash.id, dash.uuid),
|
||||
)
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
|
||||
@@ -415,6 +425,34 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
type: number
|
||||
result:
|
||||
$ref: '#/components/schemas/{{self.__class__.__name__}}.put'
|
||||
old_version:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: >-
|
||||
0-based version_number of the live row before this
|
||||
update. Unstable under retention pruning — see
|
||||
old_transaction_id for a stable identifier.
|
||||
new_version:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: >-
|
||||
0-based version_number of the newly-live row after
|
||||
this update. Can equal old_version when no
|
||||
versioned column changed, or when retention
|
||||
pruning dropped an older closed row in the same
|
||||
commit.
|
||||
old_transaction_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Continuum transaction_id of the live
|
||||
row before this update. Stable across pruning.
|
||||
new_transaction_id:
|
||||
type: integer
|
||||
nullable: true
|
||||
description: Continuum transaction_id of the live
|
||||
row after this update. Differs from
|
||||
old_transaction_id when the update produced a new
|
||||
version row.
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
@@ -433,9 +471,43 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
# This validates custom Schema with custom validations
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.daos.version import VersionDAO
|
||||
from superset.extensions import db as _db
|
||||
|
||||
pre_chart = _db.session.query(Slice).filter(Slice.id == pk).one_or_none()
|
||||
old_version = VersionDAO.current_version_number(Slice, pk)
|
||||
old_transaction_id = VersionDAO.current_live_transaction_id(Slice, pk)
|
||||
old_version_uuid = (
|
||||
VersionDAO.current_live_version_uuid(Slice, pk, pre_chart.uuid)
|
||||
if pre_chart is not None
|
||||
else None
|
||||
)
|
||||
|
||||
try:
|
||||
changed_model = UpdateChartCommand(pk, item).run()
|
||||
response = self.response(200, id=changed_model.id, result=item)
|
||||
new_version = VersionDAO.current_version_number(Slice, changed_model.id)
|
||||
new_transaction_id = VersionDAO.current_live_transaction_id(
|
||||
Slice, changed_model.id
|
||||
)
|
||||
new_version_uuid = VersionDAO.current_live_version_uuid(
|
||||
Slice, changed_model.id, changed_model.uuid
|
||||
)
|
||||
response = self.response(
|
||||
200,
|
||||
id=changed_model.id,
|
||||
result=item,
|
||||
old_version=old_version,
|
||||
new_version=new_version,
|
||||
old_transaction_id=old_transaction_id,
|
||||
new_transaction_id=new_transaction_id,
|
||||
old_version_uuid=str(old_version_uuid) if old_version_uuid else None,
|
||||
new_version_uuid=str(new_version_uuid) if new_version_uuid else None,
|
||||
)
|
||||
from superset.versioning.etag import set_version_etag
|
||||
|
||||
set_version_etag(response, new_version_uuid)
|
||||
except ChartNotFoundError:
|
||||
response = self.response_404()
|
||||
except ChartForbiddenError:
|
||||
@@ -1199,3 +1271,326 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
)
|
||||
command.run()
|
||||
return self.response(200, message="OK")
|
||||
|
||||
@expose("/<uuid_str>/versions/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.list_versions",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def list_versions(self, uuid_str: str) -> Response:
|
||||
"""List version history for a chart.
|
||||
---
|
||||
get:
|
||||
summary: Return the version history for a chart
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: uuid_str
|
||||
description: Chart UUID
|
||||
responses:
|
||||
200:
|
||||
description: Version history ordered by oldest first
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
count:
|
||||
type: integer
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from uuid import UUID
|
||||
|
||||
from superset.daos.version import VersionDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
try:
|
||||
entity_uuid = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return self.response_400(message="Invalid UUID")
|
||||
|
||||
entity = VersionDAO.find_active_by_uuid(Slice, entity_uuid)
|
||||
if entity is None:
|
||||
return self.response_404()
|
||||
try:
|
||||
security_manager.raise_for_ownership(entity)
|
||||
except SupersetSecurityException:
|
||||
return self.response_403()
|
||||
|
||||
versions = VersionDAO.list_versions(Slice, entity_uuid, entity=entity)
|
||||
if versions is None:
|
||||
return self.response_404()
|
||||
from superset.versioning.etag import set_version_etag_by_uuid
|
||||
|
||||
return set_version_etag_by_uuid(
|
||||
self.response(200, result=versions, count=len(versions)),
|
||||
Slice,
|
||||
entity_uuid,
|
||||
)
|
||||
|
||||
@expose(
|
||||
"/<uuid_str>/versions/<version_uuid_str>/",
|
||||
methods=("GET",),
|
||||
)
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.get_version", # noqa: E501
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def get_version(self, uuid_str: str, version_uuid_str: str) -> Response:
|
||||
"""Return the chart's state at a specific version.
|
||||
---
|
||||
get:
|
||||
summary: Read-only snapshot of the chart at a given version
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: uuid_str
|
||||
description: Chart UUID
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: version_uuid_str
|
||||
description: Version UUID as returned by the list endpoint
|
||||
responses:
|
||||
200:
|
||||
description: Snapshot of the chart at the target version
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
result:
|
||||
type: object
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from uuid import UUID
|
||||
|
||||
from superset.daos.version import VersionDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
|
||||
try:
|
||||
entity_uuid = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return self.response_400(message="Invalid UUID")
|
||||
try:
|
||||
version_uuid = UUID(version_uuid_str)
|
||||
except ValueError:
|
||||
return self.response_400(message="Invalid version UUID")
|
||||
|
||||
entity = VersionDAO.find_active_by_uuid(Slice, entity_uuid)
|
||||
if entity is None:
|
||||
return self.response_404()
|
||||
try:
|
||||
security_manager.raise_for_ownership(entity)
|
||||
except SupersetSecurityException:
|
||||
return self.response_403()
|
||||
|
||||
snapshot = VersionDAO.get_version(
|
||||
Slice, entity_uuid, version_uuid, entity=entity
|
||||
)
|
||||
if snapshot is None:
|
||||
return self.response_404()
|
||||
from superset.versioning.etag import set_version_etag_by_uuid
|
||||
|
||||
return set_version_etag_by_uuid(
|
||||
self.response(200, result=snapshot), Slice, entity_uuid
|
||||
)
|
||||
|
||||
@expose("/<uuid_str>/activity/", methods=("GET",))
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: f"{self.__class__.__name__}.activity",
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def activity(self, uuid_str: str) -> Response:
|
||||
"""Return the cross-entity activity stream for a chart.
|
||||
---
|
||||
get:
|
||||
summary: Activity stream — chart own edits + datasets the
|
||||
chart pointed at during association (sc-107283 US2)
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: uuid_str
|
||||
description: Chart UUID
|
||||
- in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
name: since
|
||||
- in: query
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
name: until
|
||||
- in: query
|
||||
schema:
|
||||
type: string
|
||||
enum: [self, related, all]
|
||||
default: all
|
||||
name: include
|
||||
- in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
default: 0
|
||||
name: page
|
||||
- in: query
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 200
|
||||
default: 25
|
||||
name: page_size
|
||||
responses:
|
||||
200:
|
||||
description: Activity stream ordered newest-first
|
||||
content:
|
||||
application/json:
|
||||
schema: ActivityResponseSchema
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset.versioning import activity as activity_module
|
||||
from superset.versioning.schemas import ActivityResponseSchema
|
||||
|
||||
try:
|
||||
entity = activity_module.resolve_endpoint_path_entity(self, Slice, uuid_str)
|
||||
except activity_module.PathEntityResponseError as exc:
|
||||
return exc.response
|
||||
|
||||
try:
|
||||
params = activity_module.parse_activity_query_params(request.args)
|
||||
except activity_module.ActivityParamsError as exc:
|
||||
return self.response_400(message=str(exc))
|
||||
|
||||
records, count = activity_module.get_activity(Slice, entity.uuid, **params)
|
||||
payload = ActivityResponseSchema().dump({"result": records, "count": count})
|
||||
return self.response(200, **payload)
|
||||
|
||||
@expose(
|
||||
"/<uuid_str>/versions/<version_uuid_str>/restore",
|
||||
methods=("POST",),
|
||||
)
|
||||
@protect()
|
||||
@safe
|
||||
@statsd_metrics
|
||||
@event_logger.log_this_with_context(
|
||||
action=lambda self, *args, **kwargs: (
|
||||
f"{self.__class__.__name__}.restore_version"
|
||||
), # noqa: E501
|
||||
log_to_statsd=False,
|
||||
)
|
||||
def restore_version(self, uuid_str: str, version_uuid_str: str) -> Response:
|
||||
"""Restore a chart to a previous version.
|
||||
---
|
||||
post:
|
||||
summary: Revert a chart to an earlier version (non-destructive)
|
||||
parameters:
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: uuid_str
|
||||
description: Chart UUID
|
||||
- in: path
|
||||
schema:
|
||||
type: string
|
||||
format: uuid
|
||||
name: version_uuid_str
|
||||
description: >-
|
||||
Version UUID as returned by the list-versions endpoint.
|
||||
Stable across retention pruning.
|
||||
responses:
|
||||
200:
|
||||
description: Chart was restored
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
400:
|
||||
$ref: '#/components/responses/400'
|
||||
401:
|
||||
$ref: '#/components/responses/401'
|
||||
403:
|
||||
$ref: '#/components/responses/403'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from uuid import UUID
|
||||
|
||||
from superset.commands.chart.restore_version import (
|
||||
RestoreChartVersionCommand,
|
||||
)
|
||||
|
||||
try:
|
||||
entity_uuid = UUID(uuid_str)
|
||||
except ValueError:
|
||||
return self.response_400(message="Invalid UUID")
|
||||
try:
|
||||
version_uuid = UUID(version_uuid_str)
|
||||
except ValueError:
|
||||
return self.response_400(message="Invalid version UUID")
|
||||
|
||||
try:
|
||||
RestoreChartVersionCommand(entity_uuid, version_uuid).run()
|
||||
except ChartNotFoundError:
|
||||
return self.response_404()
|
||||
except ChartForbiddenError:
|
||||
return self.response_403()
|
||||
except ChartUpdateFailedError as ex:
|
||||
logger.error("Error restoring chart version: %s", ex)
|
||||
return self.response_422(message=str(ex))
|
||||
from superset.versioning.etag import set_version_etag_by_uuid
|
||||
|
||||
return set_version_etag_by_uuid(
|
||||
self.response(200, message="OK"), Slice, entity_uuid
|
||||
)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user