Compare commits

..

11 Commits

Author SHA1 Message Date
rusackas
0dfb778bcd fix(mobile): address review-thread findings on mobile dashboard mode
- Include chart customizations in hasFilters so customization-only
  dashboards get a mobile filter drawer trigger, not just a drawer
  nobody can open (DashboardBuilder/state.ts).
- Fix the mobile "Dashboards" link under APPLICATION_ROOT deployments
  by routing it through stripAppRoot/ensureAppRoot like the other
  desktop menu links (RightMenu.tsx).
- Match the language picker's actual menu key (language-submenu, not
  language-picker) so it shows up in the mobile menu (RightMenu.tsx).
- Initialize useIsMobile's viewport state synchronously from
  matchMedia so MobileRouteGuard doesn't mount unsupported route
  content for a commit before the mobile check settles.
- Hide the authoring-only Save as / Embed dashboard menu items on the
  mobile consumption-only header menu.
- Fix the playwright mobile filter button locator to match the real
  data-test/aria-label the trigger renders, so the drawer tests
  actually exercise the flow instead of always skipping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-03 13:05:02 -07:00
rusackas
4eaffae7a0 test(mobile): dedupe navigation and filter-button helpers in playwright spec
Extract openFirstDashboard() and getMobileFilterButton() helpers to
remove duplicated dashboard-open and filter-locator logic across
mobile-dashboard.spec.ts tests, per bito review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-01 11:00:06 -07:00
rusackas
441b62bd26 fix(dashboard): add missing hasFilters field to DashboardBuilder test mocks
Three test blocks mocking useNativeFilters were missing the hasFilters
field added to the hook's return type, causing TS2345 in lint-frontend CI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-31 16:26:47 -07:00
Superset Dev
a0a427c5e6 fix(mobile): create screenshot output dir defensively
MOBILE_SCREENSHOTS_DIR is committed to the repo alongside the generated
images, so this was never hit in practice, but Playwright doesn't create
missing parent directories for screenshot paths — mkdir defensively so
the generator also works standalone against a clean/pruned checkout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:12:24 -07:00
rusackas
7b480d2682 fix(mobile): correct sm breakpoint mock and auth for beforeAll check
The mobile jest breakpoint mock reported sm:true at a 375px viewport,
which is inconsistent with antd's actual sm (>=576px) breakpoint. The
Playwright beforeAll dashboard-card check also created an unauthenticated
page via browser.newPage() (which doesn't inherit the project's
storageState), so it always hit the login page and skipped the suite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:23 -07:00
rusackas
1e230f19f9 fix: reuse mockAntdWithDesktopBreakpoint helper in remaining test files
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:20 -07:00
rusackas
e472e04b5a fix: address bito review nits (mockAntdWithDesktopBreakpoint reuse, dedupe button css)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:19 -07:00
rusackas
bd2a495094 fix: address mobile playwright test review feedback
Convert silent no-op if-guards in mobile dashboard interaction tests to
explicit test.skip() calls so a missing dashboard/menu/refresh option is
reported as skipped rather than a false-positive pass. Also fix a
contradictory comment in the filter drawer tests and align the second
filter-drawer test's locator with the first test's .mobile-filter-button
fallback selector.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:18 -07:00
rusackas
e9298b24ae fix(frontend): fix jest antd-mock hoist order and address mobile review feedback
Jest module registration order caused Home.test.tsx and DashboardList.test.tsx
to reference mockAntdWithDesktopBreakpoint before it was defined, since
importing it from '@superset-ui/core' pulled in the theme/antd chain first.
Also fixes a stuck view-mode bug when forceViewMode clears, a vacuous
Playwright filter assertion, and an overly-permissive matchMedia mock, all
flagged in review.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:17 -07:00
rusackas
2beb86859f chore(mobile): address bot review nits
- remove redundant inline feature_flag_manager import
- use URL.DASHBOARD_LIST constant in mobile-dashboard e2e spec
- add No-filters tooltip to drawer Clear all button for parity
- drop duplicated dashboard-content-wrapper render test
- consolidate desktop breakpoint mocks onto mockAntdWithDesktopBreakpoint

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-30 13:12:16 -07:00
Superset Dev
f9da2f93de feat(mobile): consumption-only mobile experience behind MOBILE_CONSUMPTION_MODE
Adds an opt-in, consumption-only mobile experience (feature flag
MOBILE_CONSUMPTION_MODE, default off, @lifecycle: development):

- Dashboards: charts stacked full-width with real plugin dimensions
  (ChartHolder reports full column count on mobile; heights capped to
  the viewport minus chrome), sticky swipeable tab bars with gradient
  overflow affordances, filter bar in a drawer (FilterBar mobileMode),
  compact header (title scrolls away; edit/publish/fave/refresh controls
  hidden; dashboard info moved into the kebab menu)
- Dashboard list: forced card view, full-width cards, search/filters and
  sort in a drawer (single FilterControls instance)
- Home: dashboards-only Recents, compact empty states, desktop-only
  sections hidden
- Navigation: hamburger drawer (dashboards, theme/language, user
  info/logout with row-tap navigation)
- Route guarding: routes declare mobileSupported in routes.tsx;
  everything else renders a MobileUnsupported screen; viewport growth
  unblocks automatically (useIsMobile subscribes to matchMedia only when
  the flag is on, so flag-off deployments have zero render delta)
- Serves a viewport meta tag (flag-gated) so mobile browsers lay out at
  device width instead of the ~980px legacy viewport; exposes
  is_feature_enabled to Jinja via the common context processor
- User docs (using-superset/mobile-experience.mdx) with a Playwright
  screenshot generator following the docs:screenshots pattern
- Docker dev config enables the flag; jest + Playwright coverage
  throughout

Squashed from the iterative mobile-dashboard-support history (preserved
at backup/mobile-pre-rebase-2).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-30 13:12:15 -07:00
203 changed files with 4004 additions and 4596 deletions

View File

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

View File

@@ -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)."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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.

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

@@ -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();
});

View File

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

View File

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

View File

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

View File

@@ -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}
/>
);
}

View File

@@ -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();
});
});

View File

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

View File

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

View File

@@ -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;
}`
: ''
}
`}
`;

View File

@@ -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();
});

View File

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

View File

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

View File

@@ -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();
});

View 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;

View File

@@ -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();
});

View File

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

View File

@@ -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;
}
}`
: ''
}
`}
`;

View File

@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
hasFilters: filterValues.length > 0 || chartCustomizations.length > 0,
};
};

View File

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

View File

@@ -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'} />}
/>
);

View File

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

View File

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

View File

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

View File

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

View File

@@ -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();
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -247,8 +247,6 @@ const Column = (props: ColumnProps) => {
/>
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Column settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>

View File

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

View File

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

View File

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