mirror of
https://github.com/apache/superset.git
synced 2026-08-03 20:42:30 +00:00
Compare commits
11 Commits
dependabot
...
mobile-das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfb778bcd | ||
|
|
4eaffae7a0 | ||
|
|
441b62bd26 | ||
|
|
a0a427c5e6 | ||
|
|
7b480d2682 | ||
|
|
1e230f19f9 | ||
|
|
e472e04b5a | ||
|
|
bd2a495094 | ||
|
|
e9298b24ae | ||
|
|
2beb86859f | ||
|
|
f9da2f93de |
1
.github/actions/chart-releaser-action
vendored
Submodule
1
.github/actions/chart-releaser-action
vendored
Submodule
Submodule .github/actions/chart-releaser-action added at a917fd15b2
7
.github/workflows/check-python-deps.yml
vendored
7
.github/workflows/check-python-deps.yml
vendored
@@ -11,7 +11,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
@@ -22,10 +21,6 @@ jobs:
|
||||
check-python-deps:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -51,7 +46,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
if: steps.check.outputs.python
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
63
.github/workflows/dependabot-auto-approve.yml
vendored
63
.github/workflows/dependabot-auto-approve.yml
vendored
@@ -1,63 +0,0 @@
|
||||
name: Auto-approve Dependabot patch bumps
|
||||
|
||||
# Posts an approving review on Dependabot PRs that only bump a patch
|
||||
# version, using the same trigger/guard convention already proven to work
|
||||
# for Dependabot PRs in sync-requirements-for-python-dep-upgrade-pr.yml
|
||||
# (plain `pull_request` gets a working, write-capable GITHUB_TOKEN here
|
||||
# because Dependabot pushes branches directly to this repo, not a fork).
|
||||
#
|
||||
# This does NOT auto-merge anything - repo-wide auto-merge is disabled
|
||||
# (Settings > General > Pull Requests > "Allow auto-merge" is off), and
|
||||
# flipping that is a separate, repo-wide decision this workflow doesn't
|
||||
# make on its own. Branch protection also still requires 1 approving
|
||||
# review; this just means that review can already exist by the time a
|
||||
# human looks at the PR, for the (large majority of) ecosystems whose
|
||||
# files aren't matched by any CODEOWNERS pattern. One ecosystem - the npm
|
||||
# bump under .github/actions - matches the /.github/ CODEOWNERS entry, so
|
||||
# those PRs will still need a human owner's approval regardless of this
|
||||
# workflow; it posts a review there too, but that alone won't satisfy the
|
||||
# code-owner requirement.
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize]
|
||||
|
||||
# Cancel a superseded run if Dependabot pushes to the same PR again before
|
||||
# the previous run finished (matches the pattern used elsewhere in
|
||||
# superset-docs-verify.yml).
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
approve-patch-bump:
|
||||
name: Approve patch-level bump
|
||||
# Mirrors the guard in sync-requirements-for-python-dep-upgrade-pr.yml:
|
||||
# limited to (1) PRs authored by Dependabot and (2) the upstream repo,
|
||||
# since forked PRs don't get a write-capable token here anyway.
|
||||
if: >
|
||||
github.repository == 'apache/superset' &&
|
||||
github.event.pull_request.user.login == 'dependabot[bot]' &&
|
||||
github.event.pull_request.head.repo.fork == false
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write # to post the approving review via `gh pr review`
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: metadata
|
||||
# This exact SHA is on ASF Infra's action allowlist
|
||||
# (apache/infrastructure-actions approved_patterns.yml) as of this
|
||||
# writing. Do not bump without opening an Infra ticket to allow
|
||||
# the new SHA first!
|
||||
uses: dependabot/fetch-metadata@25dd0e34f4fe68f24cc83900b1fe3fe149efef98 # v3.1.0
|
||||
|
||||
- name: Approve patch-level bump
|
||||
if: steps.metadata.outputs.update-type == 'version-update:semver-patch'
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
DEPENDENCY_NAMES: ${{ steps.metadata.outputs.dependency-names }}
|
||||
run: |
|
||||
gh pr review --approve "$PR_URL" \
|
||||
--body "Auto-approved: patch-level bump only ($DEPENDENCY_NAMES)."
|
||||
5
.github/workflows/dependency-review.yml
vendored
5
.github/workflows/dependency-review.yml
vendored
@@ -20,7 +20,6 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
dependency-review:
|
||||
@@ -50,10 +49,6 @@ jobs:
|
||||
# You cannot use a liccheck.ini file in this workflow.
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
@@ -213,14 +213,3 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
docker compose -f docker-compose-image-tag.yml up superset-init --exit-code-from superset-init
|
||||
|
||||
actions-timeline:
|
||||
needs: [docker-build, docker-compose-image-tag]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
54
.github/workflows/label-merge-conflicts.yml
vendored
54
.github/workflows/label-merge-conflicts.yml
vendored
@@ -1,54 +0,0 @@
|
||||
name: Label Merge Conflicts
|
||||
|
||||
# Sweeps every open PR and labels the ones GitHub reports as CONFLICTING with
|
||||
# `requires:rebase` (removing it once a rebase makes the PR mergeable again),
|
||||
# so the label can be used to filter the PR backlog for the ones that need a
|
||||
# rebase before they can be reviewed/merged.
|
||||
#
|
||||
# The action itself always re-checks *every* open PR via GraphQL on each run
|
||||
# regardless of what triggered it (see eps1lon/actions-label-merge-conflict's
|
||||
# sources/main.ts) - there's no way to scope it to "just this PR". The
|
||||
# project's own README suggests triggering on `push` (to the default branch)
|
||||
# plus `pull_request_target: [synchronize]`, but on a repo with Superset's PR
|
||||
# volume that combination would re-sweep the entire open-PR list on every
|
||||
# merge to master *and* every push to *any* open PR - many times an hour.
|
||||
# A schedule bounds that to a fixed, predictable cadence instead; adjust it
|
||||
# if 2 hours turns out to be too slow or too chatty in practice.
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 */2 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
# Avoid two full backlog sweeps racing (a manual workflow_dispatch landing
|
||||
# mid-schedule-tick, say); queue rather than cancel so an in-progress
|
||||
# paginated sweep always runs to completion.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
label-merge-conflicts:
|
||||
# Scheduled/dispatch workflows still run on forks that carry this file;
|
||||
# skip anywhere but the canonical repo.
|
||||
if: github.repository == 'apache/superset'
|
||||
name: Label Merge Conflicts
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write # to add/remove requires:rebase and need:merge
|
||||
steps:
|
||||
# ASF Infra allowlists this whole action via a wildcard
|
||||
# (eps1lon/actions-label-merge-conflict@*), so any pinned SHA/version
|
||||
# is already fine here - no Infra ticket needed for future bumps.
|
||||
- uses: eps1lon/actions-label-merge-conflict@0273be72a0bbd58fcd71d0d6c02c209b50d1e5e1 # v3.1.0
|
||||
with:
|
||||
dirtyLabel: "requires:rebase"
|
||||
# A conflicting PR isn't actually ready to merge; strip that signal
|
||||
# if it was previously set so reviewers don't act on a stale one.
|
||||
removeOnDirtyLabel: "need:merge"
|
||||
repoToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# Intentionally no commentOnDirty/commentOnClean: the label alone is
|
||||
# the signal (matches the label's existing description, and avoids
|
||||
# a one-time comment storm across the whole backlog on first run).
|
||||
113
.github/workflows/mirror-service-images.yml
vendored
113
.github/workflows/mirror-service-images.yml
vendored
@@ -1,113 +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.
|
||||
#
|
||||
# Mirror the Docker Hub service-container images that CI relies on into the
|
||||
# repository's GitHub Container Registry (GHCR) namespace.
|
||||
#
|
||||
# WHY: CI jobs declare Postgres/MySQL/Redis/Presto as `services:` containers
|
||||
# pulled anonymously from Docker Hub. Anonymous pulls share the runner's IP
|
||||
# rate limit, which causes intermittent timeouts / 429s / 502s on `master`
|
||||
# and same-repo PRs. The obvious fix — adding `credentials:` to the service
|
||||
# blocks — breaks fork PRs hard: forks can't read secrets, so the templated
|
||||
# username/password resolve to '' and GitHub rejects the workflow at parse
|
||||
# time ("Unexpected value ''"), failing every fork job at "Set up job".
|
||||
#
|
||||
# Mirroring to GHCR sidesteps both problems: public GHCR images are pulled
|
||||
# without Docker Hub's anonymous rate limit AND without any credentials, so
|
||||
# the consuming workflows need no `credentials:` block and forks work
|
||||
# unchanged.
|
||||
#
|
||||
# ONE-TIME BOOTSTRAP (maintainer, after this lands on the default branch):
|
||||
# 1. Run this workflow once (Actions tab → "Mirror service images to GHCR"
|
||||
# → Run workflow), or wait for the weekly schedule.
|
||||
# 2. In the org's Packages settings, set each mirrored package's visibility
|
||||
# to **public** (apache/superset → ci/postgres, ci/mysql, ci/redis,
|
||||
# ci/presto). Public visibility is what lets fork CI pull without auth.
|
||||
# 3. Only then merge the follow-up that repoints the `services.*.image`
|
||||
# refs at these GHCR copies and drops the `credentials:` blocks.
|
||||
#
|
||||
# NOTE: this mirrors only the images declared as `services:` containers (the
|
||||
# ones that broke forks). The `bde2020` hive-metastore image pulled via
|
||||
# `docker compose` in the Presto/Hive job is a separate path and is left for
|
||||
# a follow-up.
|
||||
|
||||
name: Mirror service images to GHCR
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Weekly, Monday 06:00 UTC — keeps the mirror fresh as upstream tags move.
|
||||
- cron: "0 6 * * 1"
|
||||
workflow_dispatch: {}
|
||||
|
||||
concurrency:
|
||||
group: mirror-service-images
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
# Never run on forks: they lack both the secrets and write access to the
|
||||
# apache GHCR namespace, so a scheduled run there would only ever fail.
|
||||
if: github.repository == 'apache/superset'
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Keep this list in sync with the `services.*.image` refs in
|
||||
# superset-e2e.yml, superset-python-integrationtest.yml, and
|
||||
# superset-python-presto-hive.yml.
|
||||
image:
|
||||
- postgres:17-alpine
|
||||
- redis:7-alpine
|
||||
- mysql:8.0
|
||||
- starburstdata/presto:350-e.6
|
||||
steps:
|
||||
- name: Log in to Docker Hub (authenticated source pulls)
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR (push target)
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||
|
||||
- name: Copy image to GHCR
|
||||
env:
|
||||
# Pass the matrix value through the environment rather than
|
||||
# interpolating it into the shell, to avoid template injection.
|
||||
SRC_IMAGE: ${{ matrix.image }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Destination keeps the image's short name (drop any namespace),
|
||||
# under a `ci/` prefix in this repo's GHCR namespace.
|
||||
name="${SRC_IMAGE##*/}"
|
||||
dst="ghcr.io/${GITHUB_REPOSITORY}/ci/${name}"
|
||||
echo "Mirroring docker.io/${SRC_IMAGE} -> ${dst}"
|
||||
# imagetools copies the full (multi-arch) manifest registry-to-
|
||||
# registry without a local pull/retag/push round trip.
|
||||
docker buildx imagetools create --tag "${dst}" "docker.io/${SRC_IMAGE}"
|
||||
echo "- \`docker.io/${SRC_IMAGE}\` → \`${dst}\`" >> "${GITHUB_STEP_SUMMARY}"
|
||||
10
.github/workflows/pre-commit.yml
vendored
10
.github/workflows/pre-commit.yml
vendored
@@ -15,7 +15,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
@@ -189,12 +188,3 @@ jobs:
|
||||
echo "📖 More details here: https://superset.apache.org/docs/contributing/development#git-hooks"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
actions-timeline:
|
||||
needs: pre-commit
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
5
.github/workflows/superset-app-cli.yml
vendored
5
.github/workflows/superset-app-cli.yml
vendored
@@ -11,7 +11,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
@@ -41,10 +40,6 @@ jobs:
|
||||
ports:
|
||||
- 16379:6379
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
5
.github/workflows/superset-docs-deploy.yml
vendored
5
.github/workflows/superset-docs-deploy.yml
vendored
@@ -30,7 +30,6 @@ concurrency:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
config:
|
||||
@@ -60,10 +59,6 @@ jobs:
|
||||
name: Build & Deploy
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: "Checkout ${{ github.event.workflow_run.head_sha || github.sha }}"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
|
||||
11
.github/workflows/superset-e2e.yml
vendored
11
.github/workflows/superset-e2e.yml
vendored
@@ -340,14 +340,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "playwright-tests result: $RESULT (changes: $CHANGES)"
|
||||
|
||||
actions-timeline:
|
||||
needs: [cypress-matrix, playwright-tests, cypress-matrix-required, playwright-tests-required]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
10
.github/workflows/superset-extensions-cli.yml
vendored
10
.github/workflows/superset-extensions-cli.yml
vendored
@@ -11,7 +11,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
actions: read
|
||||
|
||||
# cancel previous workflow jobs for PRs
|
||||
concurrency:
|
||||
@@ -70,12 +69,3 @@ jobs:
|
||||
with:
|
||||
name: superset-extensions-cli-coverage-html
|
||||
path: htmlcov/
|
||||
|
||||
actions-timeline:
|
||||
needs: test-superset-extensions-cli-package
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
11
.github/workflows/superset-frontend.yml
vendored
11
.github/workflows/superset-frontend.yml
vendored
@@ -201,14 +201,3 @@ jobs:
|
||||
run: |
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm run build-storybook && npx playwright install-deps && npx playwright install chromium && npm run test-storybook:ci"
|
||||
|
||||
actions-timeline:
|
||||
needs: [report-coverage, lint-frontend, validate-frontend, test-storybook]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
10
.github/workflows/superset-helm-lint-test.yml
vendored
10
.github/workflows/superset-helm-lint-test.yml
vendored
@@ -8,7 +8,6 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
# Serialize runs per PR without cancelling: when a first-time contributor's
|
||||
# queued runs are approved together, cancel-in-progress lets an older run
|
||||
@@ -22,10 +21,6 @@ jobs:
|
||||
lint-test:
|
||||
runs-on: ubuntu-26.04
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -43,11 +38,6 @@ jobs:
|
||||
with:
|
||||
install-superset: "false"
|
||||
|
||||
# Still vendored (not de-vendored like chart-releaser-action below): the
|
||||
# allowlisted helm/chart-testing-action@v2.8.0 depends internally on
|
||||
# astral-sh/setup-uv@v7.0.0, which isn't itself on the ASF Actions
|
||||
# allowlist (only v8.1.0+ are, at apache/infrastructure-actions'
|
||||
# actions.yml). Needs an INFRA request before this can de-vendor too.
|
||||
- name: Set up chart-testing
|
||||
uses: ./.github/actions/chart-testing-action
|
||||
|
||||
|
||||
7
.github/workflows/superset-helm-release.yml
vendored
7
.github/workflows/superset-helm-release.yml
vendored
@@ -95,8 +95,13 @@ jobs:
|
||||
# Return to the original branch
|
||||
git checkout local_gha_temp
|
||||
|
||||
- name: Fetch/list all tags
|
||||
run: |
|
||||
git submodule update
|
||||
cat .github/actions/chart-releaser-action/action.yml
|
||||
|
||||
- name: Run chart-releaser
|
||||
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
|
||||
uses: ./.github/actions/chart-releaser-action
|
||||
with:
|
||||
version: v1.6.0
|
||||
charts_dir: helm
|
||||
|
||||
22
.github/workflows/superset-playwright.yml
vendored
22
.github/workflows/superset-playwright.yml
vendored
@@ -155,6 +155,17 @@ jobs:
|
||||
INCLUDE_EMBEDDED: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" embedded
|
||||
- name: Run Playwright (Mobile Tests)
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
env:
|
||||
NODE_OPTIONS: "--max-old-space-size=4096"
|
||||
# Scoped to this step for the same reason as the embedded flags
|
||||
# above: the mobile consumption mode should not alter Flask's
|
||||
# configuration for the required desktop test steps.
|
||||
SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE: "true"
|
||||
INCLUDE_MOBILE: "true"
|
||||
with:
|
||||
run: playwright-run "${{ matrix.app_root }}" mobile/
|
||||
- name: Set safe app root
|
||||
if: failure()
|
||||
id: set-safe-app-root
|
||||
@@ -170,14 +181,3 @@ jobs:
|
||||
${{ github.workspace }}/superset-frontend/playwright-results/
|
||||
${{ github.workspace }}/superset-frontend/test-results/
|
||||
name: playwright-experimental-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}
|
||||
|
||||
actions-timeline:
|
||||
needs: playwright-tests-experimental
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -255,14 +255,3 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
echo "test-postgres result: $RESULT"
|
||||
|
||||
actions-timeline:
|
||||
needs: [test-mysql, test-postgres, test-sqlite, test-postgres-required]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -158,14 +158,3 @@ jobs:
|
||||
verbose: true
|
||||
use_oidc: true
|
||||
slug: apache/superset
|
||||
|
||||
actions-timeline:
|
||||
needs: [test-postgres-presto, test-postgres-hive]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -101,14 +101,7 @@ jobs:
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
- name: Check unit-tests result
|
||||
env:
|
||||
RESULT: ${{ needs.unit-tests.result }}
|
||||
|
||||
11
.github/workflows/superset-translations.yml
vendored
11
.github/workflows/superset-translations.yml
vendored
@@ -153,14 +153,3 @@ jobs:
|
||||
- name: Fail if regression detected
|
||||
if: steps.regression.outcome == 'failure'
|
||||
run: exit 1
|
||||
|
||||
actions-timeline:
|
||||
needs: [frontend-check-translations, babel-extract]
|
||||
if: always()
|
||||
runs-on: ubuntu-26.04
|
||||
permissions:
|
||||
actions: read
|
||||
steps:
|
||||
- uses: Kesin11/actions-timeline@7bf79990b7c09f5dfb570ac30b814ca597bd538e # v3.1.1
|
||||
with:
|
||||
expand-composite-actions: true
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.dependabot-metadata.outputs.package-ecosystem == 'pip' }}
|
||||
continue-on-error: true
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USER }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
3
.gitmodules
vendored
3
.gitmodules
vendored
@@ -30,6 +30,9 @@
|
||||
[submodule ".github/actions/chart-testing-action"]
|
||||
path = .github/actions/chart-testing-action
|
||||
url = https://github.com/helm/chart-testing-action
|
||||
[submodule ".github/actions/chart-releaser-action"]
|
||||
path = .github/actions/chart-releaser-action
|
||||
url = https://github.com/helm/chart-releaser-action
|
||||
[submodule ".github/actions/github-action-push-to-another-repository"]
|
||||
path = .github/actions/github-action-push-to-another-repository
|
||||
url = https://github.com/cpina/github-action-push-to-another-repository
|
||||
|
||||
@@ -423,7 +423,7 @@ git push origin ${SUPERSET_VERSION}
|
||||
|
||||
### Publishing a Convenience Release to PyPI
|
||||
|
||||
Extract the release to the `/tmp` folder to build the PyPI release. Files in the `/tmp` folder will be automatically deleted by the OS.
|
||||
Extract the release to the `/tmp` folder to build the PiPY release. Files in the `/tmp` folder will be automatically deleted by the OS.
|
||||
|
||||
```bash
|
||||
mkdir -p /tmp/superset && cd /tmp/superset
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# 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
|
||||
@@ -17,6 +15,8 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
#!/bin/bash
|
||||
|
||||
# Function to determine Python command
|
||||
get_python_command() {
|
||||
if command -v python3 &>/dev/null; then
|
||||
|
||||
@@ -38,7 +38,7 @@ RESET='\033[0m'
|
||||
echo -e "${GREEN}Updating package lists...${RESET}"
|
||||
apt-get update -qq
|
||||
|
||||
echo -e "${GREEN}Installing packages: $*${RESET}"
|
||||
echo -e "${GREEN}Installing packages: $@${RESET}"
|
||||
apt-get install -yqq --no-install-recommends "$@"
|
||||
|
||||
echo -e "${GREEN}Autoremoving unnecessary packages...${RESET}"
|
||||
|
||||
@@ -118,6 +118,7 @@ FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"DATASET_FOLDERS": True,
|
||||
"ENABLE_EXTENSIONS": True,
|
||||
"MOBILE_CONSUMPTION_MODE": True,
|
||||
"SEMANTIC_LAYERS": True,
|
||||
}
|
||||
EXTENSIONS_PATH = "/app/docker/extensions"
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -112,61 +112,12 @@ USER superset
|
||||
CMD ["/app/docker/entrypoints/run-server.sh"]
|
||||
```
|
||||
|
||||
### Adding translations to a custom image
|
||||
|
||||
The pattern above, a small Dockerfile that just extends `FROM apache/superset:...`, can't add
|
||||
translations after the fact. By the time an official tag is published, its frontend and backend
|
||||
layers have already had non-English translation files stripped out unless `BUILD_TRANSLATIONS`
|
||||
was set at build time (see below), and there's no `superset/translations` source tree left in the
|
||||
final image to compile from.
|
||||
|
||||
To get translations into your own image, you need to build from the full Superset source (a
|
||||
clone or fork of this repo) rather than extend a published tag. The most efficient way to do this
|
||||
is to append your customizations as one more stage at the end of the repo's own `Dockerfile`, so
|
||||
Docker can reuse the cached upstream layers and only rebuild what your stage adds:
|
||||
|
||||
```Dockerfile
|
||||
# Append this to the end of the repo's Dockerfile
|
||||
# Keep this tag in sync with the branch/tag of the repo you cloned, so the
|
||||
# translation files built from source match the keys the runtime expects:
|
||||
FROM apache/superset:5.0.0 AS my-custom-image
|
||||
USER root
|
||||
|
||||
# Pull the translation files out of the earlier build stages (frontend
|
||||
# .json in `superset-node`, backend .mo in `python-translation-compiler`).
|
||||
# Those stages' own cleanup only matches single-character extensions, so
|
||||
# the source `.po` files can still be present here; strip them explicitly
|
||||
# so this stage only keeps the compiled translations.
|
||||
COPY --from=superset-node /app/superset/translations superset/translations
|
||||
COPY --from=python-translation-compiler /app/translations_mo superset/translations
|
||||
RUN find superset/translations -name '*.po' -delete
|
||||
|
||||
USER superset
|
||||
```
|
||||
|
||||
Then build with:
|
||||
|
||||
```bash
|
||||
docker build --target=my-custom-image --build-arg=BUILD_TRANSLATIONS=true -t mysuperset:5.0.0 .
|
||||
```
|
||||
|
||||
You can combine this with the database-driver/dependency pattern above by adding your own
|
||||
`RUN uv pip install ...` step before switching back to `USER superset`. See
|
||||
[issue #35959](https://github.com/apache/superset/issues/35959) for the discussion this pattern
|
||||
came out of, credit to the community for working it out.
|
||||
|
||||
## Key ARGs in Dockerfile
|
||||
|
||||
- `BUILD_TRANSLATIONS`: whether to compile non-English translations into the image.
|
||||
When `true`, the frontend build converts the `*.po` files to locale JSON and the
|
||||
backend runs `pybabel compile` to produce `*.mo` files; both source `*.po` files
|
||||
are stripped afterward either way. When `false` (the default), those compile
|
||||
steps are skipped and only `en` ships. This only takes effect when building the image from source
|
||||
(`docker build` against this repo's own `Dockerfile`); it has no effect on a downstream
|
||||
Dockerfile that just extends an already-published tag, see
|
||||
"Adding translations to a custom image" above. Note that the backend `pybabel compile`
|
||||
step ignores its exit code, so a `.po` file with a compile error won't fail the build;
|
||||
check the build logs for `pybabel` warnings if a locale's backend strings aren't showing up.
|
||||
- `BUILD_TRANSLATIONS`: whether to build the translations into the image. For the
|
||||
frontend build this tells webpack to strip out all locales other than `en` from
|
||||
the `moment-timezone` library. For the backendthis skips compiling the
|
||||
`*.po` translation files
|
||||
- `DEV_MODE`: whether to skip the frontend build, this is used by our `docker-compose` dev setup
|
||||
where we mount the local volume and build using `webpack` in `--watch` mode, meaning as you
|
||||
alter the code in the local file system, webpack, from within a docker image used for this
|
||||
|
||||
89
docs/docs/using-superset/mobile-experience.mdx
Normal file
89
docs/docs/using-superset/mobile-experience.mdx
Normal file
@@ -0,0 +1,89 @@
|
||||
---
|
||||
title: Mobile Experience
|
||||
sidebar_position: 7
|
||||
version: 1
|
||||
---
|
||||
|
||||
import useBaseUrl from "@docusaurus/useBaseUrl";
|
||||
|
||||
# Mobile Experience
|
||||
|
||||
Superset ships an optional, consumption-only mobile experience for viewing
|
||||
dashboards on phones and other small screens. When enabled, screens below
|
||||
768px wide get a layout built for touch: dashboards render their charts
|
||||
stacked full-width, navigation collapses into a drawer, and dashboard
|
||||
filters open in a slide-out panel.
|
||||
|
||||
The mobile experience is **read-only by design**. It is aimed at consumers
|
||||
of analytics — people checking a dashboard from a phone — not at dashboard
|
||||
authors. Authoring surfaces (chart builder, SQL Lab, dataset management,
|
||||
and administrative screens) remain desktop-only.
|
||||
|
||||
## Enabling the mobile experience
|
||||
|
||||
The mobile experience is gated behind the `MOBILE_CONSUMPTION_MODE` feature
|
||||
flag, which is off by default. Enable it in your `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"MOBILE_CONSUMPTION_MODE": True,
|
||||
}
|
||||
```
|
||||
|
||||
With the flag disabled, Superset renders identically at every screen size,
|
||||
and phones display the desktop layout scaled down (the pre-existing
|
||||
behavior). The flag also controls whether Superset serves a viewport meta
|
||||
tag, which is required for mobile browsers to apply the responsive layout
|
||||
at their native width.
|
||||
|
||||
## What works on mobile
|
||||
|
||||
| Area | Mobile behavior |
|
||||
| --- | --- |
|
||||
| **Dashboards** | Charts stack vertically at full width, sized to the screen. Tab bars are sticky and swipeable. Native filters open in a drawer via the filter icon in the header. |
|
||||
| **Dashboard list** | Card view with full-width cards; search and filters open in a drawer. |
|
||||
| **Home** | Recents (dashboards only) and dashboard cards; desktop-only sections are hidden. |
|
||||
| **Navigation** | A hamburger menu opens a drawer with links to dashboards, theme and language selection, and user info/logout. |
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard.jpg")} alt="A dashboard on mobile with charts stacked full width" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_filter_drawer.jpg")} alt="The dashboard filter drawer on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_dashboard_list.jpg")} alt="The dashboard list in card view on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
<div style={{display: 'flex', gap: '1rem', flexWrap: 'wrap', marginTop: '1rem'}}>
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_home.jpg")} alt="The Superset home page on mobile" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_nav_drawer.jpg")} alt="The mobile navigation drawer" width="260" />
|
||||
<img src={useBaseUrl("/img/screenshots/mobile/mobile_unsupported.jpg")} alt="The screen shown for views that are not available on mobile" width="260" />
|
||||
</div>
|
||||
|
||||
## What doesn't work on mobile
|
||||
|
||||
Everything not listed above shows a friendly "This view isn't available on
|
||||
mobile" screen with shortcuts back to dashboards and the home page. That
|
||||
includes:
|
||||
|
||||
- Chart builder (Explore) and chart-level links — chart titles on
|
||||
dashboards are plain text on mobile, and chart entries are filtered out
|
||||
of the home page's Recents feed
|
||||
- SQL Lab and query history
|
||||
- Creating or editing dashboards, charts, datasets, and databases
|
||||
- List views other than dashboards (charts, datasets, saved queries, etc.)
|
||||
- Administrative and settings screens
|
||||
|
||||
Editing controls are also removed from the screens that *are* supported:
|
||||
the dashboard header hides the edit, publish, and favorite controls, and
|
||||
dashboard/chart kebab menus are reduced to view-oriented actions.
|
||||
|
||||
If a device crosses the 768px threshold — for example, rotating a tablet
|
||||
to landscape or resizing a window — the full desktop experience becomes
|
||||
available immediately.
|
||||
|
||||
## Notes for operators
|
||||
|
||||
- The flag is deployment-wide; there is no per-role or per-user targeting.
|
||||
- Dashboard permalinks and links shared from desktop resolve normally on
|
||||
mobile as long as they point at dashboards.
|
||||
- Embedded dashboards are unaffected: the embedded SDK controls its own
|
||||
layout, and the viewport meta tag is only interpreted by the top-level
|
||||
page.
|
||||
@@ -61,7 +61,7 @@
|
||||
"@storybook/addon-docs": "^10.5.3",
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.46",
|
||||
"antd": "^6.5.2",
|
||||
"antd": "^6.5.1",
|
||||
"baseline-browser-mapping": "^2.11.1",
|
||||
"caniuse-lite": "^1.0.30001806",
|
||||
"docusaurus-plugin-openapi-docs": "^5.1.2",
|
||||
@@ -99,7 +99,7 @@
|
||||
"prettier": "^3.9.6",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.65.0",
|
||||
"webpack": "^5.109.0"
|
||||
"webpack": "^5.108.2"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
6
docs/static/feature-flags.json
vendored
6
docs/static/feature-flags.json
vendored
@@ -69,6 +69,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Enable Matrixify feature for matrix-style chart layouts"
|
||||
},
|
||||
{
|
||||
"name": "MOBILE_CONSUMPTION_MODE",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Serve a consumption-only mobile experience (dashboards, dashboard list, and home page) on small screens; other views show a \"not supported on mobile\" screen. Authoring features are hidden on mobile when enabled."
|
||||
},
|
||||
{
|
||||
"name": "OPTIMIZE_SQL",
|
||||
"default": false,
|
||||
|
||||
BIN
docs/static/img/screenshots/mobile/mobile_dashboard.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_dashboard.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_dashboard_list.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_filter_drawer.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 57 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_home.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_home.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_nav_drawer.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/static/img/screenshots/mobile/mobile_unsupported.jpg
vendored
Normal file
BIN
docs/static/img/screenshots/mobile/mobile_unsupported.jpg
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -3779,7 +3779,7 @@
|
||||
"@rc-component/virtual-list" "^1.2.0"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.1", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
|
||||
"@rc-component/trigger@^3.0.0", "@rc-component/trigger@^3.10.0", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.1":
|
||||
version "3.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-3.10.1.tgz#cb28e1bc0745a2af6897dd7ec774f9b56dc88f86"
|
||||
integrity sha512-mXlDN0IXdtV8Yqqm8195ECCyrbmfvvfKvwVvSlH0+qvKD6BUF8gRhEjSy0FOcD1+CcDRHgTiX99LoxfQrmh3Cw==
|
||||
@@ -3798,13 +3798,13 @@
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
|
||||
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.12.0", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.12.0.tgz#58e453585810bcb8a35ff1aafd5e01187457b86f"
|
||||
integrity sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==
|
||||
"@rc-component/util@^1.10.1", "@rc-component/util@^1.11.0", "@rc-component/util@^1.11.1", "@rc-component/util@^1.2.0", "@rc-component/util@^1.2.1", "@rc-component/util@^1.3.0", "@rc-component/util@^1.4.0", "@rc-component/util@^1.7.0", "@rc-component/util@^1.9.0":
|
||||
version "1.11.1"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/util/-/util-1.11.1.tgz#07d698908339c55648e4f974afa739345e65b483"
|
||||
integrity sha512-awVlI3ub2vqfqkYxOBc/uQ0efm3jw0wcrhtO/YWLyZfxiKXczKwNbVuhlnyxytDt7H9pbbVQiqr+O6MLATtRYg==
|
||||
dependencies:
|
||||
is-mobile "^5.0.0"
|
||||
react-is "^19.2.7"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@rc-component/virtual-list@^1.0.1", "@rc-component/virtual-list@^1.2.0":
|
||||
version "1.2.0"
|
||||
@@ -5835,6 +5835,11 @@ acorn-dynamic-import@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/acorn-dynamic-import/-/acorn-dynamic-import-4.0.0.tgz#482210140582a36b83c3e342e1cfebcaa9240948"
|
||||
integrity sha512-d3OEjQV4ROpoflsnUA8HozoIR504TFxNivYEUi6uwz0IYhBkTDXGuWlNdMtybRt3nqVx/L6XqMt0FxkXuWKZhw==
|
||||
|
||||
acorn-import-phases@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
|
||||
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
|
||||
|
||||
acorn-jsx@^5.0.0, acorn-jsx@^5.0.1, acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
@@ -6023,10 +6028,10 @@ ansis@^3.2.0:
|
||||
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
|
||||
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
|
||||
|
||||
antd@^6.5.2:
|
||||
version "6.5.2"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.2.tgz#d771211beddf539f37303f862df24e8aaf32b7c4"
|
||||
integrity sha512-ntYx0lr4Jq192QnBkDWkDqEeoberXZ34vSE9SgiP/0J6DY8O0pzR3bVZLBsdpCSguVkwjtEAP+QNeMN7LNAvgw==
|
||||
antd@^6.5.1:
|
||||
version "6.5.1"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-6.5.1.tgz#2623db1f3b0ae32a2e311ef20f2f1413934d0094"
|
||||
integrity sha512-VZVVF9zYI6S0NHqboVhCoY9Iiqj6dphW1NPB+sEaAf2HuIQ0haXWXj7ZvAXTRDzusktV6+cvvrSZEdRi4twATg==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^8.0.1"
|
||||
"@ant-design/cssinjs" "^2.1.2"
|
||||
@@ -6068,9 +6073,9 @@ antd@^6.5.2:
|
||||
"@rc-component/tour" "~2.4.0"
|
||||
"@rc-component/tree" "~1.3.2"
|
||||
"@rc-component/tree-select" "~1.11.0"
|
||||
"@rc-component/trigger" "^3.10.1"
|
||||
"@rc-component/trigger" "^3.10.0"
|
||||
"@rc-component/upload" "~1.1.1"
|
||||
"@rc-component/util" "^1.12.0"
|
||||
"@rc-component/util" "^1.11.1"
|
||||
clsx "^2.1.1"
|
||||
dayjs "^1.11.11"
|
||||
scroll-into-view-if-needed "^3.1.0"
|
||||
@@ -8061,10 +8066,10 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.24.2:
|
||||
version "5.24.5"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz#b4dad3255b7545f07ba5535189868e9f85f47573"
|
||||
integrity sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==
|
||||
enhanced-resolve@^5.22.2:
|
||||
version "5.24.1"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.24.1.tgz#b2439adf5d31d7e4764de1f9ecf942d6cd3fc874"
|
||||
integrity sha512-7DdUaTjmNwMcH2gLr1qycesKII3BK4RLy/mdAb7x10Lq7bR4aNKHt1BR1ZALSv0rPM/hF5wYF0PhGop/rJm8vw==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.3.3"
|
||||
@@ -10426,6 +10431,11 @@ liquid-json@0.3.1:
|
||||
resolved "https://registry.yarnpkg.com/liquid-json/-/liquid-json-0.3.1.tgz#9155a18136d8a6b2615e5f16f9a2448ab6b50eea"
|
||||
integrity sha512-wUayTU8MS827Dam6MxgD72Ui+KOSF+u/eIqpatOtjnvgJ0+mnDq33uC2M7J0tPK+upe/DpUAuK4JUU89iBoNKQ==
|
||||
|
||||
loader-runner@^4.3.2:
|
||||
version "4.3.2"
|
||||
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.2.tgz#9913d3a15971f8f635915e601fb5c9d495d918e9"
|
||||
integrity sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==
|
||||
|
||||
loader-utils@^2.0.0:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-2.0.4.tgz#8b5cb38b5c34a9a018ee1fc0e6a066d1dfcc528c"
|
||||
@@ -13317,16 +13327,11 @@ react-is@^17.0.1:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
|
||||
integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
|
||||
|
||||
react-is@^18.0.0:
|
||||
react-is@^18.0.0, react-is@^18.2.0:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
|
||||
react-is@^19.2.7:
|
||||
version "19.2.8"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-19.2.8.tgz#09826f9fbc187bc668e3e5c62edc001f804d5018"
|
||||
integrity sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==
|
||||
|
||||
react-json-view-lite@^2.3.0:
|
||||
version "2.5.0"
|
||||
resolved "https://registry.yarnpkg.com/react-json-view-lite/-/react-json-view-lite-2.5.0.tgz#c7ff011c7cc80e9900abc7aa4916c6a5c6d6c1c6"
|
||||
@@ -15850,20 +15855,20 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.5.1:
|
||||
version "3.5.1"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.1.tgz#76c2418486dcc02b2aa0694c104176c2858fe84a"
|
||||
integrity sha512-jyuiGJdtvY434z5bUZrjz67v76/ePNvFZTp9Mdz29IlH4+GPsgyGjiv0fKI+M7BdkU6ADjulUcKAd3tUK3WlEw==
|
||||
webpack-sources@^3.5.0:
|
||||
version "3.5.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.5.0.tgz#87bf7f5801a4e985b1f1c92b64b9620a02f76d08"
|
||||
integrity sha512-HPuy+uuoTCaaoEoI1LQ3JN9+vrPBvEesnnX1jADHy728cHSMlq4wUc4afYqahq2B1mhQVZxCXOkNTnXltr+2vQ==
|
||||
|
||||
webpack-virtual-modules@^0.6.2:
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
|
||||
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
|
||||
|
||||
webpack@^5.109.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.109.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.109.0.tgz#871d8eee5e2d5e6eaf5ec8d1a6db74ea65491030"
|
||||
integrity sha512-vomrngskVVXEZF9sMZfYAd4pXZUnfaWdJGlF+BTNF+gJBCKYCQBnOeVPlrh39Ewl7nlCsirDplMy6o5g9xJHBg==
|
||||
webpack@^5.108.2, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.108.4"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.108.4.tgz#141818a411662773a0bb32dc5536acc5409943b7"
|
||||
integrity sha512-yur8LyJoeiWh47dErD+Ok7vlbmDsJ3UbbRPAoxbGJ54WpE2y5yVo5G/inUzujnYgw3tPmBRdn+G7PoxXaYC33w==
|
||||
dependencies:
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
@@ -15871,20 +15876,22 @@ webpack@^5.109.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
"@webassemblyjs/wasm-edit" "^1.14.1"
|
||||
"@webassemblyjs/wasm-parser" "^1.14.1"
|
||||
acorn "^8.16.0"
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.28.1"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.24.2"
|
||||
enhanced-resolve "^5.22.2"
|
||||
es-module-lexer "^2.1.0"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
graceful-fs "^4.2.11"
|
||||
loader-runner "^4.3.2"
|
||||
mime-db "^1.54.0"
|
||||
minimizer-webpack-plugin "^5.6.1"
|
||||
neo-async "^2.6.2"
|
||||
schema-utils "^4.3.3"
|
||||
tapable "^2.3.0"
|
||||
watchpack "^2.5.2"
|
||||
webpack-sources "^3.5.1"
|
||||
webpack-sources "^3.5.0"
|
||||
|
||||
webpackbar@^7.0.0:
|
||||
version "7.0.0"
|
||||
|
||||
@@ -42,13 +42,13 @@ dependencies = [
|
||||
# ``google-auth`` 2.53+ dropped it, so Superset must declare it
|
||||
# explicitly to keep fresh ``pip install apache-superset`` working
|
||||
# without the ``base.txt`` lock file (#40962).
|
||||
"cachetools>=7.1.6, <8",
|
||||
"cachetools>=7.1.4, <8",
|
||||
"celery>=5.6.3, <6.0.0",
|
||||
"click>=8.4.2",
|
||||
"click-option-group",
|
||||
"colorama",
|
||||
"flask-cors>=6.0.5, <7.0",
|
||||
"croniter>=6.2.4",
|
||||
"croniter>=6.2.2",
|
||||
"cron-descriptor",
|
||||
"cryptography>=49.0.0, <50.0.0",
|
||||
"deprecation>=2.1.0, <2.2.0",
|
||||
@@ -62,7 +62,7 @@ dependencies = [
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"greenlet<=3.5.3, >=3.5.3",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
@@ -90,7 +90,7 @@ dependencies = [
|
||||
"paramiko>=3.4.0, <4.0", # 4.0 removed DSSKey, still referenced by sshtunnel
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <13",
|
||||
"polyline>=2.0.4, <3.0",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"pydantic>=2.8.0",
|
||||
"pyparsing>=3.3.2, <4",
|
||||
"python-dateutil",
|
||||
@@ -122,14 +122,14 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
athena = ["pyathena[pandas]>=3.35.2, <4"]
|
||||
athena = ["pyathena[pandas]>=2, <4"]
|
||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||
bigquery = [
|
||||
"pandas-gbq>=0.35.0",
|
||||
"sqlalchemy-bigquery>=1.17.0",
|
||||
"google-cloud-bigquery>=3.42.2",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
|
||||
d1 = [
|
||||
@@ -139,7 +139,7 @@ d1 = [
|
||||
]
|
||||
databend = ["databend-sqlalchemy>=0.5.5, <1.0"]
|
||||
databricks = [
|
||||
"databricks-sql-connector>=4.4.0, <4.5.0",
|
||||
"databricks-sql-connector>=4.2.6, <4.4.0",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
datafusion = ["flightsql-dbapi>=0.2.2, <0.3"]
|
||||
@@ -173,7 +173,7 @@ hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
"tableschema",
|
||||
"thrift>=0.24.0, <1.0.0",
|
||||
"thrift>=0.23.0, <1.0.0",
|
||||
"thrift_sasl>=0.4.3, < 1.0.0",
|
||||
]
|
||||
impala = ["impyla>=0.24.0, <0.25"]
|
||||
@@ -196,7 +196,7 @@ playwright = ["playwright>=1.61.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
prophet = ["prophet>=1.3.0, <2"]
|
||||
prophet = ["prophet>=1.1.6, <2"]
|
||||
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
risingwave = ["sqlalchemy-risingwave"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
@@ -206,7 +206,7 @@ sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
|
||||
spark = [
|
||||
"pyhive[hive_pure_sasl]>=0.7",
|
||||
"tableschema",
|
||||
"thrift>=0.24.0, <1",
|
||||
"thrift>=0.23.0, <1",
|
||||
]
|
||||
tdengine = [
|
||||
"taospy>=2.8.9",
|
||||
|
||||
@@ -46,7 +46,7 @@ cachelib==0.13.0
|
||||
# via
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.4
|
||||
# via apache-superset (pyproject.toml)
|
||||
cattrs==25.1.1
|
||||
# via requests-cache
|
||||
@@ -86,7 +86,7 @@ colorama==0.4.6
|
||||
# flask-appbuilder
|
||||
cron-descriptor==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
croniter==6.2.4
|
||||
croniter==6.2.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
cryptography==49.0.0
|
||||
# via
|
||||
@@ -166,7 +166,7 @@ google-auth==2.53.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# shillelagh
|
||||
greenlet==3.5.4
|
||||
greenlet==3.5.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# shillelagh
|
||||
@@ -291,7 +291,7 @@ pillow==12.3.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
polyline==2.0.4
|
||||
polyline==2.0.2
|
||||
# via apache-superset (pyproject.toml)
|
||||
prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
|
||||
@@ -101,7 +101,7 @@ cachelib==0.13.0
|
||||
# -c requirements/base-constraint.txt
|
||||
# flask-caching
|
||||
# flask-session
|
||||
cachetools==7.1.6
|
||||
cachetools==7.1.4
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -178,7 +178,7 @@ cron-descriptor==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
croniter==6.2.4
|
||||
croniter==6.2.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -377,7 +377,7 @@ googleapis-common-protos==1.66.0
|
||||
# via
|
||||
# google-api-core
|
||||
# grpcio-status
|
||||
greenlet==3.5.4
|
||||
greenlet==3.5.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -687,7 +687,7 @@ pluggy==1.5.0
|
||||
# via pytest
|
||||
polib==1.2.0
|
||||
# via apache-superset
|
||||
polyline==2.0.4
|
||||
polyline==2.0.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -703,7 +703,7 @@ prompt-toolkit==3.0.51
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# click-repl
|
||||
prophet==1.3.0
|
||||
prophet==1.2.0
|
||||
# via apache-superset
|
||||
proto-plus==1.25.0
|
||||
# via google-api-core
|
||||
|
||||
@@ -35,7 +35,7 @@ acquire_rat_jar () {
|
||||
wget --quiet ${URL} -O "$JAR_DL" && mv "$JAR_DL" "$JAR"
|
||||
else
|
||||
printf "You do not have curl or wget installed, please install rat manually.\n"
|
||||
exit 255
|
||||
exit -1
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -44,7 +44,7 @@ acquire_rat_jar () {
|
||||
# We failed to download
|
||||
rm "$JAR"
|
||||
printf "Our attempt to download rat locally to ${JAR} failed. Please install rat manually.\n"
|
||||
exit 255
|
||||
exit -1
|
||||
fi
|
||||
printf "Done downloading.\n"
|
||||
}
|
||||
|
||||
@@ -163,10 +163,10 @@ do
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) -gt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) -lt $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
|
||||
@@ -215,7 +215,7 @@ Common permissions you might need:
|
||||
|
||||
By default, the Embedded SDK creates an `iframe` element without a `referrerPolicy` value enforced. This means that a policy defined for `iframe` elements at the host app level would reflect to it.
|
||||
|
||||
This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens through the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail.
|
||||
This can be an issue as during the embedded enablement for a dashboard it's possible to specify which domain(s) are allowed to embed the dashboard, and this validation happens throuth the `Referrer` header. That said, in case the hosting app has a more restrictive policy that would omit this header, this validation would fail.
|
||||
|
||||
Use the `referrerPolicy` parameter in the `embedDashboard` method to specify [a particular policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Referrer-Policy) that works for your implementation.
|
||||
|
||||
|
||||
940
superset-frontend/package-lock.json
generated
940
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -178,7 +178,7 @@
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.4.0",
|
||||
"fs-extra": "^11.3.6",
|
||||
"fuse.js": "^7.5.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
@@ -186,7 +186,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.9.1",
|
||||
"google-auth-library": "^10.9.0",
|
||||
"immer": "^11.1.15",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -195,7 +195,7 @@
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.26.0",
|
||||
"markdown-to-jsx": "^9.9.0",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
@@ -275,9 +275,9 @@
|
||||
"@swc/core": "^1.15.46",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/jest-dom": "^7.0.0",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
@@ -367,7 +367,7 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.5",
|
||||
"wait-on": "^9.1.0",
|
||||
"webpack": "^5.109.0",
|
||||
"webpack": "^5.108.4",
|
||||
"webpack-bundle-analyzer": "^5.3.1",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.5",
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "^10.1.0",
|
||||
"fs-extra": "^11.4.0",
|
||||
"fs-extra": "^11.3.6",
|
||||
"jest": "^30.4.2",
|
||||
"yeoman-test": "^11.6.0"
|
||||
},
|
||||
|
||||
@@ -93,9 +93,9 @@
|
||||
"typescript": "^5.0.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
|
||||
@@ -34,9 +34,9 @@
|
||||
"@ant-design/icons": "^5.6.1 || ^6.0.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
"brace": "^0.11.1",
|
||||
|
||||
@@ -92,9 +92,9 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
|
||||
@@ -49,6 +49,7 @@ const titleStyles = (theme: SupersetTheme) => css`
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
padding: 0;
|
||||
font-weight: inherit;
|
||||
color: ${theme.colorText};
|
||||
background-color: ${theme.colorBgContainer};
|
||||
|
||||
@@ -127,6 +128,21 @@ export const DynamicEditableTitle = memo(
|
||||
}
|
||||
}, [currentTitle, placeholder]);
|
||||
|
||||
// Webfont metrics differ from the fallback font's, so a measurement
|
||||
// taken before fonts finish loading under- or over-sizes the input.
|
||||
// Re-measure once all fonts are ready.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
document.fonts?.ready?.then(() => {
|
||||
if (!cancelled && sizerRef.current) {
|
||||
setInputWidth(sizerRef.current.offsetWidth);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const inputElement = inputRef.current?.input;
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
|
||||
import type { DropdownProps } from '../Dropdown/types';
|
||||
import type { TooltipPlacement } from '../Tooltip/types';
|
||||
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
|
||||
@@ -82,6 +83,20 @@ const headerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: center the title between left/right panels */
|
||||
${
|
||||
isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
.title-panel {
|
||||
flex: 1;
|
||||
justify-content: center;
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`;
|
||||
|
||||
const buttonsStyles = (theme: SupersetTheme) => css`
|
||||
@@ -109,6 +124,7 @@ export type PageHeaderWithActionsProps = {
|
||||
showFaveStar: boolean;
|
||||
showMenuDropdown?: boolean;
|
||||
faveStarProps: FaveStarProps;
|
||||
leftPanelItems?: ReactNode;
|
||||
titlePanelAdditionalItems: ReactNode;
|
||||
rightPanelAdditionalItems: ReactNode;
|
||||
additionalActionsMenu: ReactElement;
|
||||
@@ -126,6 +142,7 @@ export const PageHeaderWithActions = memo(
|
||||
certificatiedBadgeProps,
|
||||
showFaveStar,
|
||||
faveStarProps,
|
||||
leftPanelItems,
|
||||
titlePanelAdditionalItems,
|
||||
rightPanelAdditionalItems,
|
||||
additionalActionsMenu,
|
||||
@@ -136,6 +153,7 @@ export const PageHeaderWithActions = memo(
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<div css={headerStyles} className="header-with-actions">
|
||||
{leftPanelItems}
|
||||
<div className="title-panel">
|
||||
<DynamicEditableTitle {...editableTitleProps} />
|
||||
{showTitlePanelItems && (
|
||||
|
||||
@@ -57,6 +57,7 @@ export enum FeatureFlag {
|
||||
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
|
||||
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
Matrixify = 'MATRIXIFY',
|
||||
MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE',
|
||||
ScheduledQueries = 'SCHEDULED_QUERIES',
|
||||
SemanticLayers = 'SEMANTIC_LAYERS',
|
||||
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',
|
||||
|
||||
@@ -96,6 +96,7 @@ export default defineConfig({
|
||||
'**/tests/auth/**/*.spec.ts',
|
||||
'**/tests/sqllab/**/*.spec.ts',
|
||||
'**/tests/embedded/**/*.spec.ts',
|
||||
'**/tests/mobile/**/*.spec.ts',
|
||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||
],
|
||||
use: {
|
||||
@@ -156,6 +157,23 @@ export default defineConfig({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// Mobile consumption-mode tests need the MOBILE_CONSUMPTION_MODE feature
|
||||
// flag enabled in the Flask backend (the workflow's mobile step sets
|
||||
// SUPERSET_FEATURE_MOBILE_CONSUMPTION_MODE), so they only run when the
|
||||
// environment opts in. Same strict 'true' check as INCLUDE_EMBEDDED.
|
||||
...(process.env.INCLUDE_MOBILE?.toLowerCase() === 'true'
|
||||
? [
|
||||
{
|
||||
name: 'chromium-mobile',
|
||||
testMatch: '**/tests/mobile/**/*.spec.ts',
|
||||
use: {
|
||||
browserName: 'chromium' as const,
|
||||
testIdAttribute: 'data-test',
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
|
||||
// Web server setup - disabled in CI (Flask started separately in workflow)
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile Experience Documentation Screenshot Generator
|
||||
*
|
||||
* Captures phone-sized screenshots for the mobile consumption mode docs
|
||||
* (docs/docs/using-superset/mobile-experience.mdx). Depends on example data
|
||||
* loaded via `superset load_examples` AND the MOBILE_CONSUMPTION_MODE
|
||||
* feature flag being enabled in the target environment:
|
||||
*
|
||||
* FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}
|
||||
*
|
||||
* Run locally:
|
||||
* cd superset-frontend
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
|
||||
*
|
||||
* Screenshots are saved under docs/static/img/screenshots/mobile/.
|
||||
*/
|
||||
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { Page, test, expect } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
const MOBILE_SCREENSHOTS_DIR = path.resolve(
|
||||
__dirname,
|
||||
'../../../../docs/static/img/screenshots/mobile',
|
||||
);
|
||||
// Committed to the repo alongside the generated images, but create it
|
||||
// defensively in case someone deletes the directory and re-runs this
|
||||
// generator standalone (Playwright does not create missing parent
|
||||
// directories for screenshot paths).
|
||||
fs.mkdirSync(MOBILE_SCREENSHOTS_DIR, { recursive: true });
|
||||
|
||||
// iPhone 12-class viewport; 2x scale factor for crisp docs images
|
||||
test.use({
|
||||
viewport: { width: 390, height: 844 },
|
||||
deviceScaleFactor: 2,
|
||||
hasTouch: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Waits for animations and async renders to settle before taking a
|
||||
* screenshot. ECharts entry animations, drawer transitions, and image
|
||||
* lazy-loading require a short pause that can't be expressed as a
|
||||
* deterministic wait condition.
|
||||
*/
|
||||
async function settle(page: Page, ms = 1000): Promise<void> {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the Sales Dashboard (from example data) at phone size and waits for
|
||||
* the stacked charts to finish rendering.
|
||||
*/
|
||||
async function openSalesDashboardMobile(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Mobile list is card-only; cards navigate on tap (titles are plain
|
||||
// text, not links, in consumption mode)
|
||||
const dashboardCard = page.getByText('Sales Dashboard', { exact: true });
|
||||
await expect(dashboardCard.first()).toBeVisible({ timeout: 15000 });
|
||||
await dashboardCard.first().click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-content-wrapper"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
}
|
||||
|
||||
test('mobile dashboard screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard filter drawer screenshot', async ({ page }) => {
|
||||
await openSalesDashboardMobile(page);
|
||||
|
||||
const filterTrigger = page.locator('[data-test="mobile-filters-trigger"]');
|
||||
await expect(filterTrigger).toBeVisible({ timeout: 15000 });
|
||||
await filterTrigger.click();
|
||||
|
||||
// Wait for the drawer and its filter controls to render
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.locator('[data-test="filter-bar"]')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
// Park the pointer so no hover card is open in the capture
|
||||
await page.mouse.move(5, 830);
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_filter_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile dashboard list screenshot', async ({ page }) => {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
// Card view is forced on mobile; wait for cards to render
|
||||
await expect(page.locator('[data-test="styled-card"]').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_dashboard_list.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile home screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
await settle(page, 2000);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_home.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile navigation drawer screenshot', async ({ page }) => {
|
||||
await page.goto(URL.WELCOME);
|
||||
await expect(page.getByText('Recents')).toBeVisible({ timeout: 15000 });
|
||||
|
||||
const menuButton = page.getByRole('button', { name: 'Menu' });
|
||||
await expect(menuButton).toBeVisible({ timeout: 10000 });
|
||||
await menuButton.click();
|
||||
|
||||
await expect(page.locator('.ant-drawer-body')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await expect(page.getByText('Dashboards').first()).toBeVisible();
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_nav_drawer.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
|
||||
test('mobile unsupported route screenshot', async ({ page }) => {
|
||||
await page.goto(URL.SQLLAB);
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(MOBILE_SCREENSHOTS_DIR, 'mobile_unsupported.jpg'),
|
||||
type: 'jpeg',
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,284 @@
|
||||
/**
|
||||
* 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 { test, expect, devices, Page } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
import { URL } from '../../utils/urls';
|
||||
|
||||
/**
|
||||
* Mobile dashboard viewing tests verify that dashboards can be viewed
|
||||
* and interacted with on mobile devices.
|
||||
*
|
||||
* These tests assume the World Bank's Health sample dashboard exists.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
/**
|
||||
* Navigates to the dashboard list, clicks the first available dashboard
|
||||
* card, and waits for navigation into that dashboard. Skips the current
|
||||
* test when no dashboards are available to open.
|
||||
*/
|
||||
async function openFirstDashboard(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
test.skip(cardCount === 0, 'No dashboards available to open on mobile');
|
||||
|
||||
await cards.first().click();
|
||||
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the World Bank's Health dashboard and returns a locator
|
||||
* for its mobile filter button. Skips the current test when the fixture
|
||||
* has no native filters configured.
|
||||
*/
|
||||
async function getMobileFilterButton(page: Page) {
|
||||
// Navigate directly to the World Bank's Health dashboard, which this
|
||||
// spec's fixtures require, rather than an arbitrary first card from
|
||||
// the list. Whether it has native filters configured depends on the
|
||||
// fixture, so callers skip themselves when none are present.
|
||||
await page.goto('dashboard/world_health/');
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Give filters time to load
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
const filterButton = page
|
||||
.locator('[data-test="mobile-filters-trigger"]')
|
||||
.or(page.locator('[aria-label="Open filters"]'));
|
||||
|
||||
const filterCount = await filterButton.count();
|
||||
|
||||
test.skip(
|
||||
filterCount === 0,
|
||||
'world_health dashboard fixture has no native filters configured; ' +
|
||||
'cannot verify mobile filter behavior.',
|
||||
);
|
||||
|
||||
return filterButton;
|
||||
}
|
||||
|
||||
test.describe('Mobile Dashboard Viewing', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to dashboard list to find a dashboard
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
});
|
||||
|
||||
test('dashboard list renders in card view on mobile', async ({ page }) => {
|
||||
// On mobile, dashboard list should show cards, not table
|
||||
// Look for card elements
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
|
||||
// Should have at least one card if dashboards exist
|
||||
// (This test may need adjustment based on test data availability)
|
||||
const cardCount = await cards.count();
|
||||
|
||||
// Either cards are visible, or the empty state is shown; the table
|
||||
// view must never render on mobile
|
||||
if (cardCount > 0) {
|
||||
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
await expect(page.locator('[data-test="empty-state"]')).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
}
|
||||
await expect(page.locator('[data-test="listview-table"]')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('mobile search button appears in dashboard list', async ({ page }) => {
|
||||
// On mobile, the search/filter button should appear in the header
|
||||
const searchButton = page
|
||||
.locator('[aria-label="Search"]')
|
||||
.or(page.locator('[data-test="mobile-search-button"]'));
|
||||
|
||||
// Search button should be visible on mobile
|
||||
await expect(searchButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('tapping dashboard card opens the dashboard', async ({ page }) => {
|
||||
// Find a dashboard card
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
if (cardCount > 0) {
|
||||
// Click the first card
|
||||
await cards.first().click();
|
||||
|
||||
// Should navigate to dashboard view
|
||||
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard should load (look for dashboard content)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
} else {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Dashboard Interaction', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
// Skip this test suite if no dashboards exist
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
// browser.newPage() does not inherit the project's `storageState`, so
|
||||
// it must be passed explicitly to reuse the authenticated session -
|
||||
// otherwise this check hits the login page and always finds 0 cards.
|
||||
const page = await browser.newPage({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
});
|
||||
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const cards = page.locator('[data-test="styled-card"]');
|
||||
const cardCount = await cards.count();
|
||||
|
||||
await page.close();
|
||||
|
||||
if (cardCount === 0) {
|
||||
test.skip();
|
||||
}
|
||||
});
|
||||
|
||||
test('dashboard loads and shows charts on mobile', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Dashboard content should be visible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="dashboard-content-wrapper"]')
|
||||
.or(page.locator('.dashboard')),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Charts should start loading (look for chart containers)
|
||||
const chartContainers = page
|
||||
.locator('[data-test="chart-container"]')
|
||||
.or(page.locator('.dashboard-chart'));
|
||||
|
||||
// Wait for at least one chart to be visible (with timeout)
|
||||
await expect(chartContainers.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD * 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('dashboard header shows hamburger menu on mobile', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Look for the hamburger menu / more actions button
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
await expect(menuButton.first()).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('refresh dashboard works from mobile menu', async ({ page }) => {
|
||||
await openFirstDashboard(page);
|
||||
|
||||
// Open the actions menu
|
||||
const menuButton = page
|
||||
.locator('[data-test="actions-trigger"]')
|
||||
.or(page.locator('[aria-label="Menu actions trigger"]'));
|
||||
|
||||
test.skip(
|
||||
(await menuButton.count()) === 0,
|
||||
'Mobile actions menu button not found on this dashboard',
|
||||
);
|
||||
|
||||
await menuButton.first().click();
|
||||
|
||||
// Look for refresh option
|
||||
const refreshOption = page.getByText('Refresh dashboard');
|
||||
|
||||
test.skip(
|
||||
(await refreshOption.count()) === 0,
|
||||
'Refresh dashboard option not found in mobile actions menu',
|
||||
);
|
||||
|
||||
await refreshOption.click();
|
||||
|
||||
// Should show success toast or refresh the charts
|
||||
// This is hard to verify without checking network requests
|
||||
// Just verify the menu closes and we're still on the dashboard
|
||||
await page.waitForTimeout(1000);
|
||||
expect(page.url()).toMatch(/\/dashboard\/(?!list)/);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Mobile Filter Drawer', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test('filter button appears on dashboards with filters', async ({ page }) => {
|
||||
const filterButton = await getMobileFilterButton(page);
|
||||
|
||||
await expect(filterButton.first()).toBeVisible();
|
||||
});
|
||||
|
||||
test('filter drawer opens when filter button is tapped', async ({ page }) => {
|
||||
const filterButton = await getMobileFilterButton(page);
|
||||
|
||||
await filterButton.first().click();
|
||||
|
||||
// Filter drawer should open
|
||||
const drawer = page
|
||||
.locator('.ant-drawer-open')
|
||||
.or(page.locator('[data-test="filter-bar"]'));
|
||||
|
||||
await expect(drawer.first()).toBeVisible({
|
||||
timeout: TIMEOUT.FORM_LOAD,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { test, expect, devices } from '@playwright/test';
|
||||
|
||||
// NOTE: These tests exercise the mobile consumption experience and require
|
||||
// the MOBILE_CONSUMPTION_MODE feature flag to be enabled in the target
|
||||
// environment (FEATURE_FLAGS = {"MOBILE_CONSUMPTION_MODE": True}).
|
||||
import { URL } from '../../utils/urls';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
/**
|
||||
* Mobile navigation tests verify the MobileRouteGuard behavior
|
||||
* and mobile-specific navigation patterns.
|
||||
*
|
||||
* These tests run with a mobile viewport to trigger mobile-specific behavior.
|
||||
*/
|
||||
|
||||
// Use iPhone 12 viewport for mobile tests
|
||||
const mobileViewport = devices['iPhone 12'];
|
||||
|
||||
test.describe('Mobile Navigation', () => {
|
||||
test.use({
|
||||
viewport: mobileViewport.viewport,
|
||||
userAgent: mobileViewport.userAgent,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/');
|
||||
});
|
||||
|
||||
test('mobile viewport redirects from chart list to MobileUnsupported page', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to chart list (not mobile-supported)
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Primary action buttons should be visible
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'View Dashboards' }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('button', { name: 'Go to Welcome Page' }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to dashboard list', async ({ page }) => {
|
||||
// Navigate to dashboard list (mobile-supported)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show dashboard list content (look for dashboard list elements)
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('mobile viewport allows access to welcome page', async ({ page }) => {
|
||||
// Navigate to welcome page (mobile-supported)
|
||||
await page.goto(URL.WELCOME);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show welcome page content
|
||||
await expect(
|
||||
page.getByText('Recents').or(page.getByText('Dashboards')).first(),
|
||||
).toBeVisible({
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('View Dashboards button navigates to dashboard list', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click View Dashboards button
|
||||
await page.getByRole('button', { name: 'View Dashboards' }).click();
|
||||
|
||||
// Should navigate to dashboard list
|
||||
await page.waitForURL(url => url.pathname.includes('dashboard/list'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Dashboard list should be accessible
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
|
||||
test('Go to Welcome Page button navigates to welcome', async ({ page }) => {
|
||||
// Navigate to unsupported route
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Wait for MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// Click Go to Welcome Page button
|
||||
await page.getByRole('button', { name: 'Go to Welcome Page' }).click();
|
||||
|
||||
// Should navigate to welcome page
|
||||
await page.waitForURL(url => url.pathname.includes('welcome'), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
});
|
||||
|
||||
test('unsupported screen offers no bypass', async ({ page }) => {
|
||||
// The "Continue anyway" bypass was removed: desktop views are unusable
|
||||
// at phone width, and growing the viewport unblocks routes automatically
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
await expect(page.getByText('Continue anyway')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('SQL Lab is not accessible on mobile', async ({ page }) => {
|
||||
// Navigate to SQL Lab (not mobile-supported)
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
// Should show the MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
|
||||
test.describe('Desktop Navigation (control group)', () => {
|
||||
// Use default desktop viewport
|
||||
|
||||
test('desktop viewport allows access to all routes', async ({ page }) => {
|
||||
// Navigate to chart list
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
// Should NOT show MobileUnsupported page
|
||||
await expect(
|
||||
page.getByText("This view isn't available on mobile"),
|
||||
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
|
||||
// Should show chart list content
|
||||
await expect(
|
||||
page
|
||||
.locator('[data-test="listview-table"]')
|
||||
.or(page.locator('[data-test="styled-card"]'))
|
||||
.first(),
|
||||
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
|
||||
});
|
||||
});
|
||||
@@ -32,7 +32,7 @@
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"react": "^18.3.0",
|
||||
|
||||
@@ -38,9 +38,9 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.7",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/jest": "^30.0.0",
|
||||
"jest": "^30.4.2"
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@math.gl/web-mercator": "^4.1.0",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"mapbox-gl": "^3.26.0",
|
||||
"maplibre-gl": "^5.24.0",
|
||||
"react-map-gl": "^8.1.1",
|
||||
"supercluster": "^8.0.1"
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^15.0.0",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"match-sorter": "^8.2.0",
|
||||
|
||||
169
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
169
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Mobile testing utilities for Jest tests.
|
||||
*
|
||||
* Note: We mock 'antd' directly rather than '@superset-ui/core/components' because
|
||||
* mocking the latter causes circular dependency issues with ActionButton during
|
||||
* jest.requireActual evaluation. Since Grid is re-exported from antd, mocking
|
||||
* antd at the source works correctly.
|
||||
*
|
||||
* Note: FeatureFlag is imported from the '@superset-ui/core/utils' submodule
|
||||
* rather than the '@superset-ui/core' package root. The package root barrel
|
||||
* transitively pulls in the theme module, which imports 'antd'. Consuming
|
||||
* test files call `jest.mock('antd', () => mockAntdWithDesktopBreakpoint())`
|
||||
* before importing this file, so if loading this file triggered an 'antd'
|
||||
* require before `mockAntdWithDesktopBreakpoint` were defined, the mock
|
||||
* factory would throw.
|
||||
*/
|
||||
|
||||
import { FeatureFlag } from '@superset-ui/core/utils';
|
||||
|
||||
/**
|
||||
* Standard mobile breakpoint values (below md breakpoint)
|
||||
*/
|
||||
export const mobileBreakpoints = {
|
||||
xs: true,
|
||||
sm: false,
|
||||
md: false,
|
||||
lg: false,
|
||||
xl: false,
|
||||
xxl: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Standard desktop breakpoint values (at or above md breakpoint)
|
||||
*/
|
||||
export const desktopBreakpoints = {
|
||||
xs: true,
|
||||
sm: true,
|
||||
md: true,
|
||||
lg: true,
|
||||
xl: true,
|
||||
xxl: true,
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns mobile breakpoints.
|
||||
* Use this at the top of test files that need to simulate mobile viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithMobileBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithMobileBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => mobileBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates a mock for antd Grid.useBreakpoint that returns desktop breakpoints.
|
||||
* Use this at the top of test files that need to simulate desktop viewport.
|
||||
*
|
||||
* @example
|
||||
* jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
*/
|
||||
export const mockAntdWithDesktopBreakpoint = () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Grid: {
|
||||
...jest.requireActual('antd').Grid,
|
||||
useBreakpoint: () => desktopBreakpoints,
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Mocks window.matchMedia to simulate a narrow mobile viewport (375px wide
|
||||
* by default) for the useIsMobile hook. A `max-width: Npx` query only
|
||||
* matches when the simulated viewport width is at or below N, mirroring
|
||||
* real browser media-query evaluation; queries for narrower breakpoints
|
||||
* than the simulated viewport correctly report no match, instead of every
|
||||
* `max-width` query matching regardless of its threshold.
|
||||
* Returns a cleanup function restoring the previous matchMedia. Mobile
|
||||
* behavior requires BOTH this AND the MOBILE_CONSUMPTION_MODE flag (see
|
||||
* enableMobileConsumptionFlag).
|
||||
*/
|
||||
export const mockMobileMatchMedia = (viewportWidth = 375) => {
|
||||
const previous = window.matchMedia;
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: jest.fn().mockImplementation((query: string) => {
|
||||
const maxWidthMatch = query.match(/max-width:\s*(\d+(?:\.\d+)?)px/);
|
||||
const matches = maxWidthMatch
|
||||
? viewportWidth <= Number(maxWidthMatch[1])
|
||||
: false;
|
||||
return {
|
||||
matches,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: jest.fn(),
|
||||
removeListener: jest.fn(),
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn(),
|
||||
dispatchEvent: jest.fn(),
|
||||
};
|
||||
}),
|
||||
});
|
||||
return () => {
|
||||
Object.defineProperty(window, 'matchMedia', {
|
||||
writable: true,
|
||||
value: previous,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
|
||||
* Mobile behavior requires BOTH a small viewport (mockMobileMatchMedia)
|
||||
* AND this flag; call this in beforeAll/beforeEach of mobile test suites.
|
||||
* Returns a cleanup function restoring the previous flags.
|
||||
*/
|
||||
export const enableMobileConsumptionFlag = () => {
|
||||
const previous = window.featureFlags;
|
||||
window.featureFlags = {
|
||||
...window.featureFlags,
|
||||
[FeatureFlag.MobileConsumptionMode]: true,
|
||||
};
|
||||
return () => {
|
||||
window.featureFlags = previous;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Common mobile viewport dimensions for reference
|
||||
*/
|
||||
export const mobileViewports = {
|
||||
iPhoneX: { width: 375, height: 812 },
|
||||
iPhoneSE: { width: 375, height: 667 },
|
||||
iPhone12Pro: { width: 390, height: 844 },
|
||||
pixel5: { width: 393, height: 851 },
|
||||
samsungGalaxyS20: { width: 360, height: 800 },
|
||||
};
|
||||
|
||||
/**
|
||||
* Common tablet viewport dimensions for reference
|
||||
*/
|
||||
export const tabletViewports = {
|
||||
iPadMini: { width: 768, height: 1024 },
|
||||
iPadAir: { width: 820, height: 1180 },
|
||||
iPadPro11: { width: 834, height: 1194 },
|
||||
surfacePro7: { width: 912, height: 1368 },
|
||||
};
|
||||
@@ -216,12 +216,6 @@ test('should render the error', async () => {
|
||||
.spyOn(SupersetClient, 'post')
|
||||
.mockRejectedValue(new Error('Something went wrong'));
|
||||
await waitForRender();
|
||||
// The error is wrapped in an Alert component with a stable headline and the
|
||||
// raw error text in the description — no more bare ``<pre>`` elements.
|
||||
expect(await screen.findByRole('alert')).toBeVisible();
|
||||
expect(
|
||||
await screen.findByText('Failed to load drill-to-detail rows'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -42,7 +42,6 @@ import BooleanCell from '@superset-ui/core/components/Table/cell-renderers/Boole
|
||||
import NullCell from '@superset-ui/core/components/Table/cell-renderers/NullCell';
|
||||
import TimeCell from '@superset-ui/core/components/Table/cell-renderers/TimeCell';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
||||
import Table, {
|
||||
ColumnsType,
|
||||
@@ -363,18 +362,13 @@ export default function DrillDetailPane({
|
||||
if (responseError) {
|
||||
// Render error if page download failed
|
||||
tableContent = (
|
||||
<div
|
||||
<pre
|
||||
css={css`
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
>
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message={t('Failed to load drill-to-detail rows')}
|
||||
description={responseError}
|
||||
/>
|
||||
</div>
|
||||
{responseError}
|
||||
</pre>
|
||||
);
|
||||
} else if (bootstrapping) {
|
||||
// Render loading if first page hasn't loaded
|
||||
|
||||
@@ -50,7 +50,6 @@ const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
DATASOURCE: t('Drill to detail is not available for this datasource type.'),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
@@ -117,17 +116,6 @@ export const useDrillDetailMenuItems = ({
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
// Capability flag on the datasource itself. Datasources that don't model
|
||||
// raw rows (e.g. semantic views) opt out via ``supports_drill_to_detail``
|
||||
// in the explore data payload.
|
||||
const datasourceSupportsDrillToDetail = useSelector<
|
||||
RootState,
|
||||
boolean | undefined
|
||||
>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.supports_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters: BinaryQueryObjectFilterClause[], event: MouseEvent) => {
|
||||
onClick(event);
|
||||
@@ -170,10 +158,7 @@ export const useDrillDetailMenuItems = ({
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (datasourceSupportsDrillToDetail === false) {
|
||||
drillDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
drillByDisabled = DISABLED_REASONS.DATASOURCE;
|
||||
} else if (drillToDetailDisabled) {
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
|
||||
@@ -444,45 +444,3 @@ test('context menu renders <NULL> for null dimension values', async () => {
|
||||
await expectDrillToDetailByEnabled();
|
||||
await expectDrillToDetailByDimension(filterNull);
|
||||
});
|
||||
|
||||
const buildStateWithUnsupportedDatasource = () => {
|
||||
const baseState = getMockStoreWithNativeFilters().getState();
|
||||
const datasourceKey = defaultFormData.datasource as string;
|
||||
return {
|
||||
...baseState,
|
||||
datasources: {
|
||||
...baseState.datasources,
|
||||
[datasourceKey]: {
|
||||
...baseState.datasources[datasourceKey],
|
||||
supports_drill_to_detail: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
test('dropdown menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
await expectDrillToDetailDisabled(
|
||||
'Drill to detail is not available for this datasource type.',
|
||||
);
|
||||
await expectNoDrillToDetailBy();
|
||||
});
|
||||
|
||||
test('context menu when datasource opts out via supports_drill_to_detail=false', async () => {
|
||||
cleanup();
|
||||
render(<MockRenderChart formData={defaultFormData} isContextMenu />, {
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: buildStateWithUnsupportedDatasource(),
|
||||
});
|
||||
|
||||
const message = 'Drill to detail is not available for this datasource type.';
|
||||
await expectDrillToDetailDisabled(message);
|
||||
await expectDrillToDetailByDisabled(message);
|
||||
});
|
||||
|
||||
@@ -62,7 +62,6 @@ export function ErrorMessageWithStackTrace({
|
||||
fallback,
|
||||
compact,
|
||||
closable = true,
|
||||
errorMitigationFunction,
|
||||
}: Props) {
|
||||
// Check if a custom error message component was registered for this message
|
||||
if (error) {
|
||||
@@ -78,7 +77,6 @@ export function ErrorMessageWithStackTrace({
|
||||
error={error}
|
||||
source={source}
|
||||
subtitle={subtitle}
|
||||
errorMitigationFunction={errorMitigationFunction}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
import * as reduxHooks from 'react-redux';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createStore, Store } from 'redux';
|
||||
import { act, render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { render, waitFor } from 'spec/helpers/testing-library';
|
||||
import { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
|
||||
import { reRunQuery } from 'src/SqlLab/actions/sqlLab';
|
||||
import { triggerQuery } from 'src/components/Chart/chartAction';
|
||||
@@ -166,12 +166,10 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
render(setup());
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
expect(reRunQuery).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
|
||||
@@ -184,44 +182,6 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('waits for the SQL Lab query before consuming the completion', async () => {
|
||||
const initialState = {
|
||||
sqlLab: {
|
||||
queries: {},
|
||||
queryEditors: [{ id: 'editor-id', latestQueryId: 'query-id' }],
|
||||
tabHistory: ['editor-id'],
|
||||
},
|
||||
explore: { slice: null },
|
||||
charts: {},
|
||||
dashboardInfo: {},
|
||||
};
|
||||
const delayedQueryStore = createStore(
|
||||
(state: typeof initialState = initialState, action) =>
|
||||
action.type === 'load-query'
|
||||
? {
|
||||
...state,
|
||||
sqlLab: {
|
||||
...state.sqlLab,
|
||||
queries: { 'query-id': { sql: 'SELECT * FROM table' } },
|
||||
},
|
||||
}
|
||||
: state,
|
||||
);
|
||||
render(setup({}, delayedQueryStore));
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
expect(reRunQuery).not.toHaveBeenCalled();
|
||||
|
||||
act(() => {
|
||||
delayedQueryStore.dispatch({ type: 'load-query' });
|
||||
});
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
|
||||
});
|
||||
});
|
||||
|
||||
test('dispatches triggerQuery action for explore source upon receiving a correct message', async () => {
|
||||
render(setup({ source: 'explore' }));
|
||||
|
||||
@@ -274,22 +234,4 @@ describe('OAuth2RedirectMessage Component', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test('runs scoped mitigation once instead of CRUD invalidation', async () => {
|
||||
const errorMitigationFunction = jest.fn();
|
||||
render(
|
||||
setup({
|
||||
source: 'crud' as ErrorSource,
|
||||
errorMitigationFunction,
|
||||
}),
|
||||
);
|
||||
|
||||
simulateBroadcastMessage({ tabId: 'tabId' });
|
||||
simulateStorageMessage({ tabId: 'tabId' });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(errorMitigationFunction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(api.util.invalidateTags).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
|
||||
@@ -58,16 +58,15 @@ interface OAuth2RedirectExtra {
|
||||
*
|
||||
* After the token has been stored, the opened tab will broadcast a message to the
|
||||
* original tab and close itself. This component, running on the original tab, listens
|
||||
* for same-origin BroadcastChannel and storage notifications and re-runs the query
|
||||
* for the user once it receives the success message — be it in SQL Lab, Explore, or
|
||||
* a dashboard. Both tabs share a "tab ID" (a UUID generated by the backend) which is
|
||||
* echoed back so the original tab only reacts to its own OAuth2 flow.
|
||||
* on a same-origin BroadcastChannel and re-runs the query for the user once it
|
||||
* receives the success message — be it in SQL Lab, Explore, or a dashboard. Both tabs
|
||||
* share a "tab ID" (a UUID generated by the backend) which is echoed back through the
|
||||
* channel so the original tab only reacts to its own OAuth2 flow.
|
||||
*/
|
||||
export function OAuth2RedirectMessage({
|
||||
error,
|
||||
source,
|
||||
closable,
|
||||
errorMitigationFunction,
|
||||
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
|
||||
const { extra, level } = error;
|
||||
|
||||
@@ -104,17 +103,13 @@ export function OAuth2RedirectMessage({
|
||||
);
|
||||
|
||||
const dispatch = useDispatch();
|
||||
const lastHandledTabIdRef = useRef<string>();
|
||||
|
||||
useEffect(() => {
|
||||
const handleOAuthComplete = (tabId?: string) => {
|
||||
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
|
||||
if (tabId !== extra.tab_id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (errorMitigationFunction) {
|
||||
errorMitigationFunction();
|
||||
} else if (source === 'sqllab' && query) {
|
||||
if (source === 'sqllab' && query) {
|
||||
dispatch(reRunQuery(query));
|
||||
} else if (source === 'explore') {
|
||||
dispatch(triggerQuery(true, chartId));
|
||||
@@ -128,11 +123,7 @@ export function OAuth2RedirectMessage({
|
||||
'Tables',
|
||||
]),
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
lastHandledTabIdRef.current = tabId;
|
||||
};
|
||||
|
||||
const channel =
|
||||
@@ -165,16 +156,7 @@ export function OAuth2RedirectMessage({
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
channel?.close();
|
||||
};
|
||||
}, [
|
||||
source,
|
||||
extra.tab_id,
|
||||
dispatch,
|
||||
query,
|
||||
chartId,
|
||||
chartList,
|
||||
dashboardId,
|
||||
errorMitigationFunction,
|
||||
]);
|
||||
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
|
||||
|
||||
const body = (
|
||||
<p>
|
||||
|
||||
@@ -27,7 +27,6 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
|
||||
subtitle?: ReactNode;
|
||||
compact?: boolean;
|
||||
closable?: boolean;
|
||||
errorMitigationFunction?: () => void;
|
||||
};
|
||||
|
||||
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
import { ReactNode, MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { TableInstance, Row, UseRowSelectRowProps } from 'react-table';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import cx from 'classnames';
|
||||
|
||||
interface CardCollectionProps {
|
||||
@@ -42,6 +43,18 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>`
|
||||
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
|
||||
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
|
||||
};
|
||||
|
||||
/* Full-width cards on mobile (consumption mode) */
|
||||
${
|
||||
isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
grid-template-columns: 1fr;
|
||||
grid-gap: ${theme.sizeUnit * 4}px;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
padding-right: ${theme.sizeUnit * 4}px;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -380,3 +380,227 @@ describe('ListView', () => {
|
||||
expect(screen.getByTestId('empty-state')).toHaveClass('card');
|
||||
});
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
test('respects forceViewMode prop and hides view toggle', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// View toggle should not be present when forceViewMode is set
|
||||
expect(screen.queryByLabelText('card-view')).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('list-view')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows card view when forceViewMode is card', () => {
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="test-card">Card Content</div>}
|
||||
forceViewMode="card"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should render cards, not table rows
|
||||
expect(screen.getAllByTestId('test-card')).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('renders mobile filter drawer when mobileFiltersOpen is true', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search Dashboards"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should be visible with custom title
|
||||
expect(screen.getByText('Search Dashboards')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls setMobileFiltersOpen(false) when drawer is closed', async () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
// Omit cardSortSelectOptions to avoid CardSortSelect needing initialSort
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Click the close button on the drawer
|
||||
const closeButton = screen.getByLabelText('Close');
|
||||
await userEvent.click(closeButton);
|
||||
|
||||
expect(setMobileFiltersOpen).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
test('mobile drawer contains FilterControls', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// The drawer should contain the filter controls; select filters render
|
||||
// as popover triggers (aria-haspopup="listbox") in the drawer
|
||||
const drawer = screen.getByRole('dialog');
|
||||
const filterTriggers = drawer.querySelectorAll('[aria-haspopup="listbox"]');
|
||||
expect(filterTriggers.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('mobile drawer contains CardSortSelect when in card view with sort options', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...mockedPropsComprehensive}
|
||||
renderCard={() => <div>Card</div>}
|
||||
forceViewMode="card"
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
initialSort={[{ id: 'something' }]}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Sort select should be present (may be multiple - one in drawer, one in header)
|
||||
const sortSelects = screen.getAllByTestId('card-sort-select');
|
||||
expect(sortSelects.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('uses default drawer title when mobileFiltersDrawerTitle not provided', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Default title should be 'Search'
|
||||
expect(screen.getByText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render drawer when mobileFiltersOpen is false', () => {
|
||||
const setMobileFiltersOpen = jest.fn();
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
mobileFiltersOpen={false}
|
||||
setMobileFiltersOpen={setMobileFiltersOpen}
|
||||
mobileFiltersDrawerTitle="Search"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Drawer should not be visible (title not in visible content)
|
||||
// Note: Ant Design drawer might still be in DOM but hidden
|
||||
const drawer = document.querySelector('.ant-drawer-open');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('does not render mobile drawer without setMobileFiltersOpen prop', () => {
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView {...propsWithoutSort} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// No drawer elements should exist
|
||||
const drawer = document.querySelector('.ant-drawer');
|
||||
expect(drawer).toBeNull();
|
||||
});
|
||||
|
||||
test('forceViewMode table shows table view', () => {
|
||||
const { cardSortSelectOptions: _cardSortSelectOptions, ...propsWithoutSort } =
|
||||
mockedPropsComprehensive;
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider adapter={ReactRouter5Adapter}>
|
||||
<ListView
|
||||
{...propsWithoutSort}
|
||||
renderCard={() => <div data-test="card">Card</div>}
|
||||
forceViewMode="table"
|
||||
/>
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{ store: mockStore() },
|
||||
);
|
||||
|
||||
// Should show table, not cards
|
||||
expect(screen.queryByTestId('card')).not.toBeInTheDocument();
|
||||
// Table should be present
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
|
||||
import {
|
||||
Button,
|
||||
Tooltip,
|
||||
Drawer,
|
||||
Icons,
|
||||
EmptyState,
|
||||
Loading,
|
||||
@@ -252,6 +253,30 @@ const EmptyWrapper = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
const MobileFilterDrawerContent = styled.div`
|
||||
${({ theme }) => `
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
padding: ${theme.sizeUnit * 2}px;
|
||||
|
||||
/* Make filter inputs stack vertically and full-width */
|
||||
> * {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Override inline filter styling for vertical layout */
|
||||
.filter-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
.ant-select {
|
||||
width: 100% !important;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
const ViewModeToggle = ({
|
||||
mode,
|
||||
setMode,
|
||||
@@ -314,6 +339,7 @@ export interface ListViewProps<T extends object = any> {
|
||||
renderCard?: (row: T & { loading: boolean }) => ReactNode;
|
||||
cardSortSelectOptions?: Array<CardSortSelectOption>;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
highlightRowId?: number;
|
||||
showThumbnails?: boolean;
|
||||
emptyState?: EmptyStateProps;
|
||||
@@ -329,6 +355,12 @@ export interface ListViewProps<T extends object = any> {
|
||||
expandable?: Record<string, unknown>;
|
||||
/** Content rendered between the filter bar and the table/card body. */
|
||||
headerContent?: ReactNode;
|
||||
/** Whether mobile filters drawer is open (controlled externally) */
|
||||
mobileFiltersOpen?: boolean;
|
||||
/** Callback to set mobile filters drawer open state */
|
||||
setMobileFiltersOpen?: (open: boolean) => void;
|
||||
/** Title for the mobile filters drawer */
|
||||
mobileFiltersDrawerTitle?: string;
|
||||
}
|
||||
|
||||
export function ListView<T extends object = any>({
|
||||
@@ -350,6 +382,7 @@ export function ListView<T extends object = any>({
|
||||
showThumbnails,
|
||||
cardSortSelectOptions,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
highlightRowId,
|
||||
emptyState,
|
||||
columnsForWrapText,
|
||||
@@ -360,6 +393,9 @@ export function ListView<T extends object = any>({
|
||||
headerContent,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
mobileFiltersOpen = false,
|
||||
setMobileFiltersOpen,
|
||||
mobileFiltersDrawerTitle,
|
||||
}: ListViewProps<T>) {
|
||||
const {
|
||||
getTableProps,
|
||||
@@ -386,6 +422,7 @@ export function ListView<T extends object = any>({
|
||||
initialFilters: filters,
|
||||
renderCard: Boolean(renderCard),
|
||||
defaultViewMode,
|
||||
forceViewMode,
|
||||
});
|
||||
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
|
||||
const filterable = Boolean(filters.length);
|
||||
@@ -462,11 +499,15 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
<div data-test={className} className={`superset-list-view ${className} `}>
|
||||
<div className="header">
|
||||
{cardViewEnabled && (
|
||||
{cardViewEnabled && !forceViewMode && (
|
||||
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
|
||||
)}
|
||||
<div className="controls" data-test="filters-select">
|
||||
{filterable && (
|
||||
{/* When a mobile drawer callback is provided, filters and sort
|
||||
render inside the drawer instead of inline. Only one
|
||||
FilterControls instance is ever mounted, so filtersRef and
|
||||
filterControlsRef always point at the visible instance. */}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
@@ -474,14 +515,16 @@ export function ListView<T extends object = any>({
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
)}
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && (
|
||||
{viewMode === 'card' &&
|
||||
cardSortSelectOptions &&
|
||||
!setMobileFiltersOpen && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
{filterable && !setMobileFiltersOpen && (
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
@@ -660,6 +703,46 @@ export function ListView<T extends object = any>({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile filter drawer */}
|
||||
{filterable && setMobileFiltersOpen && (
|
||||
<Drawer
|
||||
title={mobileFiltersDrawerTitle || t('Search')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width={300}
|
||||
>
|
||||
<MobileFilterDrawerContent>
|
||||
<FilterControls
|
||||
ref={filterControlsRef}
|
||||
filters={filters}
|
||||
internalFilters={internalFilters}
|
||||
updateFilterValue={applyFilterValue}
|
||||
/>
|
||||
{viewMode === 'card' && cardSortSelectOptions && (
|
||||
<CardSortSelect
|
||||
initialSort={sortBy}
|
||||
onChange={(value: SortColumn[]) => setSortBy(value)}
|
||||
options={cardSortSelectOptions}
|
||||
/>
|
||||
)}
|
||||
<Tooltip
|
||||
title={!hasActiveFilters ? t('No filters applied') : undefined}
|
||||
>
|
||||
<span>
|
||||
<ClearAllButton
|
||||
type="button"
|
||||
disabled={!hasActiveFilters}
|
||||
onClick={() => filterControlsRef.current?.clearFilters()}
|
||||
>
|
||||
{t('Clear all')}
|
||||
</ClearAllButton>
|
||||
</span>
|
||||
</Tooltip>
|
||||
</MobileFilterDrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
</ListViewStyles>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -195,6 +195,7 @@ interface UseListViewConfig {
|
||||
initialFilters?: Filter[];
|
||||
renderCard?: boolean;
|
||||
defaultViewMode?: ViewModeType;
|
||||
forceViewMode?: ViewModeType;
|
||||
}
|
||||
|
||||
export function useListViewState({
|
||||
@@ -207,6 +208,7 @@ export function useListViewState({
|
||||
initialSort = [],
|
||||
renderCard = false,
|
||||
defaultViewMode = 'card',
|
||||
forceViewMode,
|
||||
}: UseListViewConfig) {
|
||||
const [query, setQuery] = useQueryParams({
|
||||
filters: RisonParam,
|
||||
@@ -234,10 +236,31 @@ export function useListViewState({
|
||||
};
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewModeType>(
|
||||
(query.viewMode as ViewModeType) ||
|
||||
// forceViewMode overrides everything (used for mobile)
|
||||
forceViewMode ||
|
||||
(query.viewMode as ViewModeType) ||
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
|
||||
// Update viewMode when forceViewMode changes (e.g., screen resize). When
|
||||
// forceViewMode is cleared (e.g., resizing from mobile back to desktop),
|
||||
// fall back to the persisted query param or the default view instead of
|
||||
// leaving the view stuck in the previously forced mode.
|
||||
useEffect(() => {
|
||||
if (forceViewMode) {
|
||||
setViewMode(forceViewMode);
|
||||
} else {
|
||||
setViewMode(
|
||||
(query.viewMode as ViewModeType) ||
|
||||
(renderCard ? defaultViewMode : 'table'),
|
||||
);
|
||||
}
|
||||
// Only react to forceViewMode transitions; query.viewMode, renderCard, and
|
||||
// defaultViewMode are read for their current values, not to retrigger
|
||||
// this effect on every change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [forceViewMode]);
|
||||
|
||||
const columnsWithFilter = useMemo(
|
||||
// add exact filter type so filters with falsy values are not filtered out
|
||||
() => columns.map(f => ({ ...f, filter: 'exact' })),
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* 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 { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileRouteGuard from '.';
|
||||
|
||||
jest.mock('src/hooks/useIsMobile', () => ({
|
||||
useIsMobile: jest.fn(),
|
||||
isMobileConsumptionEnabled: jest.fn().mockReturnValue(true),
|
||||
}));
|
||||
|
||||
const mockedUseIsMobile = useIsMobile as jest.MockedFunction<
|
||||
typeof useIsMobile
|
||||
>;
|
||||
|
||||
const renderGuard = (mobileSupported?: boolean) =>
|
||||
render(
|
||||
<MemoryRouter initialEntries={['/some/route/']}>
|
||||
<MobileRouteGuard mobileSupported={mobileSupported}>
|
||||
<div data-test="guarded-content">Content</div>
|
||||
</MobileRouteGuard>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
mockedUseIsMobile.mockReturnValue(false);
|
||||
});
|
||||
|
||||
test('renders children on desktop regardless of mobileSupported', () => {
|
||||
renderGuard(undefined);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders children on mobile when the route is mobileSupported', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(true);
|
||||
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('shows the unsupported screen on mobile for unsupported routes', () => {
|
||||
mockedUseIsMobile.mockReturnValue(true);
|
||||
renderGuard(undefined);
|
||||
expect(screen.queryByTestId('guarded-content')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("This view isn't available on mobile"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
52
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
52
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* 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 { ReactNode } from 'react';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import MobileUnsupported from 'src/pages/MobileUnsupported';
|
||||
|
||||
interface MobileRouteGuardProps {
|
||||
children: ReactNode;
|
||||
/**
|
||||
* Whether the wrapped route is part of the mobile consumption
|
||||
* experience. Set via the `mobileSupported` flag on the route
|
||||
* definition in `src/views/routes.tsx`.
|
||||
*/
|
||||
mobileSupported?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps route content and shows the MobileUnsupported page when a
|
||||
* non-mobile-friendly route is accessed on a small screen with
|
||||
* MOBILE_CONSUMPTION_MODE enabled. Growing the viewport past the
|
||||
* breakpoint unblocks the route automatically.
|
||||
*/
|
||||
function MobileRouteGuard({
|
||||
children,
|
||||
mobileSupported,
|
||||
}: MobileRouteGuardProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!isMobile || mobileSupported) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return <MobileUnsupported />;
|
||||
}
|
||||
|
||||
export default MobileRouteGuard;
|
||||
@@ -16,6 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// Imported first: loading this before 'spec/helpers/testing-library' or
|
||||
// '@superset-ui/core' ensures mockAntdWithDesktopBreakpoint is defined
|
||||
// before anything transitively requires (and thus mocks) 'antd'.
|
||||
import { mockAntdWithDesktopBreakpoint } from 'spec/helpers/mobileTestUtils';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
fireEvent,
|
||||
@@ -50,6 +54,9 @@ fetchMock.put('glob:*/api/v1/dashboard/*', {});
|
||||
// Add mock for logging endpoint
|
||||
fetchMock.post('glob:*/log/?*', {});
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
|
||||
jest.mock('src/dashboard/actions/dashboardState', () => ({
|
||||
...jest.requireActual('src/dashboard/actions/dashboardState'),
|
||||
fetchFaveStar: jest.fn(),
|
||||
@@ -422,6 +429,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -448,6 +456,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: false,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -474,6 +483,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { getByTestId } = setup();
|
||||
@@ -533,6 +543,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -546,6 +557,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup();
|
||||
|
||||
@@ -559,6 +571,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
const { queryByTestId } = setup({
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
@@ -576,6 +589,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
try {
|
||||
const { getByTestId } = setup();
|
||||
@@ -601,6 +615,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
try {
|
||||
const { getByTestId } = setup();
|
||||
@@ -622,6 +637,7 @@ describe('DashboardBuilder', () => {
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: true,
|
||||
hasFilters: true,
|
||||
});
|
||||
try {
|
||||
const { getByTestId } = setup();
|
||||
@@ -786,3 +802,62 @@ test('should maintain layout when switching between tabs', async () => {
|
||||
expect(gridContainer).toBeInTheDocument();
|
||||
expect(tabPanels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Mobile support tests
|
||||
// Note: The main mobile tests require mocking useBreakpoint to return mobile breakpoints
|
||||
// which is done at the module level. These tests verify mobile-related component behavior.
|
||||
|
||||
test('should not render filter bar panel on desktop when nativeFiltersEnabled is false', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
jest.spyOn(useNativeFiltersModule, 'useNativeFilters').mockReturnValue({
|
||||
showDashboard: true,
|
||||
missingInitialFilters: [],
|
||||
dashboardFiltersOpen: true,
|
||||
toggleDashboardFiltersOpen: jest.fn(),
|
||||
nativeFiltersEnabled: false,
|
||||
hasFilters: false,
|
||||
});
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
// Filter panel should not be present when native filters are disabled
|
||||
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render header container', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { queryByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
useRouter: true,
|
||||
});
|
||||
|
||||
// Header container should be present
|
||||
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core/translation';
|
||||
import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
|
||||
import { css, styled, useTheme } from '@apache-superset/core/theme';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { Drawer, EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||
import DashboardHeader from 'src/dashboard/components/Header';
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from 'src/dashboard/util/constants';
|
||||
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
|
||||
import { useUiConfig } from 'src/components/UiConfigContext';
|
||||
import { isMobileConsumptionEnabled, useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import ResizableSidebar from 'src/components/ResizableSidebar';
|
||||
import {
|
||||
BUILDER_SIDEPANEL_WIDTH,
|
||||
@@ -101,6 +102,19 @@ const StyledHeader = styled.div<{ filterBarWidth: number }>`
|
||||
z-index: 99;
|
||||
max-width: calc(100vw - ${filterBarWidth}px);
|
||||
|
||||
/* Mobile consumption mode: let the dashboard title scroll away and keep
|
||||
only the tab bar sticky. A pinned title would sit underneath the
|
||||
higher-z sticky tabs, leaving its bottom edge (kebab button) peeking
|
||||
out below the tab bar. */
|
||||
${
|
||||
isMobileConsumptionEnabled() &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
position: relative;
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
.empty-droptarget {
|
||||
min-height: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
@@ -372,6 +386,8 @@ const DashboardBuilder = () => {
|
||||
const dispatch = useDispatch();
|
||||
const uiConfig = useUiConfig();
|
||||
const theme = useTheme();
|
||||
const isNotMobile = !useIsMobile();
|
||||
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
|
||||
|
||||
const dashboardId = useSelector<RootState, string>(
|
||||
({ dashboardInfo }) => `${dashboardInfo.id}`,
|
||||
@@ -470,13 +486,14 @@ const DashboardBuilder = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
} = useNativeFilters();
|
||||
|
||||
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
|
||||
ELEMENT_ON_SCREEN_OPTIONS,
|
||||
);
|
||||
|
||||
const showFilterBar = !editMode && nativeFiltersEnabled;
|
||||
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
|
||||
|
||||
const offset =
|
||||
FILTER_BAR_HEADER_HEIGHT +
|
||||
@@ -488,6 +505,7 @@ const DashboardBuilder = () => {
|
||||
const draggableStyle = useMemo(
|
||||
() => ({
|
||||
marginLeft:
|
||||
!isNotMobile ||
|
||||
dashboardFiltersOpen ||
|
||||
editMode ||
|
||||
!nativeFiltersEnabled ||
|
||||
@@ -496,6 +514,7 @@ const DashboardBuilder = () => {
|
||||
: -32,
|
||||
}),
|
||||
[
|
||||
isNotMobile,
|
||||
dashboardFiltersOpen,
|
||||
editMode,
|
||||
filterBarOrientation,
|
||||
@@ -527,7 +546,15 @@ const DashboardBuilder = () => {
|
||||
const headerContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{!hideDashboardHeader && <DashboardHeader />}
|
||||
{!hideDashboardHeader && (
|
||||
<DashboardHeader
|
||||
onOpenMobileFilters={
|
||||
!isNotMobile && nativeFiltersEnabled && hasFilters
|
||||
? () => setMobileFiltersOpen(true)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{/* Report mode is a one-shot screenshot render (reports, thumbnails),
|
||||
so it must never start a refresh timer that could re-fetch charts
|
||||
mid-capture. */}
|
||||
@@ -541,7 +568,16 @@ const DashboardBuilder = () => {
|
||||
)}
|
||||
</>
|
||||
),
|
||||
[hideDashboardHeader, showFilterBar, filterBarOrientation, hideFilterBar],
|
||||
[
|
||||
hideDashboardHeader,
|
||||
isNotMobile,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters,
|
||||
showFilterBar,
|
||||
filterBarOrientation,
|
||||
hideFilterBar,
|
||||
isReport,
|
||||
],
|
||||
);
|
||||
|
||||
const renderDraggableContent = useCallback(
|
||||
@@ -585,6 +621,8 @@ const DashboardBuilder = () => {
|
||||
topLevelTabs,
|
||||
uiConfig.hideTab,
|
||||
uiConfig.hideNav,
|
||||
isNotMobile,
|
||||
theme,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -747,6 +785,36 @@ const DashboardBuilder = () => {
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
{/* Mobile filters drawer */}
|
||||
{!isNotMobile && nativeFiltersEnabled && (
|
||||
<Drawer
|
||||
title={t('Filters')}
|
||||
placement="left"
|
||||
onClose={() => setMobileFiltersOpen(false)}
|
||||
open={mobileFiltersOpen}
|
||||
width="85vw"
|
||||
styles={{
|
||||
body: {
|
||||
padding: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FilterBar
|
||||
orientation={FilterBarOrientation.Vertical}
|
||||
verticalConfig={{
|
||||
filtersOpen: true,
|
||||
toggleFiltersBar: () => {},
|
||||
width: 300,
|
||||
height: '100%',
|
||||
offset: 0,
|
||||
mobileMode: true,
|
||||
}}
|
||||
hidden={false}
|
||||
/>
|
||||
</Drawer>
|
||||
)}
|
||||
</DashboardWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSelector } from 'react-redux';
|
||||
import { useDragDropManager } from 'react-dnd';
|
||||
import classNames from 'classnames';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
|
||||
const StyledDiv = styled.div`
|
||||
${({ theme }) => css`
|
||||
@@ -110,6 +111,22 @@ const StyledDiv = styled.div`
|
||||
i.warning {
|
||||
color: ${theme.colorWarning};
|
||||
}
|
||||
|
||||
/* Mobile consumption mode: show the full chart title without
|
||||
truncation (controls and links are render-gated in SliceHeader) */
|
||||
${
|
||||
isMobileConsumptionEnabled()
|
||||
? `@media (max-width: ${theme.screenSMMax}px) {
|
||||
[data-test='slice-header'] .header-title {
|
||||
-webkit-line-clamp: unset;
|
||||
display: block;
|
||||
white-space: normal;
|
||||
overflow: visible;
|
||||
text-overflow: unset;
|
||||
}
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters: filterValues.length > 0 || chartCustomizations.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,38 +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, fireEvent } from 'spec/helpers/testing-library';
|
||||
import DeleteComponentButton from './DeleteComponentButton';
|
||||
|
||||
test('exposes an accessible name without rendering visible label text', () => {
|
||||
render(<DeleteComponentButton onDelete={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Delete component' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete component')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onDelete when clicked', () => {
|
||||
const onDelete = jest.fn();
|
||||
render(<DeleteComponentButton onDelete={onDelete} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Delete component' }));
|
||||
|
||||
expect(onDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import { MouseEventHandler, FC } from 'react';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import type { IconType } from '@superset-ui/core/components/Icons/types';
|
||||
import IconButton from './IconButton';
|
||||
@@ -34,8 +33,6 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
|
||||
}) => (
|
||||
<IconButton
|
||||
onClick={onDelete}
|
||||
label={t('Delete component')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -16,6 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// Imported first: loading this before 'spec/helpers/testing-library' or
|
||||
// '@superset-ui/core' ensures mockAntdWithDesktopBreakpoint is defined
|
||||
// before anything transitively requires (and thus mocks) 'antd'.
|
||||
import { mockAntdWithDesktopBreakpoint } from 'spec/helpers/mobileTestUtils';
|
||||
import * as redux from 'redux';
|
||||
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
||||
import { screen, userEvent, within, waitFor } from '@superset-ui/core/spec';
|
||||
@@ -186,6 +190,9 @@ const recordError = jest.fn();
|
||||
const setPaused = jest.fn();
|
||||
const setPausedByTab = jest.fn();
|
||||
|
||||
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
|
||||
jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
|
||||
|
||||
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
|
||||
useUnsavedChangesPrompt: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -23,7 +23,12 @@ import {
|
||||
FeatureFlag,
|
||||
getExtensionsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
styled,
|
||||
css,
|
||||
SupersetTheme,
|
||||
useTheme,
|
||||
} from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { Global } from '@emotion/react';
|
||||
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
|
||||
@@ -37,6 +42,7 @@ import {
|
||||
UnsavedChangesModal,
|
||||
} from '@superset-ui/core/components';
|
||||
import { findPermission } from 'src/utils/findPermission';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
import Subject from 'src/types/Subject';
|
||||
import { DashboardLayout, RootState } from 'src/dashboard/types';
|
||||
@@ -219,8 +225,14 @@ const discardChanges = () => {
|
||||
window.location.assign(url);
|
||||
};
|
||||
|
||||
const Header = (): JSX.Element => {
|
||||
interface HeaderComponentProps {
|
||||
onOpenMobileFilters?: () => void;
|
||||
}
|
||||
|
||||
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
|
||||
const dispatch = useDispatch();
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
|
||||
useState(false);
|
||||
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
|
||||
@@ -631,7 +643,8 @@ const Header = (): JSX.Element => {
|
||||
|
||||
const titlePanelAdditionalItems = useMemo(
|
||||
() => [
|
||||
!editMode && (
|
||||
// The kebab menu's "Refresh dashboard" item covers this on mobile
|
||||
!editMode && !isMobile && (
|
||||
<RefreshButton key="refresh-button" onRefresh={forceRefresh} />
|
||||
),
|
||||
!editMode && (
|
||||
@@ -640,7 +653,7 @@ const Header = (): JSX.Element => {
|
||||
onTogglePause={handlePauseToggle}
|
||||
/>
|
||||
),
|
||||
!editMode && (
|
||||
!editMode && !isMobile && (
|
||||
<PublishedStatus
|
||||
key="published-status"
|
||||
dashboardId={dashboardInfo.id}
|
||||
@@ -650,12 +663,13 @@ const Header = (): JSX.Element => {
|
||||
userCanSave={userCanSaveAs}
|
||||
/>
|
||||
),
|
||||
!editMode && !isEmbedded && metadataBar,
|
||||
!editMode && !isEmbedded && !isMobile && metadataBar,
|
||||
],
|
||||
[
|
||||
boundActionCreators.savePublished,
|
||||
dashboardInfo.id,
|
||||
editMode,
|
||||
isMobile,
|
||||
metadataBar,
|
||||
isEmbedded,
|
||||
isPublished,
|
||||
@@ -752,7 +766,7 @@ const Header = (): JSX.Element => {
|
||||
) : (
|
||||
<div css={actionButtonsStyle}>
|
||||
{NavExtension && <NavExtension />}
|
||||
{userCanEdit && !isEmbedded && (
|
||||
{userCanEdit && !isEmbedded && !isMobile && (
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
onClick={handleEnterEditMode}
|
||||
@@ -780,6 +794,7 @@ const Header = (): JSX.Element => {
|
||||
handleEnterEditMode,
|
||||
hasUnsavedChanges,
|
||||
isEmbedded,
|
||||
isMobile,
|
||||
overwriteDashboard,
|
||||
redoLength,
|
||||
undoLength,
|
||||
@@ -816,6 +831,10 @@ const Header = (): JSX.Element => {
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar: boundActionCreators.saveFaveStar,
|
||||
showReportModal,
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
@@ -835,6 +854,21 @@ const Header = (): JSX.Element => {
|
||||
editableTitleProps={editableTitleProps}
|
||||
certificatiedBadgeProps={certifiedBadgeProps}
|
||||
faveStarProps={faveStarProps}
|
||||
leftPanelItems={
|
||||
onOpenMobileFilters && (
|
||||
<Button
|
||||
buttonStyle="link"
|
||||
aria-label={t('Open filters')}
|
||||
onClick={onOpenMobileFilters}
|
||||
data-test="mobile-filters-trigger"
|
||||
>
|
||||
<Icons.FilterOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
iconSize="l"
|
||||
/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
titlePanelAdditionalItems={titlePanelAdditionalItems}
|
||||
rightPanelAdditionalItems={rightPanelAdditionalItems}
|
||||
menuDropdownProps={{
|
||||
@@ -842,7 +876,7 @@ const Header = (): JSX.Element => {
|
||||
onOpenChange: setIsDropdownVisible,
|
||||
}}
|
||||
additionalActionsMenu={menu}
|
||||
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
|
||||
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
|
||||
showTitlePanelItems
|
||||
/>
|
||||
{showingPropertiesModal && (
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface HeaderDropdownProps {
|
||||
forceRefreshAllCharts: () => unknown;
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
isMobile?: boolean;
|
||||
isStarred?: boolean;
|
||||
isPublished?: boolean;
|
||||
saveFaveStar?: (id: number, isStarred: boolean) => void;
|
||||
layout: Layout;
|
||||
onSave: (...args: unknown[]) => unknown;
|
||||
refreshFrequency: number;
|
||||
|
||||
@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import getUserName from 'src/utils/getUserName';
|
||||
|
||||
export const useHeaderActionsMenu = ({
|
||||
customCss,
|
||||
@@ -56,6 +57,10 @@ export const useHeaderActionsMenu = ({
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar,
|
||||
lastModifiedTime,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
@@ -117,6 +122,11 @@ export const useHeaderActionsMenu = ({
|
||||
case MenuKeys.ManageEmbedded:
|
||||
manageEmbedded();
|
||||
break;
|
||||
case 'toggle-favorite':
|
||||
if (saveFaveStar && isStarred !== undefined) {
|
||||
saveFaveStar(dashboardId, isStarred);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -128,6 +138,9 @@ export const useHeaderActionsMenu = ({
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
manageEmbedded,
|
||||
saveFaveStar,
|
||||
dashboardId,
|
||||
isStarred,
|
||||
history,
|
||||
location,
|
||||
],
|
||||
@@ -205,6 +218,52 @@ export const useHeaderActionsMenu = ({
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// Mobile-only: show dashboard info items in menu
|
||||
if (isMobile && !editMode) {
|
||||
// Favorite toggle
|
||||
if (saveFaveStar) {
|
||||
menuItems.push({
|
||||
key: 'toggle-favorite',
|
||||
label: isStarred ? t('Remove from favorites') : t('Add to favorites'),
|
||||
});
|
||||
}
|
||||
|
||||
// Published status
|
||||
menuItems.push({
|
||||
key: 'status-info',
|
||||
label: isPublished ? t('Status: Published') : t('Status: Draft'),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Editor info
|
||||
const editorNames = dashboardInfo?.editors?.length
|
||||
? dashboardInfo.editors
|
||||
.map((editor: { label?: string }) => editor.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('None');
|
||||
menuItems.push({
|
||||
key: 'owner-info',
|
||||
label: t('Owner: %(names)s', { names: editorNames }),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Last modified
|
||||
const modifiedBy =
|
||||
getUserName(dashboardInfo?.changed_by) || t('Not available');
|
||||
const modifiedDate = dashboardInfo?.changed_on_delta_humanized || '';
|
||||
menuItems.push({
|
||||
key: 'modified-info',
|
||||
label: t('Modified %(date)s by %(user)s', {
|
||||
date: modifiedDate,
|
||||
user: modifiedBy,
|
||||
}),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
menuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
// Refresh dashboard
|
||||
if (!editMode) {
|
||||
menuItems.push({
|
||||
@@ -224,8 +283,8 @@ export const useHeaderActionsMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle fullscreen
|
||||
if (!editMode && !isEmbedded) {
|
||||
// Toggle fullscreen (hide on mobile)
|
||||
if (!editMode && !isEmbedded && !isMobile) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ToggleFullscreen,
|
||||
label: getUrlParam(URL_PARAMS.standalone)
|
||||
@@ -245,8 +304,8 @@ export const useHeaderActionsMenu = ({
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Save as
|
||||
if (userCanSave) {
|
||||
// Save as (authoring action, hidden on mobile consumption-only menu)
|
||||
if (userCanSave && !isMobile) {
|
||||
menuItems.push(
|
||||
createModalMenuItem(
|
||||
MenuKeys.SaveModal,
|
||||
@@ -283,8 +342,8 @@ export const useHeaderActionsMenu = ({
|
||||
menuItems.push(shareMenuItems);
|
||||
}
|
||||
|
||||
// Embed dashboard
|
||||
if (!editMode && userCanCurate) {
|
||||
// Embed dashboard (authoring action, hidden on mobile consumption-only menu)
|
||||
if (!editMode && userCanCurate && !isMobile) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ManageEmbedded,
|
||||
label: t('Embed dashboard'),
|
||||
@@ -293,15 +352,15 @@ export const useHeaderActionsMenu = ({
|
||||
|
||||
// Only add divider if there are items after it
|
||||
const hasItemsAfterDivider =
|
||||
(!editMode && reportMenuItem) ||
|
||||
(!editMode && reportMenuItem && !isMobile) ||
|
||||
(editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes));
|
||||
|
||||
if (hasItemsAfterDivider) {
|
||||
menuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
// Report dropdown
|
||||
if (!editMode && reportMenuItem) {
|
||||
// Report dropdown (hide on mobile)
|
||||
if (!editMode && reportMenuItem && !isMobile) {
|
||||
menuItems.push(reportMenuItem);
|
||||
}
|
||||
|
||||
@@ -339,11 +398,15 @@ export const useHeaderActionsMenu = ({
|
||||
expandedSlices,
|
||||
handleMenuClick,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isPublished,
|
||||
isStarred,
|
||||
lastModifiedTime,
|
||||
layout,
|
||||
onSave,
|
||||
refreshFrequency,
|
||||
reportMenuItem,
|
||||
saveFaveStar,
|
||||
shareMenuItems,
|
||||
shouldPersistRefreshFrequency,
|
||||
userCanCurate,
|
||||
|
||||
@@ -73,17 +73,3 @@ test('renders the provided label', () => {
|
||||
|
||||
expect(screen.getByText('My Label')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hideVisibleLabel suppresses visible text but keeps the accessible name', () => {
|
||||
render(
|
||||
<IconButton
|
||||
icon={icon}
|
||||
onClick={jest.fn()}
|
||||
label="My Label"
|
||||
hideVisibleLabel
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('My Label')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'My Label' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,6 @@ import { styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
interface IconButtonProps extends HTMLAttributes<HTMLButtonElement> {
|
||||
icon: JSX.Element;
|
||||
label?: string;
|
||||
hideVisibleLabel?: boolean;
|
||||
onClick: MouseEventHandler<HTMLButtonElement>;
|
||||
disabled?: boolean;
|
||||
'data-test'?: string;
|
||||
@@ -64,7 +63,6 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{
|
||||
icon,
|
||||
label,
|
||||
hideVisibleLabel,
|
||||
onClick,
|
||||
onKeyDown,
|
||||
disabled,
|
||||
@@ -77,7 +75,6 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
{...rest}
|
||||
ref={ref}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
isDisabled={disabled}
|
||||
aria-disabled={disabled}
|
||||
data-test={dataTest}
|
||||
@@ -94,7 +91,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
|
||||
{label && <StyledSpan>{label}</StyledSpan>}
|
||||
</StyledButton>
|
||||
),
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
|
||||
import MemoizedFiltersBadge from 'src/dashboard/components/FiltersBadge';
|
||||
import MemoizedCustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
|
||||
@@ -229,7 +230,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
0,
|
||||
);
|
||||
|
||||
const canExplore = !editMode && supersetCanExplore;
|
||||
// Consumption-only mobile mode: no explore link, no chart controls
|
||||
const isMobile = useIsMobile();
|
||||
const canExplore = !editMode && supersetCanExplore && !isMobile;
|
||||
const showRowLimitWarning =
|
||||
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
|
||||
|
||||
@@ -355,7 +358,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!uiConfig.hideChartControls && (
|
||||
{!uiConfig.hideChartControls && !isMobile && (
|
||||
<SliceHeaderControls
|
||||
slice={slice}
|
||||
isCached={isCached}
|
||||
|
||||
@@ -566,31 +566,3 @@ test('should pass filterState from dataMask to ChartContainer', () => {
|
||||
mockFilterState,
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass chartStackTrace to ChartContainer so dashboard chart errors stay expandable', () => {
|
||||
// Regression guard for #31858: the dashboard chart wrapper stopped forwarding
|
||||
// the stack trace, so failed charts rendered a flat error with no "See more"
|
||||
// affordance while the same error in Explore stayed expandable.
|
||||
const stackTrace = 'Traceback (most recent call last): ValueError: boom';
|
||||
|
||||
setup(
|
||||
{},
|
||||
{
|
||||
...defaultState,
|
||||
charts: {
|
||||
...defaultState.charts,
|
||||
[queryId]: {
|
||||
...defaultState.charts[queryId],
|
||||
chartStatus: 'failed',
|
||||
chartAlert: 'Something went wrong',
|
||||
chartStackTrace: stackTrace,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(capturedChartContainerProps).toHaveProperty(
|
||||
'chartStackTrace',
|
||||
stackTrace,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -789,7 +789,6 @@ const Chart = (props: ChartProps) => {
|
||||
chartAlert={chart.chartAlert ?? undefined}
|
||||
chartId={props.id}
|
||||
chartStatus={chartStatus ?? undefined}
|
||||
chartStackTrace={chart.chartStackTrace ?? undefined}
|
||||
datasource={datasource}
|
||||
dashboardId={props.dashboardId}
|
||||
initialValues={EMPTY_OBJECT}
|
||||
|
||||
@@ -36,13 +36,19 @@ import { AntdThemeProvider } from '@superset-ui/core/components';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import {
|
||||
GRID_BASE_UNIT,
|
||||
GRID_COLUMN_COUNT,
|
||||
GRID_GUTTER_SIZE,
|
||||
GRID_MIN_COLUMN_COUNT,
|
||||
GRID_MIN_ROW_UNITS,
|
||||
} from 'src/dashboard/util/constants';
|
||||
import { useIsMobile } from 'src/hooks/useIsMobile';
|
||||
|
||||
export const CHART_MARGIN = 32;
|
||||
|
||||
// Vertical space reserved for app chrome (main nav + dashboard header +
|
||||
// tab bar) when capping chart heights to the viewport on mobile.
|
||||
export const MOBILE_CHROME_HEIGHT = 160;
|
||||
|
||||
export interface ChartHolderProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
@@ -96,6 +102,7 @@ const ChartHolder = ({
|
||||
isInView,
|
||||
}: ChartHolderProps) => {
|
||||
const theme = useTheme();
|
||||
const isMobile = useIsMobile();
|
||||
const fullSizeStyle = css`
|
||||
&& {
|
||||
position: fixed !important;
|
||||
@@ -167,6 +174,14 @@ const ChartHolder = ({
|
||||
}, [outlinedComponentId]);
|
||||
|
||||
const widthMultiple = useMemo(() => {
|
||||
// Mobile consumption mode stacks charts vertically at full width, so
|
||||
// report the full column count. This keeps the pixel width handed to the
|
||||
// chart plugin (and to ResizableContainer's inline size) in sync with the
|
||||
// stacked layout instead of the desktop grid fraction.
|
||||
if (isMobile && !editMode) {
|
||||
return GRID_COLUMN_COUNT;
|
||||
}
|
||||
|
||||
const columnParentWidth = getComponentById(
|
||||
parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)),
|
||||
)?.meta?.width;
|
||||
@@ -182,11 +197,29 @@ const ChartHolder = ({
|
||||
}, [
|
||||
component,
|
||||
getComponentById,
|
||||
isMobile,
|
||||
editMode,
|
||||
parentComponent.meta.width,
|
||||
parentComponent.parents,
|
||||
parentComponent.type,
|
||||
]);
|
||||
|
||||
// Grid units of height for this chart. In mobile consumption mode the
|
||||
// authored desktop height is capped to the viewport (minus app chrome) so
|
||||
// tall charts don't dominate the single-column stacked layout. Used for
|
||||
// both the ResizableContainer shell and the height handed to the plugin,
|
||||
// so the two can't disagree.
|
||||
const heightMultiple = useMemo(() => {
|
||||
const authoredHeight = component.meta.height ?? GRID_MIN_ROW_UNITS;
|
||||
if (isMobile && !editMode) {
|
||||
const maxUnits = Math.floor(
|
||||
(window.innerHeight - MOBILE_CHROME_HEIGHT) / GRID_BASE_UNIT,
|
||||
);
|
||||
return Math.max(GRID_MIN_ROW_UNITS, Math.min(authoredHeight, maxUnits));
|
||||
}
|
||||
return authoredHeight;
|
||||
}, [component.meta.height, isMobile, editMode]);
|
||||
|
||||
const { chartWidth, chartHeight } = useMemo(() => {
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
@@ -200,16 +233,14 @@ const ChartHolder = ({
|
||||
(widthMultiple - 1) * GRID_GUTTER_SIZE -
|
||||
CHART_MARGIN,
|
||||
);
|
||||
height = Math.floor(
|
||||
(component.meta.height ?? 0) * GRID_BASE_UNIT - CHART_MARGIN,
|
||||
);
|
||||
height = Math.floor(heightMultiple * GRID_BASE_UNIT - CHART_MARGIN);
|
||||
}
|
||||
|
||||
return {
|
||||
chartWidth: width,
|
||||
chartHeight: height,
|
||||
};
|
||||
}, [columnWidth, component, isFullSize, widthMultiple]);
|
||||
}, [columnWidth, heightMultiple, isFullSize, widthMultiple]);
|
||||
|
||||
const handleDeleteComponent = useCallback(() => {
|
||||
deleteComponent(id, parentId);
|
||||
@@ -250,7 +281,7 @@ const ChartHolder = ({
|
||||
widthStep={columnWidth}
|
||||
widthMultiple={widthMultiple}
|
||||
heightStep={GRID_BASE_UNIT}
|
||||
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
|
||||
heightMultiple={heightMultiple}
|
||||
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
||||
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
||||
maxWidthMultiple={availableColumnCount + widthMultiple}
|
||||
@@ -342,12 +373,12 @@ const ChartHolder = ({
|
||||
),
|
||||
[
|
||||
component.id,
|
||||
component.meta.height,
|
||||
component.meta.chartId,
|
||||
component.meta.sliceNameOverride,
|
||||
component.meta.sliceName,
|
||||
parentComponent.type,
|
||||
columnWidth,
|
||||
heightMultiple,
|
||||
widthMultiple,
|
||||
availableColumnCount,
|
||||
onResizeStart,
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import React from 'react';
|
||||
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
|
||||
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
@@ -200,15 +200,6 @@ test('should call deleteComponent when deleted', () => {
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('settings IconButton exposes an accessible name without visible label text', () => {
|
||||
setup({ component: columnWithoutChildren, editMode: true });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Column settings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Column settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass its own width as availableColumnCount to children', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(
|
||||
|
||||
@@ -247,8 +247,6 @@ const Column = (props: ColumnProps) => {
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Column settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="m" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
@@ -145,9 +145,7 @@ describe('Header', () => {
|
||||
const deleteComponent = jest.fn();
|
||||
setup({ editMode: true, deleteComponent });
|
||||
|
||||
const trashButton = screen.getByRole('button', {
|
||||
name: 'Delete component',
|
||||
});
|
||||
const trashButton = screen.getByRole('button', { name: 'delete' });
|
||||
fireEvent.click(trashButton);
|
||||
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -240,15 +240,6 @@ test('should call deleteComponent when deleted', () => {
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('settings IconButton exposes an accessible name without visible label text', () => {
|
||||
setup({ component: rowWithoutChildren, editMode: true });
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Row settings' }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText('Row settings')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass appropriate availableColumnCount to children', () => {
|
||||
const { getByTestId } = setup();
|
||||
expect(getByTestId('mock-dashboard-component')).toHaveTextContent(
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
Droppable,
|
||||
} from 'src/dashboard/components/dnd/DragDroppable';
|
||||
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
|
||||
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
|
||||
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
|
||||
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
|
||||
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
||||
@@ -120,6 +121,27 @@ const GridRow = styled.div<{ editMode: boolean }>`
|
||||
&.grid-row--empty {
|
||||
min-height: ${theme.sizeUnit * 25}px;
|
||||
}
|
||||
|
||||
${
|
||||
isMobileConsumptionEnabled() &&
|
||||
css`
|
||||
@media (max-width: ${theme.screenSMMax}px) {
|
||||
flex-direction: column;
|
||||
|
||||
& > :not(.hover-menu) {
|
||||
width: 100% !important;
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
|
||||
/* Stacked children get the same vertical gutter GridContent puts
|
||||
between rows and Column puts between its children, so spacing
|
||||
stays uniform across the whole stacked layout */
|
||||
& > :not(.hover-menu):not(:last-child) {
|
||||
margin-bottom: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -293,8 +315,6 @@ const Row = memo((props: RowProps) => {
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
<IconButton
|
||||
onClick={() => handleChangeFocus(true)}
|
||||
label={t('Row settings')}
|
||||
hideVisibleLabel
|
||||
icon={<Icons.SettingOutlined iconSize="l" />}
|
||||
/>
|
||||
</HoverMenu>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user