Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Code
dd216d64bc fix(helm)!: replace dockerize initContainer with bash TCP wait
Drops `apache/superset:dockerize` from the chart entirely. The five
initContainers that gate startup on Postgres / Redis now run from the
same `apache/superset` image we're already pulling, using bash's
built-in `/dev/tcp/host/port` redirect for the readiness probe — no
external `dockerize`, `nc`, or busybox needed.

A trivy scan of the current published `apache/superset:dockerize`
(image created 2024-05-09, alpine 3.19.1 EOSL) found 3 CRITICAL,
25 HIGH, 71 MEDIUM, and 24 LOW CVEs — 64 of them in the bundled
`dockerize` Go binary itself (stale Go stdlib + golang.org/x/{net,
crypto}); the rest in the alpine base. Rebuilding the image on a
fresher base would just defer the same problem; removing the
dependency eliminates it.

Verified `/bin/bash` 5.2.15 is present in `apache/superset:latest`
and supports the `/dev/tcp` redirect (the image's `/bin/sh` is dash,
which does not — hence the explicit `/bin/bash` invocation).
Rendered the chart with `helm template` and confirmed all five
initContainers (supersetNode, init, supersetWorker,
supersetCeleryBeat, supersetCeleryFlower) emit the expected
bash-based probe and pull the main superset image.

The 120s timeout from `dockerize -timeout 120s` is preserved via a
SECONDS-based deadline in the bash loop. Two-port waits (postgres
+ redis) factor out a small `wait_for` helper to keep the script
readable.

BREAKING CHANGE: chart `values.yaml` no longer defines `initImage`.
Operators who customised `.Values.initImage.repository/tag/pullPolicy`
must remove those overrides — they are silently ignored. Operators
who fully overrode `.Values.supersetNode.initContainers` (etc.) are
unaffected; their override still wins. Chart bumped 0.15.5 → 0.16.0.

Closes #40424

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 19:05:43 -07:00
246 changed files with 2056 additions and 19787 deletions

View File

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

View File

@@ -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 }}

View File

@@ -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

View File

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

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

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

View File

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

View File

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

View File

@@ -32,8 +32,6 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
persist-credentials: false
- name: Check for file changes
id: check
@@ -43,7 +41,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -55,6 +53,6 @@ jobs:
- name: Perform CodeQL Analysis
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
with:
category: "/language:${{matrix.language}}"

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

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

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -1,130 +0,0 @@
name: Scheduled Docker image refresh
# Re-runs the Docker image build against the latest published release on a
# weekly cadence. The code being built doesn't change — but the base image
# layers (python:*-slim-trixie and its OS packages) DO get upstream
# security patches between Superset releases, and those patches don't
# reach our published images unless we rebuild.
#
# Without this workflow, `apache/superset:<latest>` lags behind upstream
# Debian/Python base patches by whatever interval falls between Superset
# releases (typically 36 weeks). With it, the lag drops to at most one
# week regardless of release cadence.
#
# This is a security-hygiene cron, not a release. It overwrites the
# existing tags for the most recent release (e.g. `apache/superset:5.0.0`
# and `apache/superset:latest`) with bit-for-bit-equivalent contents
# layered on a refreshed base. Image digests change; everything users
# actually pin against (image content, code, deps) does not.
on:
schedule:
# Mondays at 06:00 UTC — gives the weekend for upstream patches to
# settle and surfaces failures at the start of the work week so a
# human can react.
- cron: "0 6 * * 1"
# Manual trigger so operators can force a refresh on demand (e.g.
# immediately after a high-severity base-image CVE drops).
workflow_dispatch: {}
permissions:
contents: read
# Serialize with itself and with the release publisher (tag-release.yml) —
# both push to the same Docker Hub tags, so a race could end with stale
# layers winning. Both workflows must declare this group for the lock to work.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
outputs:
has-secrets: ${{ steps.check.outputs.has-secrets }}
latest-release: ${{ steps.latest.outputs.tag }}
steps:
- name: Check for Docker Hub secrets
id: check
shell: bash
run: |
if [ -n "${DOCKERHUB_USER}" ]; then
echo "has-secrets=1" >> "$GITHUB_OUTPUT"
fi
env:
DOCKERHUB_USER: ${{ (secrets.DOCKERHUB_USER != '' && secrets.DOCKERHUB_TOKEN != '') || '' }}
- name: Look up latest published release
id: latest
shell: bash
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPOSITORY: ${{ github.repository }}
run: |
# `releases/latest` returns the latest non-prerelease, non-draft
# release — which is exactly what `apache/superset:latest`
# should reflect.
TAG=$(gh api "repos/${REPOSITORY}/releases/latest" --jq .tag_name)
if [ -z "$TAG" ] || [ "$TAG" = "null" ]; then
echo "::error::Could not determine latest release tag"
exit 1
fi
echo "Latest release: $TAG"
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
docker-rebuild:
needs: config
if: needs.config.outputs.has-secrets
name: docker-rebuild
runs-on: ubuntu-24.04
strategy:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
with:
ref: ${{ needs.config.outputs.latest-release }}
fetch-depth: 0
persist-credentials: false
- name: Setup Docker Environment
uses: ./.github/actions/setup-docker
with:
dockerhub-user: ${{ secrets.DOCKERHUB_USER }}
dockerhub-token: ${{ secrets.DOCKERHUB_TOKEN }}
install-docker-compose: "false"
build: "true"
- name: Use Node.js 20
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6
with:
node-version: 20
- name: Setup supersetbot
uses: ./.github/actions/setup-supersetbot/
- name: Rebuild and push
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
BUILD_PRESET: ${{ matrix.build_preset }}
LATEST_RELEASE: ${{ needs.config.outputs.latest-release }}
run: |
# Reuses the same supersetbot invocation as the release
# publisher (`tag-release.yml`), so the resulting tags are
# identical to what a manual release dispatch would produce —
# just with a freshly-pulled base image layer underneath.
supersetbot docker \
--push \
--preset "$BUILD_PRESET" \
--context release \
--context-ref "$LATEST_RELEASE" \
--force-latest \
--platform "linux/arm64" \
--platform "linux/amd64"

View File

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

View File

@@ -27,9 +27,6 @@ concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
permissions:
contents: read
jobs:
config:
runs-on: ubuntu-24.04
@@ -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:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -143,7 +143,7 @@ jobs:
if: >-
github.event_name == 'pull_request' &&
steps.regression.outcome == 'failure'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7
with:
name: translation-regression
path: |

View File

@@ -21,15 +21,6 @@ on:
options:
- 'true'
- 'false'
permissions:
contents: read
# Serialize with the scheduled Docker image refresh — both workflows push
# to the same Docker Hub tags and must not race on apache/superset:latest.
concurrency:
group: docker-publish-latest-release
cancel-in-progress: false
jobs:
config:
runs-on: ubuntu-24.04
@@ -51,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"]
@@ -62,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
@@ -89,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
@@ -127,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
@@ -142,12 +128,11 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_RELEASE: ${{ github.event.inputs.release }}
GITHUB_EVENT_RELEASE_TAG_NAME: ${{ github.event.release.tag_name }}
run: |
export GITHUB_ACTOR=""
git fetch --all --tags
git checkout master
RELEASE="${GITHUB_EVENT_RELEASE_TAG_NAME}"
RELEASE="${{ github.event.release.tag_name }}"
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
# in the case of a manually-triggered run, read release from input
RELEASE="${INPUT_RELEASE}"

View File

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

View File

@@ -12,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.

View File

@@ -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/]

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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==

View File

@@ -29,7 +29,7 @@ maintainers:
- name: craig-rueda
email: craig@craigrueda.com
url: https://github.com/craig-rueda
version: 0.15.5 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
version: 0.16.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
dependencies:
- name: postgresql
version: 16.7.27

View File

@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
# superset
![Version: 0.15.5](https://img.shields.io/badge/Version-0.15.5-informational?style=flat-square)
![Version: 0.16.0](https://img.shields.io/badge/Version-0.16.0-informational?style=flat-square)
Apache Superset is a modern, enterprise-ready business intelligence web application
@@ -111,9 +111,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
| init.resources | object | `{}` | |
| init.tolerations | list | `[]` | |
| init.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to init job |
| initImage.pullPolicy | string | `"IfNotPresent"` | |
| initImage.repository | string | `"apache/superset"` | |
| initImage.tag | string | `"dockerize"` | |
| nameOverride | string | `nil` | Provide a name to override the name of the chart |
| nodeSelector | object | `{}` | |
| postgresql | object | see `values.yaml` | Configuration values for the postgresql dependency. ref: https://github.com/bitnami/charts/tree/main/bitnami/postgresql |

View File

@@ -194,11 +194,6 @@ image:
imagePullSecrets: []
initImage:
repository: apache/superset
tag: dockerize
pullPolicy: IfNotPresent
service:
type: ClusterIP
port: 8088
@@ -303,15 +298,28 @@ supersetNode:
# @default -- a container waiting for postgres
initContainers:
- name: wait-for-postgres
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
envFrom:
- secretRef:
name: "{{ tpl .Values.envFromSecret . }}"
command:
- /bin/sh
- /bin/bash
- -c
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
- |
# bash's /dev/tcp redirect performs a TCP connect; no external
# `dockerize`, `nc`, or busybox needed. SECONDS-based deadline
# mirrors the prior `dockerize -timeout 120s` behaviour.
SECONDS=0
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
if [ "$SECONDS" -ge 120 ]; then
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
exit 1
fi
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
sleep 2
done
echo "postgres at $DB_HOST:$DB_PORT is up"
resources:
limits:
memory: "256Mi"
@@ -407,15 +415,31 @@ supersetWorker:
# @default -- a container waiting for postgres and redis
initContainers:
- name: wait-for-postgres-redis
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
envFrom:
- secretRef:
name: "{{ tpl .Values.envFromSecret . }}"
command:
- /bin/sh
- /bin/bash
- -c
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
- |
# See supersetNode.initContainers for the rationale.
SECONDS=0
wait_for() {
local host=$1 port=$2 name=$3
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
if [ "$SECONDS" -ge 120 ]; then
echo "timeout waiting for $name at $host:$port after 120s" >&2
exit 1
fi
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
sleep 2
done
echo "$name at $host:$port is up"
}
wait_for "$DB_HOST" "$DB_PORT" postgres
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
resources:
limits:
memory: "256Mi"
@@ -495,15 +519,31 @@ supersetCeleryBeat:
# @default -- a container waiting for postgres
initContainers:
- name: wait-for-postgres-redis
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
envFrom:
- secretRef:
name: "{{ tpl .Values.envFromSecret . }}"
command:
- /bin/sh
- /bin/bash
- -c
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
- |
# See supersetNode.initContainers for the rationale.
SECONDS=0
wait_for() {
local host=$1 port=$2 name=$3
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
if [ "$SECONDS" -ge 120 ]; then
echo "timeout waiting for $name at $host:$port after 120s" >&2
exit 1
fi
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
sleep 2
done
echo "$name at $host:$port is up"
}
wait_for "$DB_HOST" "$DB_PORT" postgres
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
resources:
limits:
memory: "256Mi"
@@ -594,15 +634,31 @@ supersetCeleryFlower:
# @default -- a container waiting for postgres and redis
initContainers:
- name: wait-for-postgres-redis
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
envFrom:
- secretRef:
name: "{{ tpl .Values.envFromSecret . }}"
command:
- /bin/sh
- /bin/bash
- -c
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -wait "tcp://$REDIS_HOST:$REDIS_PORT" -timeout 120s
- |
# See supersetNode.initContainers for the rationale.
SECONDS=0
wait_for() {
local host=$1 port=$2 name=$3
until (echo > /dev/tcp/"$host"/"$port") 2>/dev/null; do
if [ "$SECONDS" -ge 120 ]; then
echo "timeout waiting for $name at $host:$port after 120s" >&2
exit 1
fi
echo "waiting for $name at $host:$port (elapsed ${SECONDS}s)"
sleep 2
done
echo "$name at $host:$port is up"
}
wait_for "$DB_HOST" "$DB_PORT" postgres
wait_for "$REDIS_HOST" "$REDIS_PORT" redis
resources:
limits:
memory: "256Mi"
@@ -764,15 +820,26 @@ init:
# @default -- a container waiting for postgres
initContainers:
- name: wait-for-postgres
image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
imagePullPolicy: "{{ .Values.initImage.pullPolicy }}"
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: "{{ .Values.image.pullPolicy }}"
envFrom:
- secretRef:
name: "{{ tpl .Values.envFromSecret . }}"
command:
- /bin/sh
- /bin/bash
- -c
- dockerize -wait "tcp://$DB_HOST:$DB_PORT" -timeout 120s
- |
# See supersetNode.initContainers for the rationale.
SECONDS=0
until (echo > /dev/tcp/"$DB_HOST"/"$DB_PORT") 2>/dev/null; do
if [ "$SECONDS" -ge 120 ]; then
echo "timeout waiting for postgres at $DB_HOST:$DB_PORT after 120s" >&2
exit 1
fi
echo "waiting for postgres at $DB_HOST:$DB_PORT (elapsed ${SECONDS}s)"
sleep 2
done
echo "postgres at $DB_HOST:$DB_PORT is up"
resources:
limits:
memory: "256Mi"

View File

@@ -39,7 +39,7 @@ dependencies = [
"apache-superset-core",
"backoff>=1.8.0",
"celery>=5.3.6, <6.0.0",
"click>=8.4.0",
"click>=8.0.3",
"click-option-group",
"colorama",
"flask-cors>=6.0.0, <7.0",
@@ -103,7 +103,7 @@ dependencies = [
"sqlalchemy-utils>=0.38.0, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.8.0, <31",
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
"tabulate>=0.9.0, <1.0",
"typing-extensions>=4, <5",
"waitress; sys_platform == 'win32'",
"watchdog>=6.0.0",
@@ -139,7 +139,7 @@ denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.10, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.5.2,<2", "duckdb-engine>=0.17.0"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]

View File

@@ -60,7 +60,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.4.1
click==8.2.1
# via
# apache-superset (pyproject.toml)
# celery
@@ -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)
@@ -421,7 +421,7 @@ sqlglot==30.8.0
# apache-superset-core
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.10.0
tabulate==0.9.0
# via apache-superset (pyproject.toml)
trio==0.30.0
# via
@@ -451,7 +451,7 @@ tzdata==2025.2
# pandas
url-normalize==2.2.1
# via requests-cache
urllib3==2.7.0
urllib3==2.6.3
# via
# -r requirements/base.in
# requests

View File

@@ -130,7 +130,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.4.1
click==8.2.1
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -219,7 +219,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.5.3
duckdb==1.4.2
# via
# apache-superset
# duckdb-engine
@@ -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
@@ -1007,7 +1006,7 @@ statsd==4.0.1
# via apache-superset
syntaqlite==0.1.0
# via apache-superset
tabulate==0.10.0
tabulate==0.9.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -1072,7 +1071,7 @@ url-normalize==2.2.1
# via
# -c requirements/base-constraint.txt
# requests-cache
urllib3==2.7.0
urllib3==2.6.3
# via
# -c requirements/base-constraint.txt
# botocore

View File

@@ -31,9 +31,7 @@ PATTERNS = {
r"^superset/",
r"^scripts/",
r"^setup\.py",
r"^pyproject\.toml$",
r"^requirements/.+\.txt",
r"^pyproject\.toml",
r"^.pylintrc",
],
"frontend": [
@@ -157,7 +155,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")

View File

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

View File

@@ -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",

View File

@@ -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"
@@ -49973,7 +49973,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.4.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -29,7 +29,7 @@
"acorn": "^8.16.0",
"d3-array": "^3.2.4",
"lodash": "^4.18.1",
"zod": "^4.4.3"
"zod": "^4.4.1"
},
"peerDependencies": {
"@apache-superset/core": "*",

View File

@@ -239,6 +239,8 @@ export default function transformProps(
formatter,
show: showLabels,
color: theme.colorText,
textBorderColor: theme.colorBgBase,
textBorderWidth: 1,
};
const legendData = keys.sort((a: string, b: string) => {
if (!legendSort) return 0;

View File

@@ -21,8 +21,8 @@ import { TreePathInfo } from '../types';
export const COLOR_SATURATION = [0.7, 0.4];
export const LABEL_FONTSIZE = 11;
export const BORDER_WIDTH = 0;
export const GAP_WIDTH = 0;
export const BORDER_WIDTH = 2;
export const GAP_WIDTH = 2;
export const extractTreePathInfo = (
treePathInfo: TreePathInfo[] | undefined,

View File

@@ -214,8 +214,7 @@ export default function transformProps(
colorAlpha: OpacityEnum.SemiTransparent,
color: theme.colorText,
borderColor: theme.colorBgBase,
borderWidth: BORDER_WIDTH,
gapWidth: GAP_WIDTH,
borderWidth: 2,
},
label: {
...labelProps,

View File

@@ -72,15 +72,6 @@ describe('Funnel transformProps', () => {
}),
);
});
test('does not apply a text border to segment labels', () => {
// A white textBorder washes out the dark text on light-colored segments.
const result = transformProps(chartProps as EchartsFunnelChartProps);
const { label } = (result.echartOptions.series as any)[0];
expect(label.color).toBe(supersetTheme.colorText);
expect(label.textBorderColor).toBeUndefined();
expect(label.textBorderWidth).toBeUndefined();
});
});
describe('formatFunnelLabel', () => {

View File

@@ -18,7 +18,6 @@
*/
import { ChartProps } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import { OpacityEnum } from '../../src/constants';
import { EchartsTreemapChartProps } from '../../src/Treemap/types';
import transformProps from '../../src/Treemap/transformProps';
@@ -75,44 +74,4 @@ describe('Treemap transformProps', () => {
}),
);
});
test('should not render gaps between treemap nodes when filtered', () => {
const filteredChartProps = new ChartProps({
...chartProps,
filterState: { selectedValues: ['Sylvester,bar1'] },
});
expect(
transformProps(filteredChartProps as EchartsTreemapChartProps),
).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: [
expect.objectContaining({
data: expect.arrayContaining([
expect.objectContaining({
children: expect.arrayContaining([
expect.objectContaining({
name: 'Arnold',
children: expect.arrayContaining([
expect.objectContaining({
name: 'bar2',
itemStyle: expect.objectContaining({
borderWidth: 0,
gapWidth: 0,
colorAlpha: OpacityEnum.SemiTransparent,
}),
label: expect.objectContaining({}),
}),
]),
}),
]),
}),
]),
}),
],
}),
}),
);
});
});

View File

@@ -29,7 +29,7 @@
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.24.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"
},
"peerDependencies": {

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,198 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
useEffect,
useRef,
useState,
type ReactNode,
type MouseEvent,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { useTheme, styled, css } from '@apache-superset/core/theme';
import { Dropdown, Tooltip, Icons } from '@superset-ui/core/components';
export type FilterPanelRenderProps = {
isOpen: boolean;
onClose: () => void;
};
interface CompactFilterTriggerProps {
label: ReactNode;
hasValue: boolean;
onClear: () => void;
/** Render prop: receives { isOpen, onClose } and returns the panel content. */
children: (props: FilterPanelRenderProps) => ReactNode;
/** Shown as a hover tooltip when a value is selected (e.g. the selected label). */
tooltipTitle?: string;
/** ARIA popup role for the trigger button. Use 'listbox' for option panels,
* 'dialog' for form panels (date range, numerical range). */
popupType?: 'listbox' | 'dialog';
}
const FilterPill = styled.button<{ $active: boolean }>`
${({ theme, $active }) => css`
display: inline-flex;
align-items: center;
gap: ${theme.sizeUnit}px;
height: ${theme.controlHeight}px;
padding: 0 ${theme.sizeUnit * 3}px;
border-radius: ${theme.borderRadius}px;
border: 1px solid ${$active ? theme.colorPrimary : theme.colorBorder};
background: ${$active ? theme.colorPrimaryBg : theme.colorBgContainer};
color: ${$active ? theme.colorPrimary : theme.colorText};
font-size: ${theme.fontSizeSM}px;
font-weight: ${$active ? 600 : 400};
cursor: pointer;
white-space: nowrap;
line-height: 1;
transition:
border-color 0.2s,
background 0.2s,
color 0.2s;
/* AntD anticon spans carry vertical-align: -0.125em from global styles.
align-self centers the span within the pill; the inner flex+align-items
centers the svg within the span. */
.anticon {
display: flex;
align-items: center;
align-self: center;
line-height: 0;
}
&:hover {
border-color: ${theme.colorPrimary};
background: ${$active ? theme.colorPrimaryBgHover : theme.colorFillAlter};
}
&:focus-visible {
outline: 2px solid ${theme.colorPrimary};
outline-offset: 2px;
}
`}
`;
const ActiveDot = styled.span`
${({ theme }) => css`
width: 6px;
height: 6px;
border-radius: 50%;
background: ${theme.colorPrimary};
flex-shrink: 0;
`}
`;
export default function CompactFilterTrigger({
label,
hasValue,
onClear,
children,
tooltipTitle,
popupType = 'listbox',
}: CompactFilterTriggerProps) {
const [open, setOpen] = useState(false);
const [tooltipOpen, setTooltipOpen] = useState(false);
const theme = useTheme();
// Tracks whether tooltip should be suppressed after dropdown close.
// Brave (and some other browsers) fire a synthetic mouseover on newly-exposed
// elements when a popup disappears, triggering Tooltip onOpenChange(true)
// without real user intent. We suppress until the cursor actually leaves the
// pill (onMouseLeave), which is the first reliable "hover reset" signal.
const tooltipSuppressedRef = useRef(false);
// Close dropdown on window resize — AntD Dropdown doesn't reposition
// itself on resize so the panel ends up detached from the pill.
useEffect(() => {
if (!open) return;
const handleResize = () => setOpen(false);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [open]);
const handleClear = (e: MouseEvent) => {
e.stopPropagation();
onClear();
setOpen(false);
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
};
return (
<Dropdown
open={open}
onOpenChange={visible => {
setOpen(visible);
if (!visible) {
tooltipSuppressedRef.current = true;
setTooltipOpen(false);
}
}}
trigger={['click']}
popupRender={() =>
children({ isOpen: open, onClose: () => setOpen(false) })
}
placement="bottomLeft"
destroyPopupOnHide
>
<Tooltip
title={tooltipTitle}
open={!!tooltipTitle && !open && tooltipOpen}
onOpenChange={visible => {
if (visible && tooltipSuppressedRef.current) return;
setTooltipOpen(visible && !!tooltipTitle && !open);
}}
mouseEnterDelay={0.5}
mouseLeaveDelay={0}
>
<FilterPill
$active={hasValue}
type="button"
data-test="compact-filter-pill"
aria-haspopup={popupType}
aria-expanded={open}
aria-label={typeof label === 'string' ? label : undefined}
onMouseLeave={() => {
tooltipSuppressedRef.current = false;
}}
>
{hasValue && <ActiveDot />}
<span>{label}</span>
{hasValue ? (
<Icons.CloseOutlined
iconSize="s"
iconColor={theme.colorPrimary}
onClick={handleClear}
data-test="compact-filter-clear"
aria-label={
typeof label === 'string'
? t('Clear %s filter', label)
: undefined
}
/>
) : (
<Icons.DownOutlined
iconSize="s"
iconColor={theme.colorTextSecondary}
/>
)}
</FilterPill>
</Tooltip>
</Dropdown>
);
}

View File

@@ -1,339 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import CompactSelectPanel from './CompactSelectPanel';
import type { FilterHandler } from './types';
const SMALL_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
];
const LARGE_SELECTS = [
{ label: 'Alice', value: 1 },
{ label: 'Bob', value: 2 },
{ label: 'Charlie', value: 3 },
{ label: 'David', value: 4 },
{ label: 'Eve', value: 5 },
{ label: 'Frank', value: 6 },
{ label: 'Grace', value: 7 },
];
beforeEach(() => {
jest.clearAllMocks();
});
test('renders options from selects prop', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Bob')).toBeInTheDocument();
expect(screen.getByText('Charlie')).toBeInTheDocument();
});
test('hides search input when selects.length is 6 or fewer', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.queryByPlaceholderText('Search')).not.toBeInTheDocument();
});
test('shows search input when selects.length exceeds 6', () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('shows search input when fetchSelects is provided', () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
test('filters static options by search term', async () => {
render(
<CompactSelectPanel
selects={LARGE_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
await userEvent.type(screen.getByPlaceholderText('Search'), 'ali');
expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.queryByText('Bob')).not.toBeInTheDocument();
});
test('calls onSelect with normalized option when an option is clicked', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('calls onSelect with undefined when same option is clicked twice (deselect)', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onSelect).toHaveBeenCalledWith(undefined, true);
});
test('shows checkmark icon on selected option', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const aliceOption = screen
.getByText('Alice')
.closest('[role="option"]') as HTMLElement;
expect(aliceOption).toHaveAttribute('aria-selected', 'true');
});
test('unselected options have aria-selected false', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
const bobOption = screen
.getByText('Bob')
.closest('[role="option"]') as HTMLElement;
expect(bobOption).toHaveAttribute('aria-selected', 'false');
});
test('calls onClose after a selection is made', async () => {
const onClose = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
onClose={onClose}
/>,
);
await userEvent.click(screen.getByText('Alice'));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('clearFilter via ref resets selection and calls onSelect(undefined, true)', () => {
const onSelect = jest.fn();
const ref = createRef<FilterHandler>();
const { rerender } = render(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
act(() => {
ref.current?.clearFilter();
});
expect(onSelect).toHaveBeenCalledWith(undefined, true);
// Component is fully controlled — visual deselection follows when the
// parent passes value={undefined} after receiving the onSelect callback.
rerender(
<CompactSelectPanel
ref={ref}
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});
test('shows Loading text when loading prop is true', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
loading
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});
test('shows No results when displayOptions is empty', () => {
render(
<CompactSelectPanel selects={[]} value={undefined} onSelect={jest.fn()} />,
);
expect(screen.getByText('No results')).toBeInTheDocument();
});
test('renders options list with listbox role and accessible label', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const listbox = screen.getByRole('listbox');
expect(listbox).toBeInTheDocument();
expect(listbox).toHaveAttribute('aria-label', 'Filter options');
});
test('option items have option role', () => {
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
const options = screen.getAllByRole('option');
expect(options).toHaveLength(3);
});
test('fetches and displays remote options via fetchSelects on mount', async () => {
const fetchSelects = jest.fn().mockResolvedValue({
data: [{ label: 'Remote User', value: 99 }],
totalCount: 1,
});
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Loading...')).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText('Remote User')).toBeInTheDocument();
});
expect(fetchSelects).toHaveBeenCalledWith('', 0, 200);
});
test('shows No results when fetchSelects returns empty data', async () => {
const fetchSelects = jest.fn().mockResolvedValue({ data: [], totalCount: 0 });
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('shows No results when fetchSelects rejects', async () => {
const fetchSelects = jest.fn().mockRejectedValue(new Error('network error'));
render(
<CompactSelectPanel
fetchSelects={fetchSelects}
value={undefined}
onSelect={jest.fn()}
/>,
);
await waitFor(() => {
expect(screen.getByText('No results')).toBeInTheDocument();
});
});
test('selects option via keyboard Enter key', async () => {
const onSelect = jest.fn();
render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={onSelect}
/>,
);
const aliceOption = screen.getByText('Alice').closest('[role="option"]')!;
await userEvent.type(aliceOption, '{Enter}');
expect(onSelect).toHaveBeenCalledWith({ label: 'Alice', value: 1 }, false);
});
test('syncs selected state when external value prop changes', () => {
const { rerender } = render(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={{ label: 'Alice', value: 1 }}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'true',
);
rerender(
<CompactSelectPanel
selects={SMALL_SELECTS}
value={undefined}
onSelect={jest.fn()}
/>,
);
expect(screen.getByText('Alice').closest('[role="option"]')).toHaveAttribute(
'aria-selected',
'false',
);
});

View File

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

View File

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

View File

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

View File

@@ -1,74 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import type { ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import { styled, css } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
interface FilterPopoverContentProps {
children: ReactNode;
onClose?: () => void;
}
const Wrapper = styled.div`
${({ theme }) => css`
padding: ${theme.sizeUnit * 2}px;
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 2}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
/* Visually hide the redundant label — the pill already shows it, but keep it
accessible to screen readers so filter inputs have a named context. */
label {
position: absolute !important;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
`}
`;
const Footer = styled.div`
display: flex;
justify-content: flex-end;
`;
export default function FilterPopoverContent({
children,
onClose,
}: FilterPopoverContentProps) {
return (
<Wrapper>
{children}
<Footer>
<Button size="small" buttonStyle="primary" onClick={onClose}>
{t('Apply')}
</Button>
</Footer>
</Wrapper>
);
}

View File

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

View File

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

View File

@@ -1,251 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { createRef, act } from 'react';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { NO_TIME_RANGE, SupersetClient } from '@superset-ui/core';
import TimeRangeFilter from './TimeRange';
import type { FilterHandler } from './types';
// Suppress debounced evaluation — the initial useEffect handles the committed
// value; the debounced path is an optimistic UX enhancement, not a contract.
jest.mock('src/explore/exploreUtils', () => ({
...jest.requireActual('src/explore/exploreUtils'),
useDebouncedEffect: jest.fn(),
}));
jest.mock('src/explore/components/controls/DateFilterControl/utils', () => ({
FRAME_OPTIONS: [
{ label: 'No filter', value: 'No filter' },
{ label: 'Custom', value: 'Custom' },
],
guessFrame: jest.fn().mockReturnValue('Custom'),
// 'No filter' is the string value of NO_TIME_RANGE constant
useDefaultTimeFilter: jest.fn().mockReturnValue('No filter'),
}));
jest.mock(
'src/explore/components/controls/DateFilterControl/components',
() => ({
AdvancedFrame: () => <div data-test="advanced-frame" />,
CalendarFrame: () => <div data-test="calendar-frame" />,
CommonFrame: () => <div data-test="common-frame" />,
CustomFrame: ({ value }: { value: string }) => (
<div data-test="custom-frame">{value}</div>
),
}),
);
jest.mock(
'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame',
() => ({
CurrentCalendarFrame: () => <div data-testid="current-calendar-frame" />,
}),
);
const VALID_RANGE = '2024-01-01 : 2024-01-31';
// Default successful response that fetchTimeRange and the Apply handler both use
const MOCK_TIME_RANGE_RESULT = {
json: {
result: [{ since: '2024-01-01T00:00:00', until: '2024-01-31T23:59:59' }],
},
};
let getSpy: jest.SpyInstance;
beforeEach(() => {
getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(MOCK_TIME_RANGE_RESULT as any);
});
afterEach(() => {
getSpy.mockRestore();
});
function renderFilter(
props: Partial<{
value: string;
onSubmit: jest.Mock;
onClose: jest.Mock;
}> = {},
) {
const onSubmit = props.onSubmit ?? jest.fn();
const onClose = props.onClose ?? jest.fn();
return render(
<TimeRangeFilter
value={props.value ?? VALID_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
}
test('renders range type label, actual time range section, and footer buttons', () => {
renderFilter();
expect(screen.getByText('Range type')).toBeInTheDocument();
expect(screen.getByText('Actual time range')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /cancel/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /apply/i })).toBeInTheDocument();
});
test('shows the custom frame when guessFrame returns Custom', () => {
renderFilter();
expect(screen.getByTestId('custom-frame')).toBeInTheDocument();
});
test('Apply is disabled until the API validates the initial value', async () => {
// Block resolution so we can observe disabled state
let resolve: (v: typeof MOCK_TIME_RANGE_RESULT) => void;
getSpy.mockReturnValue(
new Promise(res => {
resolve = res;
}),
);
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
expect(apply).toBeDisabled();
act(() => {
resolve!(MOCK_TIME_RANGE_RESULT);
});
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is enabled when the API returns a valid result', async () => {
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
});
test('Apply is disabled when the API returns an error response', async () => {
getSpy.mockRejectedValue(new Error('Bad request'));
renderFilter();
const apply = screen.getByRole('button', { name: /apply/i });
// Give fetchTimeRange time to reject and set validTimeRange=false
await waitFor(() => {
expect(apply).toBeDisabled();
});
});
test('Cancel button calls onClose', async () => {
const onClose = jest.fn();
renderFilter({ onClose });
await userEvent.click(screen.getByRole('button', { name: /cancel/i }));
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onSubmit([since, until]) and onClose when API succeeds', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith([
'2024-01-01T00:00:00',
'2024-01-31T23:59:59',
]);
});
expect(onClose).toHaveBeenCalledTimes(1);
});
test('Apply calls onClose but not onSubmit when the API call throws', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
// fetchTimeRange succeeds (for validTimeRange), but the Apply API call fails
getSpy
.mockResolvedValueOnce(MOCK_TIME_RANGE_RESULT as any) // fetchTimeRange in useEffect
.mockRejectedValueOnce(new Error('network')); // Apply button API call
renderFilter({ onSubmit, onClose });
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
await userEvent.click(apply);
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
});
expect(onSubmit).not.toHaveBeenCalled();
});
test('Apply with NO_TIME_RANGE calls onSubmit(undefined) and onClose without an API call', async () => {
const onSubmit = jest.fn();
const onClose = jest.fn();
render(
<TimeRangeFilter
value={NO_TIME_RANGE}
onSubmit={onSubmit}
onClose={onClose}
/>,
);
const apply = screen.getByRole('button', { name: /apply/i });
await waitFor(() => {
expect(apply).not.toBeDisabled();
});
const callsBefore = getSpy.mock.calls.length;
await userEvent.click(apply);
expect(onSubmit).toHaveBeenCalledWith(undefined);
expect(onClose).toHaveBeenCalledTimes(1);
// No extra API call for NO_TIME_RANGE — the button short-circuits
expect(getSpy.mock.calls.length).toBe(callsBefore);
});
test('clearFilter via ref calls onSubmit(undefined)', async () => {
const onSubmit = jest.fn();
const ref = createRef<FilterHandler>();
render(
<TimeRangeFilter
ref={ref}
value={VALID_RANGE}
onSubmit={onSubmit}
onClose={jest.fn()}
/>,
);
act(() => {
ref.current?.clearFilter();
});
expect(onSubmit).toHaveBeenCalledWith(undefined);
});

View File

@@ -1,291 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
useEffect,
useImperativeHandle,
useMemo,
useState,
type RefObject,
} from 'react';
import { t } from '@apache-superset/core/translation';
import {
NO_TIME_RANGE,
SupersetClient,
fetchTimeRange,
} from '@superset-ui/core';
import rison from 'rison';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import {
Button,
Constants,
Divider,
Icons,
Select,
} from '@superset-ui/core/components';
import { useDebouncedEffect } from 'src/explore/exploreUtils';
import {
FRAME_OPTIONS,
guessFrame,
useDefaultTimeFilter,
} from 'src/explore/components/controls/DateFilterControl/utils';
import {
AdvancedFrame,
CalendarFrame,
CommonFrame,
CustomFrame,
} from 'src/explore/components/controls/DateFilterControl/components';
import { CurrentCalendarFrame } from 'src/explore/components/controls/DateFilterControl/components/CurrentCalendarFrame';
import type { FrameType } from 'src/explore/components/controls/DateFilterControl/types';
import type { FilterHandler } from './types';
interface TimeRangeFilterProps {
value?: string;
onSubmit: (value: [string, string] | undefined) => void;
onClose: () => void;
}
const StyledRangeType = styled(Select)`
width: 272px;
`;
const ContentWrapper = styled.div`
${({ theme }) => css`
width: 600px;
padding: ${theme.sizeUnit * 3}px;
background: ${theme.colorBgElevated};
border-radius: ${theme.borderRadiusLG}px;
box-shadow: ${theme.boxShadowSecondary};
.ant-row {
margin-top: 8px;
}
.ant-picker {
padding: 4px 17px 4px;
border-radius: 4px;
}
.ant-divider-horizontal {
margin: 16px 0;
}
.control-label {
font-size: ${theme.fontSizeSM}px;
line-height: 16px;
margin: 8px 0;
}
.section-title {
font-style: normal;
font-weight: ${theme.fontWeightStrong};
font-size: 15px;
line-height: 24px;
margin-bottom: 8px;
}
.control-anchor-to {
margin-top: 16px;
}
.control-anchor-to-datetime {
width: 217px;
}
.footer {
text-align: right;
}
`}
`;
const IconWrapper = styled.span`
span {
margin-right: ${({ theme }) => 2 * theme.sizeUnit}px;
vertical-align: middle;
}
.text {
vertical-align: middle;
}
.error {
color: ${({ theme }) => theme.colorError};
}
`;
function TimeRangeFilter(
{ value: valueProp, onSubmit, onClose }: TimeRangeFilterProps,
ref: RefObject<FilterHandler>,
) {
const defaultTimeFilter = useDefaultTimeFilter();
const value = valueProp ?? defaultTimeFilter;
const theme = useTheme();
// guessedFrame is only used for the initial useState — value is stable at
// mount because CompactFilterTrigger uses destroyPopupOnHide, so the panel
// always mounts fresh with the current committed value.
const guessedFrame = useMemo(() => guessFrame(value), [value]);
const [frame, setFrame] = useState<FrameType>(guessedFrame);
const [timeRangeValue, setTimeRangeValue] = useState(value);
const [evalResponse, setEvalResponse] = useState(value);
const [validTimeRange, setValidTimeRange] = useState(false);
const [lastFetched, setLastFetched] = useState(value);
// Evaluate the committed value shown in "Actual time range".
useEffect(() => {
if (value === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
fetchTimeRange(value).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? value);
setValidTimeRange(true);
}
setLastFetched(value);
});
}, [value]);
// Debounced evaluation of the in-progress selection (drives "Actual time range").
useDebouncedEffect(
() => {
if (timeRangeValue === NO_TIME_RANGE) {
setEvalResponse(NO_TIME_RANGE);
setLastFetched(NO_TIME_RANGE);
setValidTimeRange(true);
return;
}
if (lastFetched !== timeRangeValue) {
fetchTimeRange(timeRangeValue).then(({ value: actual, error }) => {
if (error) {
setEvalResponse(error ?? '');
setValidTimeRange(false);
} else {
setEvalResponse(actual ?? '');
setValidTimeRange(true);
}
setLastFetched(timeRangeValue);
});
}
},
Constants.SLOW_DEBOUNCE,
[timeRangeValue],
);
useImperativeHandle(ref, () => ({
clearFilter: () => {
onSubmit(undefined);
},
}));
function onChangeFrame(val: FrameType) {
if (val === NO_TIME_RANGE) {
setTimeRangeValue(NO_TIME_RANGE);
}
setFrame(val);
}
return (
<ContentWrapper>
<div className="control-label">{t('Range type')}</div>
<StyledRangeType
ariaLabel={t('Range type')}
options={FRAME_OPTIONS}
value={frame}
onChange={onChangeFrame}
/>
{frame !== 'No filter' && <Divider />}
{frame === 'Common' && (
<CommonFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Calendar' && (
<CalendarFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Current' && (
<CurrentCalendarFrame
value={timeRangeValue}
onChange={setTimeRangeValue}
/>
)}
{frame === 'Advanced' && (
<AdvancedFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
{frame === 'Custom' && (
<CustomFrame value={timeRangeValue} onChange={setTimeRangeValue} />
)}
<Divider />
<div>
<div className="section-title">{t('Actual time range')}</div>
{validTimeRange && (
<div>
{evalResponse === NO_TIME_RANGE ? t('No filter') : evalResponse}
</div>
)}
{!validTimeRange && (
<IconWrapper className="warning">
<Icons.ExclamationCircleOutlined iconColor={theme.colorError} />
<span className="text error">{evalResponse}</span>
</IconWrapper>
)}
</div>
<Divider />
<div className="footer">
<Button buttonStyle="secondary" cta key="cancel" onClick={onClose}>
{t('CANCEL')}
</Button>
<Button
buttonStyle="primary"
cta
disabled={!validTimeRange}
key="apply"
onClick={async () => {
if (timeRangeValue === NO_TIME_RANGE) {
onSubmit(undefined);
onClose();
return;
}
// fetchTimeRange returns a formatted display string ("X ≤ col < Y"),
// not the raw since/until strings. Call the API directly to get them.
try {
const response = await SupersetClient.get({
endpoint: `/api/v1/time_range/?q=${rison.encode_uri(timeRangeValue)}`,
});
const since: string | undefined =
response?.json?.result[0]?.since;
const until: string | undefined =
response?.json?.result[0]?.until;
if (since !== undefined && until !== undefined) {
onSubmit([since, until]);
}
} catch {
// leave filter unchanged on error
}
onClose();
}}
>
{t('APPLY')}
</Button>
</div>
</ContentWrapper>
);
}
export default forwardRef(TimeRangeFilter);

View File

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

View File

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

View File

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

View File

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

View File

@@ -468,12 +468,9 @@ export function saveDashboardRequest(
);
const cleanedData: JsonObject = {
...data,
...(certified_by !== undefined && {
certified_by,
certification_details: certified_by
? (certification_details ?? '')
: '',
}),
certified_by: certified_by || '',
certification_details:
certified_by && certification_details ? certification_details : '',
css: css || '',
dashboard_title: dashboard_title || t('[ untitled dashboard ]'),
owners: ensureIsArray(owners as JsonObject[]).map((o: JsonObject) =>

View File

@@ -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,
})) || [],
};

View File

@@ -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,
};

View File

@@ -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,

View File

@@ -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,

View File

@@ -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') =>
({

View File

@@ -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'),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 {

View File

@@ -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": {

View File

@@ -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",

View File

@@ -185,16 +185,6 @@ class ExportDashboardsCommand(ExportModelsCommand):
# Add theme UUID for proper cross-system imports
payload["theme_uuid"] = str(model.theme.uuid) if model.theme else None
# Include role assignments (DASHBOARD_RBAC). Role IDs are
# environment-local, so emit names — the import side resolves them
# back to roles in the destination environment. The key is omitted
# entirely when there are no role restrictions; older import code
# treats "missing" as "no restriction" and an empty list could
# confuse importers that distinguish the two states.
role_names = sorted(role.name for role in (model.roles or []))
if role_names:
payload["roles"] = role_names
payload["version"] = EXPORT_VERSION
# Check if the TAGGING_SYSTEM feature is enabled

View File

@@ -281,11 +281,6 @@ def import_dashboard( # noqa: C901
# Note: theme_id handling moved to higher level import logic
# Pop roles before handing config to import_from_dict — it's a
# relationship, not a column, and the standard SQLAlchemy import path
# doesn't resolve role *names* into role objects. We re-attach below.
role_names = config.pop("roles", None)
for key, new_name in JSON_KEYS.items():
if config.get(key) is not None:
value = config.pop(key)
@@ -301,25 +296,4 @@ def import_dashboard( # noqa: C901
if (user := get_user()) and user not in dashboard.owners:
dashboard.owners.append(user)
# Re-attach DASHBOARD_RBAC role assignments by name. Role IDs are
# environment-local; names are how exports cross environments. Roles
# that don't exist in the destination are skipped with a warning
# rather than failing the import — admins may need to create them
# before the access restriction takes effect.
if isinstance(role_names, list) and role_names:
resolved_roles = []
for name in role_names:
role = security_manager.find_role(name)
if role is not None:
resolved_roles.append(role)
else:
logger.warning(
"Dashboard '%s': role %r referenced in export does not "
"exist in this environment; access restriction will not "
"be applied for that role",
dashboard.dashboard_title,
name,
)
dashboard.roles = resolved_roles
return dashboard

View File

@@ -22,7 +22,6 @@ from urllib import request
import pandas as pd
from flask import current_app as app
from pandas.errors import OutOfBoundsDatetime
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, String, Text
from sqlalchemy.exc import MultipleResultsFound
from sqlalchemy.sql.visitors import VisitableType
@@ -203,39 +202,6 @@ def import_dataset( # noqa: C901
return dataset
def _convert_temporal_columns(df: pd.DataFrame, dtype: dict[str, Any]) -> None:
"""Convert Date/DateTime columns in-place, coercing only out-of-bounds values."""
for column_name, sqla_type in dtype.items():
if isinstance(sqla_type, (Date, DateTime)):
try:
df[column_name] = pd.to_datetime(df[column_name])
except OutOfBoundsDatetime:
# Row-level fallback: coerce only OOB values; re-raise for malformed
# strings. Whole-column errors="coerce" would silently swallow
# malformed values that happen to share a column with an OOB date.
original = df[column_name].copy()
result = []
for val in original:
if pd.isna(val):
result.append(pd.NaT)
continue
try:
result.append(pd.to_datetime(val))
except OutOfBoundsDatetime:
result.append(pd.NaT)
# Other exceptions (e.g. malformed strings) propagate
converted = pd.Series(result, index=original.index)
n_coerced = int(converted.isna().sum() - original.isna().sum())
if n_coerced > 0:
logger.warning(
"Coerced %d out-of-bounds datetime value(s) "
"in column '%s' to NaT",
n_coerced,
column_name,
)
df[column_name] = converted
def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
"""
Load data from a data URI into a dataset.
@@ -256,7 +222,10 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
df = pd.read_csv(data, encoding="utf-8")
dtype = get_dtype(df, dataset)
_convert_temporal_columns(df, dtype)
# convert temporal columns
for column_name, sqla_type in dtype.items():
if isinstance(sqla_type, (Date, DateTime)):
df[column_name] = pd.to_datetime(df[column_name])
# reuse session when loading data if possible, to make import atomic
if database.sqlalchemy_uri == app.config.get("SQLALCHEMY_DATABASE_URI"):

View File

@@ -30,7 +30,6 @@ from superset.databases.ssh_tunnel.models import SSHTunnel
from superset.extensions import feature_flag_manager
from superset.models.core import Database
from superset.models.dashboard import dashboard_slices
from superset.models.helpers import SKIP_VISIBILITY_FILTER_CLASSES
from superset.tags.models import Tag, TaggedObject
from superset.utils import json
from superset.utils.core import check_is_safe_zip
@@ -401,49 +400,3 @@ def get_resource_mappings_batched(
mapping.update({str(x.uuid): value_func(x) for x in batch})
offset += batch_size
return mapping
def find_existing_for_import(model_cls: type[Any], uuid: str) -> Any | None:
"""Look up an existing row by UUID for an import, including soft-deleted matches.
Bypasses the soft-delete visibility filter so a soft-deleted row with
the matching UUID is returned, not hidden. Side-effect-free: returns
the row as-is whether it's live or soft-deleted (or ``None`` if no
row exists). The caller is responsible for deciding what to do with
a soft-deleted match — typically calling
:func:`clear_soft_deleted_for_import` to remove it before re-import,
but only after the caller has validated overwrite/permission decisions.
Splitting the lookup from the destructive cleanup keeps the
destructive action explicit at the call site, so a future change
that adds a permission check on the overwrite path doesn't
silently leave a "duck around it via soft-delete" backdoor.
"""
return (
db.session.query(model_cls)
.execution_options(**{SKIP_VISIBILITY_FILTER_CLASSES: {model_cls}})
.filter_by(uuid=uuid)
.first()
)
def clear_soft_deleted_for_import(existing: Any) -> None:
"""Hard-delete a soft-deleted row to free its UUID for re-import.
Uses ``db.session.delete()`` rather than a raw Core ``DELETE`` so
the ORM ``after_delete`` event listeners fire. Cleanup that depends
on those listeners would otherwise be skipped — notably tag rows in
``tagged_object`` (cleaned up by ``ObjectUpdater.after_delete`` in
``superset/tags/core.py``; the table's ``object_id`` is a plain
integer, not a foreign key, so the database cannot cascade them)
and dataset permission-view rows (cleaned up by
``SqlaTable.after_delete`` in ``superset/connectors/sqla/models.py``).
Caller contract: ``existing`` must be a soft-deleted row returned
from :func:`find_existing_for_import`. Callers should run their
overwrite / permission validation *before* invoking this so the
destructive action only happens once the import path is committed
to proceeding.
"""
db.session.delete(existing)
db.session.flush()

View File

@@ -1,98 +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.
"""Base class shared by all soft-delete restore commands."""
from functools import partial
from typing import Any, ClassVar, Generic, TypeVar
from superset import security_manager
from superset.commands.base import BaseCommand
from superset.exceptions import SupersetSecurityException
from superset.models.helpers import SoftDeleteMixin
from superset.utils.decorators import on_error, transaction
T = TypeVar("T", bound=SoftDeleteMixin)
class BaseRestoreCommand(BaseCommand, Generic[T]):
"""Base class for soft-delete restore commands.
Subclasses provide the entity-specific bindings as class variables —
no method override required:
- ``dao``: the DAO class (e.g. ``ChartDAO``)
- ``not_found_exc``: raised when the row doesn't exist OR isn't
soft-deleted
- ``forbidden_exc``: raised when the caller doesn't have ownership
- ``restore_failed_exc``: re-raised by the transactional wrapper
when an underlying SQLAlchemy error aborts the commit
The transactional wrapper is applied by this class's ``run()``
using ``restore_failed_exc`` as the rethrow type, so each subclass
just declares the four ClassVars and is done. There is no
subclass-managed decorator contract — earlier iterations of this
PR required subclasses to override ``run()`` purely to add a
``@transaction`` decorator, which was fragile (every new entity
rollout had to remember).
The model returned from ``validate()`` is the soft-deleted row,
type-narrowed via ``Generic[T]``. ``run()`` calls ``model.restore()``
on it (the method comes from ``SoftDeleteMixin``).
"""
dao: ClassVar[Any]
not_found_exc: ClassVar[type[Exception]]
forbidden_exc: ClassVar[type[Exception]]
restore_failed_exc: ClassVar[type[Exception]]
def __init__(self, model_uuid: str) -> None:
self._model_uuid = model_uuid
def run(self) -> None:
# Build the transactional wrapper at call time so ``on_error`` can
# reference ``self.restore_failed_exc`` — a per-subclass ClassVar
# that isn't available when this method is defined on the base.
@transaction(on_error=partial(on_error, reraise=self.restore_failed_exc))
def _perform() -> None:
model = self.validate()
model.restore()
_perform()
def validate(self) -> T: # type: ignore[override]
# ``skip_visibility_filter=True`` is the *only* bypass — the
# entity's RBAC ``base_filter`` stays in effect, matching the
# behavior of ``find_by_ids`` on the existing delete paths.
# Restore should not see rows the user cannot see in the live
# UI; ownership is then verified by ``raise_for_ownership``.
model = self.dao.find_by_id(
self._model_uuid,
id_column="uuid",
skip_visibility_filter=True,
)
if model is None:
raise self.not_found_exc(f"No row with uuid={self._model_uuid!r}")
if model.deleted_at is None:
raise self.not_found_exc(
f"Row with uuid={self._model_uuid!r} is not soft-deleted; "
"nothing to restore"
)
try:
security_manager.raise_for_ownership(model)
except SupersetSecurityException as ex:
raise self.forbidden_exc() from ex
return model

View File

@@ -51,7 +51,7 @@ from sqlalchemy.orm.query import Query
from superset.advanced_data_type.plugins.internet_address import internet_address
from superset.advanced_data_type.plugins.internet_port import internet_port
from superset.advanced_data_type.types import AdvancedDataType
from superset.constants import CHANGE_ME_GUEST_TOKEN_JWT_SECRET, CHANGE_ME_SECRET_KEY
from superset.constants import CHANGE_ME_SECRET_KEY
from superset.jinja_context import BaseTemplateProcessor
from superset.key_value.types import JsonKeyValueCodec
from superset.stats_logger import DummyStatsLogger
@@ -2354,7 +2354,7 @@ GLOBAL_ASYNC_QUERIES_CACHE_BACKEND = {
# Embedded config options
GUEST_ROLE_NAME = "Public"
GUEST_TOKEN_JWT_SECRET = CHANGE_ME_GUEST_TOKEN_JWT_SECRET
GUEST_TOKEN_JWT_SECRET = "test-guest-secret-change-me" # noqa: S105
GUEST_TOKEN_JWT_ALGO = "HS256" # noqa: S105
GUEST_TOKEN_HEADER_NAME = "X-GuestToken" # noqa: S105
GUEST_TOKEN_JWT_EXP_SECONDS = 300 # 5 minutes

Some files were not shown because too many files have changed in this diff Show More