Compare commits

..

31 Commits

Author SHA1 Message Date
Elizabeth Thompson
7d5e8ad785 fix(sqllab): roll back session before retrying get_query after a broken transaction
`get_query` catches any exception from the ORM lookup and relies on the
`backoff` decorator to retry up to 5 times. When the underlying failure is
(or causes) a SQLAlchemy `PendingRollbackError` - e.g. a `PendingRollbackError`
chained under an `OperationalError`/`QueryCanceled` from a dropped connection
or statement timeout - the session is left in a broken state that SQLAlchemy
refuses to use again until `.rollback()` is called explicitly. Since the
session was never rolled back, every one of the 5 retries reused the same
poisoned session and failed identically, so the retry loop never had a
chance to recover from what may be a transient connection blip.

Roll back the session in the except block before raising `SqlLabException`
so each `backoff` retry starts from a clean session. The exception raised
and logged is unchanged.

Fixes SUPERSET-PYTHON-WDZ

Co-Authored-By: Claude <noreply@anthropic.com>
2026-08-01 15:12:43 +00:00
Amin Ghadersohi
22c305f758 fix(dataset): retry metadata after OAuth2 authorization (#42581) 2026-07-31 20:41:42 -04:00
Elizabeth Thompson
6929d032b8 fix(reports): time-budget tiled screenshot to fail cleanly instead of hitting Celery kill (#42118)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:37:46 -07:00
Elizabeth Thompson
f6c574edd8 fix(screenshots): validate cached screenshot image bytes on read and write (#42120)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:13:59 -07:00
Elizabeth Thompson
b452c1634d fix(a11y): add aria-label to dashboard IconButton and unlabeled call sites (#41470)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-31 15:05:32 -07:00
Elizabeth Thompson
1bfbce3cfd fix(alerts-reports): catch CroniterBadDateError in report frequency validation (#42650) 2026-07-31 15:04:30 -07:00
Elizabeth Thompson
85bab0b07c fix(reports): downgrade chart-container timeout log level and fix tiling veto on unknown height (#42153)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 15:00:38 -07:00
dependabot[bot]
0981b1101a chore(deps-dev): bump nx from 22.6.1 to 22.7.8 in /superset-frontend (#42651)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 12:49:05 -07:00
Elizabeth Thompson
f607e17e3a fix(reports): fail loudly instead of falling back to unguarded screenshot when tiled capture fails (#42273)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-31 12:41:29 -07:00
Evan Rusackas
6c8763bf5a fix(db2): stop truncating table comments to one character (#42645)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-31 12:36:36 -07:00
PRATHAMESH HUKKERI
035eaa8b80 fix(time-comparison): preserve inner bounds for relative offsets (#42357)
Co-authored-by: Prathamesh Hukkeri <prathamesh04@users.noreply.github.com>
2026-07-31 11:03:47 -07:00
Arijit Kumar Roy
ec8405aeea chore(mcp): remove unused MCP_SERVICE_HOST and MCP_SERVICE_PORT config (#42569)
Signed-off-by: arijitroy003 <arijitroy003@gmail.com>
2026-07-31 11:01:39 -07:00
Evan Rusackas
dc632a9737 fix(db_engine_specs): stop filtering out Postgres schemas prefixed with pg (#42312)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 10:58:59 -07:00
Abdul Rehman
e391691328 fix(menu): highlight Datasets tab on /dataset/add/ and /dataset/:id (#42529) 2026-07-31 10:56:36 -07:00
lunar-me
a419a2a47f docs: fix incorrect capitalization 'PiPY' to 'PyPI' in RELEASING/README.md (#42649)
Co-authored-by: pi <pi@m2.local>
2026-07-31 10:44:51 -07:00
lunar-me
263d793b77 docs: fix missing apostrophe in 'doesn't' in superset/mcp_service/README.md (#42648)
Co-authored-by: pi <pi@m2.local>
2026-07-31 10:44:30 -07:00
lunar-me
cd77d13cfe docs: fix typo 'throuth' → 'through' in superset-embedded-sdk/README.md (#42647)
Co-authored-by: pi <pi@m2.local>
2026-07-31 10:44:11 -07:00
dependabot[bot]
268662fa49 chore(deps): bump mapbox-gl from 3.26.0 to 3.27.0 in /superset-frontend (#42636)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 10:41:41 -07:00
dependabot[bot]
c0f9dec963 chore(deps): bump docker/login-action from 3.7.0 to 4.5.1 (#42632)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-31 10:41:06 -07:00
dependabot[bot]
8e08a65abb chore(deps): bump google-auth-library from 10.9.0 to 10.9.1 in /superset-frontend (#42633) 2026-07-31 23:37:20 +07:00
dependabot[bot]
1a7f2afae9 chore(deps): bump antd from 6.5.1 to 6.5.2 in /docs (#42630) 2026-07-31 23:36:46 +07:00
dependabot[bot]
05f45863cb chore(deps-dev): bump webpack from 5.108.4 to 5.109.0 in /docs (#42631) 2026-07-31 23:36:21 +07:00
dependabot[bot]
96a12e0442 chore(deps-dev): bump webpack from 5.108.4 to 5.109.0 in /superset-frontend (#42634) 2026-07-31 23:20:44 +07:00
dependabot[bot]
06effe2961 chore(deps): bump fs-extra from 11.3.2 to 11.4.0 in /superset-frontend (#42635) 2026-07-31 23:19:50 +07:00
Evan Rusackas
8235d0c4fb fix(alerts-reports): skip inactive users when resolving report executor (#42612) 2026-07-31 09:15:59 -07:00
Evan Rusackas
67d05d0ed7 docs(installation): document how to add translations to a custom Docker image (#42586)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:15:42 -07:00
Evan Rusackas
837ae95b7b feat(ci): auto-approve Dependabot patch-level bumps (#42508)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 09:15:26 -07:00
Evan Rusackas
3692ac5870 ci: de-vendor helm/chart-releaser-action (#42506)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-31 09:15:11 -07:00
Evan Rusackas
1df022b364 ci: mirror CI service images to GHCR (fork-safe Docker Hub pulls, groundwork) (#40880)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-31 09:14:30 -07:00
endimonan
7d2b184079 fix(explore): show selected current date range (#42616)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-07-31 10:55:09 +02:00
Amin Ghadersohi
7b351d53cf fix(mcp): fall back to a temporal dataset column (#42575) 2026-07-30 17:39:49 -04:00
128 changed files with 2848 additions and 3564 deletions

View File

@@ -46,7 +46,7 @@ jobs:
- name: Login to Docker Hub
if: steps.check.outputs.python
continue-on-error: true
uses: docker/login-action@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

View File

@@ -0,0 +1,63 @@
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

@@ -0,0 +1,113 @@
# 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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.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

@@ -38,6 +38,11 @@ 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,13 +95,8 @@ 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: ./.github/actions/chart-releaser-action
uses: helm/chart-releaser-action@cae68fefc6b5f367a0275617c9f83181ba54714f # v1.7.0
with:
version: v1.6.0
charts_dir: helm

View File

@@ -155,17 +155,6 @@ 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

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@06fb636fac595d6fb4b28a5dfcb21a6f5091859c # v4.5.0
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
with:
username: ${{ secrets.DOCKERHUB_USER }}
password: ${{ secrets.DOCKERHUB_TOKEN }}

3
.gitmodules vendored
View File

@@ -30,9 +30,6 @@
[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 PiPY release. Files in the `/tmp` folder will be automatically deleted by the OS.
Extract the release to the `/tmp` folder to build the PyPI release. Files in the `/tmp` folder will be automatically deleted by the OS.
```bash
mkdir -p /tmp/superset && cd /tmp/superset

View File

@@ -118,7 +118,6 @@ 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

@@ -112,12 +112,61 @@ 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 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
- `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.
- `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

@@ -1,89 +0,0 @@
---
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.1",
"antd": "^6.5.2",
"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.108.2"
"webpack": "^5.109.0"
},
"browserslist": {
"production": [

View File

@@ -69,12 +69,6 @@
"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.

Before

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

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.0", "@rc-component/trigger@^3.6.15", "@rc-component/trigger@^3.7.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":
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.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==
"@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==
dependencies:
is-mobile "^5.0.0"
react-is "^18.2.0"
react-is "^19.2.7"
"@rc-component/virtual-list@^1.0.1", "@rc-component/virtual-list@^1.2.0":
version "1.2.0"
@@ -5835,11 +5835,6 @@ 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"
@@ -6028,10 +6023,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
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==
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==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6073,9 +6068,9 @@ antd@^6.5.1:
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.3.2"
"@rc-component/tree-select" "~1.11.0"
"@rc-component/trigger" "^3.10.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.11.1"
"@rc-component/util" "^1.12.0"
clsx "^2.1.1"
dayjs "^1.11.11"
scroll-into-view-if-needed "^3.1.0"
@@ -8066,10 +8061,10 @@ encodeurl@~2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
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==
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==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.3.3"
@@ -10431,11 +10426,6 @@ 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"
@@ -13327,11 +13317,16 @@ 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.2.0:
react-is@^18.0.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"
@@ -15855,20 +15850,20 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
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-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-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.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==
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==
dependencies:
"@types/estree" "^1.0.8"
"@types/json-schema" "^7.0.15"
@@ -15876,22 +15871,20 @@ webpack@^5.108.2, 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.22.2"
enhanced-resolve "^5.24.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.0"
webpack-sources "^3.5.1"
webpackbar@^7.0.0:
version "7.0.0"

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 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.
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.
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.3.6",
"fs-extra": "^11.4.0",
"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.0",
"google-auth-library": "^10.9.1",
"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.26.0",
"mapbox-gl": "^3.27.0",
"markdown-to-jsx": "^9.9.0",
"match-sorter": "^8.3.0",
"memoize-one": "^6.0.0",
@@ -367,7 +367,7 @@
"typescript": "5.4.5",
"unzipper": "^0.12.5",
"wait-on": "^9.1.0",
"webpack": "^5.108.4",
"webpack": "^5.109.0",
"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.3.6",
"fs-extra": "^11.4.0",
"jest": "^30.4.2",
"yeoman-test": "^11.6.0"
},

View File

@@ -49,7 +49,6 @@ const titleStyles = (theme: SupersetTheme) => css`
text-overflow: ellipsis;
white-space: nowrap;
padding: 0;
font-weight: inherit;
color: ${theme.colorText};
background-color: ${theme.colorBgContainer};
@@ -128,21 +127,6 @@ 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,7 +20,6 @@ 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';
@@ -83,20 +82,6 @@ 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`
@@ -124,7 +109,6 @@ export type PageHeaderWithActionsProps = {
showFaveStar: boolean;
showMenuDropdown?: boolean;
faveStarProps: FaveStarProps;
leftPanelItems?: ReactNode;
titlePanelAdditionalItems: ReactNode;
rightPanelAdditionalItems: ReactNode;
additionalActionsMenu: ReactElement;
@@ -142,7 +126,6 @@ export const PageHeaderWithActions = memo(
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
leftPanelItems,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
@@ -153,7 +136,6 @@ 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,7 +57,6 @@ 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,7 +96,6 @@ 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: {
@@ -157,23 +156,6 @@ 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

@@ -1,173 +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.
*/
/**
* 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

@@ -1,288 +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 { 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="filter-icon"]')
.or(
page
.locator('[aria-label="Filters"]')
.or(page.locator('.mobile-filter-button')),
);
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

@@ -1,192 +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 { 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

@@ -27,7 +27,7 @@
],
"dependencies": {
"@math.gl/web-mercator": "^4.1.0",
"mapbox-gl": "^3.26.0",
"mapbox-gl": "^3.27.0",
"maplibre-gl": "^5.24.0",
"react-map-gl": "^8.1.1",
"supercluster": "^8.0.1"

View File

@@ -1,169 +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.
*/
/**
* 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

@@ -62,6 +62,7 @@ export function ErrorMessageWithStackTrace({
fallback,
compact,
closable = true,
errorMitigationFunction,
}: Props) {
// Check if a custom error message component was registered for this message
if (error) {
@@ -77,6 +78,7 @@ 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 { render, waitFor } from 'spec/helpers/testing-library';
import { act, 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,15 +166,55 @@ 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 () => {
render(setup());
simulateStorageMessage({ tabId: 'tabId' });
await waitFor(() => {
expect(reRunQuery).toHaveBeenCalledWith({ sql: 'SELECT * FROM table' });
});
});
test('dispatches reRunQuery action when storage event has matching tab ID', async () => {
render(setup());
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(() => {
@@ -234,4 +274,22 @@ 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 } from 'react';
import { useEffect, useRef } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -58,15 +58,16 @@ 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
* 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.
* 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.
*/
export function OAuth2RedirectMessage({
error,
source,
closable,
errorMitigationFunction,
}: ErrorMessageComponentProps<OAuth2RedirectExtra>) {
const { extra, level } = error;
@@ -103,13 +104,17 @@ export function OAuth2RedirectMessage({
);
const dispatch = useDispatch();
const lastHandledTabIdRef = useRef<string>();
useEffect(() => {
const handleOAuthComplete = (tabId?: string) => {
if (tabId !== extra.tab_id) {
if (tabId !== extra.tab_id || tabId === lastHandledTabIdRef.current) {
return;
}
if (source === 'sqllab' && query) {
if (errorMitigationFunction) {
errorMitigationFunction();
} else if (source === 'sqllab' && query) {
dispatch(reRunQuery(query));
} else if (source === 'explore') {
dispatch(triggerQuery(true, chartId));
@@ -123,7 +128,11 @@ export function OAuth2RedirectMessage({
'Tables',
]),
);
} else {
return;
}
lastHandledTabIdRef.current = tabId;
};
const channel =
@@ -156,7 +165,16 @@ export function OAuth2RedirectMessage({
window.removeEventListener('storage', handleStorage);
channel?.close();
};
}, [source, extra.tab_id, dispatch, query, chartId, chartList, dashboardId]);
}, [
source,
extra.tab_id,
dispatch,
query,
chartId,
chartList,
dashboardId,
errorMitigationFunction,
]);
const body = (
<p>

View File

@@ -27,6 +27,7 @@ export type ErrorMessageComponentProps<ExtraType = Record<string, any> | null> =
subtitle?: ReactNode;
compact?: boolean;
closable?: boolean;
errorMitigationFunction?: () => void;
};
export type ErrorMessageComponent = ComponentType<ErrorMessageComponentProps>;

View File

@@ -19,7 +19,6 @@
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 {
@@ -43,18 +42,6 @@ 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,227 +380,3 @@ 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,7 +33,6 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Button,
Tooltip,
Drawer,
Icons,
EmptyState,
Loading,
@@ -253,30 +252,6 @@ 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,
@@ -339,7 +314,6 @@ 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;
@@ -355,12 +329,6 @@ 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>({
@@ -382,7 +350,6 @@ export function ListView<T extends object = any>({
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
forceViewMode,
highlightRowId,
emptyState,
columnsForWrapText,
@@ -393,9 +360,6 @@ export function ListView<T extends object = any>({
headerContent,
addSuccessToast,
addDangerToast,
mobileFiltersOpen = false,
setMobileFiltersOpen,
mobileFiltersDrawerTitle,
}: ListViewProps<T>) {
const {
getTableProps,
@@ -422,7 +386,6 @@ export function ListView<T extends object = any>({
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
forceViewMode,
});
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
const filterable = Boolean(filters.length);
@@ -499,15 +462,11 @@ export function ListView<T extends object = any>({
)}
<div data-test={className} className={`superset-list-view ${className} `}>
<div className="header">
{cardViewEnabled && !forceViewMode && (
{cardViewEnabled && (
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
)}
<div className="controls" data-test="filters-select">
{/* 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 && (
{filterable && (
<FilterControls
ref={filterControlsRef}
filters={filters}
@@ -515,16 +474,14 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' &&
cardSortSelectOptions &&
!setMobileFiltersOpen && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && !setMobileFiltersOpen && (
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
>
@@ -703,46 +660,6 @@ 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,7 +195,6 @@ interface UseListViewConfig {
initialFilters?: Filter[];
renderCard?: boolean;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
}
export function useListViewState({
@@ -208,7 +207,6 @@ export function useListViewState({
initialSort = [],
renderCard = false,
defaultViewMode = 'card',
forceViewMode,
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: RisonParam,
@@ -236,31 +234,10 @@ export function useListViewState({
};
const [viewMode, setViewMode] = useState<ViewModeType>(
// forceViewMode overrides everything (used for mobile)
forceViewMode ||
(query.viewMode as ViewModeType) ||
(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

@@ -1,64 +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 { 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

@@ -1,52 +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 { 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,10 +16,6 @@
* 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,
@@ -54,9 +50,6 @@ 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(),
@@ -429,7 +422,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -456,7 +448,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: false,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -483,7 +474,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { getByTestId } = setup();
@@ -543,7 +533,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { queryByTestId } = setup();
@@ -557,7 +546,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup();
@@ -571,7 +559,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
@@ -589,7 +576,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
try {
const { getByTestId } = setup();
@@ -615,7 +601,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
try {
const { getByTestId } = setup();
@@ -637,7 +622,6 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
try {
const { getByTestId } = setup();
@@ -802,62 +786,3 @@ 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 { Drawer, EmptyState, Loading } from '@superset-ui/core/components';
import { 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,7 +59,6 @@ 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,
@@ -102,19 +101,6 @@ 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;
}
@@ -386,8 +372,6 @@ 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}`,
@@ -486,14 +470,13 @@ const DashboardBuilder = () => {
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
hasFilters,
} = useNativeFilters();
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
ELEMENT_ON_SCREEN_OPTIONS,
);
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
const showFilterBar = !editMode && nativeFiltersEnabled;
const offset =
FILTER_BAR_HEADER_HEIGHT +
@@ -505,7 +488,6 @@ const DashboardBuilder = () => {
const draggableStyle = useMemo(
() => ({
marginLeft:
!isNotMobile ||
dashboardFiltersOpen ||
editMode ||
!nativeFiltersEnabled ||
@@ -514,7 +496,6 @@ const DashboardBuilder = () => {
: -32,
}),
[
isNotMobile,
dashboardFiltersOpen,
editMode,
filterBarOrientation,
@@ -546,15 +527,7 @@ const DashboardBuilder = () => {
const headerContent = useMemo(
() => (
<>
{!hideDashboardHeader && (
<DashboardHeader
onOpenMobileFilters={
!isNotMobile && nativeFiltersEnabled && hasFilters
? () => setMobileFiltersOpen(true)
: undefined
}
/>
)}
{!hideDashboardHeader && <DashboardHeader />}
{/* 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. */}
@@ -568,16 +541,7 @@ const DashboardBuilder = () => {
)}
</>
),
[
hideDashboardHeader,
isNotMobile,
nativeFiltersEnabled,
hasFilters,
showFilterBar,
filterBarOrientation,
hideFilterBar,
isReport,
],
[hideDashboardHeader, showFilterBar, filterBarOrientation, hideFilterBar],
);
const renderDraggableContent = useCallback(
@@ -621,8 +585,6 @@ const DashboardBuilder = () => {
topLevelTabs,
uiConfig.hideTab,
uiConfig.hideNav,
isNotMobile,
theme,
],
);
@@ -785,36 +747,6 @@ 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,7 +25,6 @@ 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`
@@ -111,22 +110,6 @@ 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,6 +121,5 @@ export const useNativeFilters = () => {
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
hasFilters: filterValues.length > 0,
};
};

View File

@@ -0,0 +1,38 @@
/**
* 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,6 +18,7 @@
*/
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';
@@ -33,6 +34,8 @@ const DeleteComponentButton: FC<DeleteComponentButtonProps> = ({
}) => (
<IconButton
onClick={onDelete}
label={t('Delete component')}
hideVisibleLabel
icon={<Icons.DeleteOutlined iconSize={iconSize ?? 'l'} />}
/>
);

View File

@@ -16,10 +16,6 @@
* 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';
@@ -190,9 +186,6 @@ 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,12 +23,7 @@ import {
FeatureFlag,
getExtensionsRegistry,
} from '@superset-ui/core';
import {
styled,
css,
SupersetTheme,
useTheme,
} from '@apache-superset/core/theme';
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { Global } from '@emotion/react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -42,7 +37,6 @@ 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';
@@ -225,14 +219,8 @@ const discardChanges = () => {
window.location.assign(url);
};
interface HeaderComponentProps {
onOpenMobileFilters?: () => void;
}
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
const Header = (): JSX.Element => {
const dispatch = useDispatch();
const theme = useTheme();
const isMobile = useIsMobile();
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
useState(false);
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
@@ -643,8 +631,7 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
const titlePanelAdditionalItems = useMemo(
() => [
// The kebab menu's "Refresh dashboard" item covers this on mobile
!editMode && !isMobile && (
!editMode && (
<RefreshButton key="refresh-button" onRefresh={forceRefresh} />
),
!editMode && (
@@ -653,7 +640,7 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
onTogglePause={handlePauseToggle}
/>
),
!editMode && !isMobile && (
!editMode && (
<PublishedStatus
key="published-status"
dashboardId={dashboardInfo.id}
@@ -663,13 +650,12 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
userCanSave={userCanSaveAs}
/>
),
!editMode && !isEmbedded && !isMobile && metadataBar,
!editMode && !isEmbedded && metadataBar,
],
[
boundActionCreators.savePublished,
dashboardInfo.id,
editMode,
isMobile,
metadataBar,
isEmbedded,
isPublished,
@@ -766,7 +752,7 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && !isEmbedded && !isMobile && (
{userCanEdit && !isEmbedded && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -794,7 +780,6 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
isMobile,
overwriteDashboard,
redoLength,
undoLength,
@@ -831,10 +816,6 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar: boundActionCreators.saveFaveStar,
showReportModal,
showPropertiesModal,
showRefreshModal,
@@ -854,21 +835,6 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): 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={{
@@ -876,7 +842,7 @@ const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
showTitlePanelItems
/>
{showingPropertiesModal && (

View File

@@ -43,10 +43,6 @@ 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,7 +37,6 @@ 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,
@@ -57,10 +56,6 @@ export const useHeaderActionsMenu = ({
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar,
lastModifiedTime,
addSuccessToast,
addDangerToast,
@@ -122,11 +117,6 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ManageEmbedded:
manageEmbedded();
break;
case 'toggle-favorite':
if (saveFaveStar && isStarred !== undefined) {
saveFaveStar(dashboardId, isStarred);
}
break;
default:
break;
}
@@ -138,9 +128,6 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
saveFaveStar,
dashboardId,
isStarred,
history,
location,
],
@@ -218,52 +205,6 @@ 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({
@@ -283,8 +224,8 @@ export const useHeaderActionsMenu = ({
});
}
// Toggle fullscreen (hide on mobile)
if (!editMode && !isEmbedded && !isMobile) {
// Toggle fullscreen
if (!editMode && !isEmbedded) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
@@ -352,15 +293,15 @@ export const useHeaderActionsMenu = ({
// Only add divider if there are items after it
const hasItemsAfterDivider =
(!editMode && reportMenuItem && !isMobile) ||
(!editMode && reportMenuItem) ||
(editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes));
if (hasItemsAfterDivider) {
menuItems.push({ type: 'divider' });
}
// Report dropdown (hide on mobile)
if (!editMode && reportMenuItem && !isMobile) {
// Report dropdown
if (!editMode && reportMenuItem) {
menuItems.push(reportMenuItem);
}
@@ -398,15 +339,11 @@ export const useHeaderActionsMenu = ({
expandedSlices,
handleMenuClick,
isLoading,
isMobile,
isPublished,
isStarred,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
reportMenuItem,
saveFaveStar,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,

View File

@@ -73,3 +73,17 @@ 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,6 +22,7 @@ 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;
@@ -63,6 +64,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{
icon,
label,
hideVisibleLabel,
onClick,
onKeyDown,
disabled,
@@ -75,6 +77,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
{...rest}
ref={ref}
type="button"
aria-label={label}
isDisabled={disabled}
aria-disabled={disabled}
data-test={dataTest}
@@ -91,7 +94,7 @@ const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
}}
>
{icon}
{label && <StyledSpan>{label}</StyledSpan>}
{label && !hideVisibleLabel && <StyledSpan>{label}</StyledSpan>}
</StyledButton>
),
);

View File

@@ -43,7 +43,6 @@ 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';
@@ -230,9 +229,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
0,
);
// Consumption-only mobile mode: no explore link, no chart controls
const isMobile = useIsMobile();
const canExplore = !editMode && supersetCanExplore && !isMobile;
const canExplore = !editMode && supersetCanExplore;
const showRowLimitWarning =
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
@@ -358,7 +355,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
}
/>
)}
{!uiConfig.hideChartControls && !isMobile && (
{!uiConfig.hideChartControls && (
<SliceHeaderControls
slice={slice}
isCached={isCached}

View File

@@ -36,19 +36,13 @@ 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;
@@ -102,7 +96,6 @@ const ChartHolder = ({
isInView,
}: ChartHolderProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const fullSizeStyle = css`
&& {
position: fixed !important;
@@ -174,14 +167,6 @@ 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;
@@ -197,29 +182,11 @@ 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;
@@ -233,14 +200,16 @@ const ChartHolder = ({
(widthMultiple - 1) * GRID_GUTTER_SIZE -
CHART_MARGIN,
);
height = Math.floor(heightMultiple * GRID_BASE_UNIT - CHART_MARGIN);
height = Math.floor(
(component.meta.height ?? 0) * GRID_BASE_UNIT - CHART_MARGIN,
);
}
return {
chartWidth: width,
chartHeight: height,
};
}, [columnWidth, heightMultiple, isFullSize, widthMultiple]);
}, [columnWidth, component, isFullSize, widthMultiple]);
const handleDeleteComponent = useCallback(() => {
deleteComponent(id, parentId);
@@ -281,7 +250,7 @@ const ChartHolder = ({
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={heightMultiple}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
@@ -373,12 +342,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 } from 'spec/helpers/testing-library';
import { fireEvent, render, screen } from 'spec/helpers/testing-library';
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
import IconButton from 'src/dashboard/components/IconButton';
@@ -200,6 +200,15 @@ 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,6 +247,8 @@ const Column = (props: ColumnProps) => {
/>
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Column settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="m" />}
/>
</HoverMenu>

View File

@@ -145,7 +145,9 @@ describe('Header', () => {
const deleteComponent = jest.fn();
setup({ editMode: true, deleteComponent });
const trashButton = screen.getByRole('button', { name: 'delete' });
const trashButton = screen.getByRole('button', {
name: 'Delete component',
});
fireEvent.click(trashButton);
expect(deleteComponent).toHaveBeenCalledTimes(1);

View File

@@ -240,6 +240,15 @@ 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,7 +37,6 @@ 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';
@@ -121,27 +120,6 @@ 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;
}
}
`
}
`}
`;
@@ -315,6 +293,8 @@ const Row = memo((props: RowProps) => {
<DeleteComponentButton onDelete={handleDeleteComponent} />
<IconButton
onClick={() => handleChangeFocus(true)}
label={t('Row settings')}
hideVisibleLabel
icon={<Icons.SettingOutlined iconSize="l" />}
/>
</HoverMenu>

View File

@@ -25,8 +25,7 @@ import {
useRef,
useState,
} from 'react';
import { css, styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import { styled } from '@apache-superset/core/theme';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
@@ -88,58 +87,6 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
display: none !important;
}
`}
/* Sticky tabs on mobile (consumption mode) */
${({ theme }) =>
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
.ant-tabs-nav {
position: sticky;
top: 0;
z-index: 100;
background-color: ${theme.colorBgContainer};
/* breathing room between the tab bar and the first card; padding
(not margin) so the gap is part of the opaque sticky bar */
padding-bottom: ${theme.sizeUnit * 2}px;
}
/* Scrollability affordance: fade the clipped edge with a
theme-colored gradient. antd toggles the ping classes when tabs
overflow on that side; restyle its shadow elements as gradients,
which read much better than the default shadows on dark themes. */
.ant-tabs-nav-wrap:before,
.ant-tabs-nav-wrap:after {
width: ${theme.sizeUnit * 10}px;
box-shadow: none !important;
pointer-events: none;
}
.ant-tabs-nav-wrap-ping-right:after {
background: linear-gradient(
to right,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
.ant-tabs-nav-wrap-ping-left:before {
background: linear-gradient(
to left,
transparent,
${theme.colorBgContainer}
);
opacity: 1;
}
/* Swipeable tab bar instead of the overflow dropdown: the "more"
menu is a poor touch target and duplicates half-clipped tabs.
antd's tab nav supports touch-drag scrolling natively and shows
edge shadows (ping classes) when tabs overflow. */
.ant-tabs-nav-operations {
display: none !important;
}
}
`}
`;
export interface TabItem {

View File

@@ -32,7 +32,7 @@ import {
import { useSelector } from 'react-redux';
import cx from 'classnames';
import { t } from '@apache-superset/core/translation';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { styled, useTheme } from '@apache-superset/core/theme';
import { RootState } from 'src/dashboard/types';
import { DataMaskStateWithId } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
@@ -145,7 +145,6 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
onPendingCustomizationDataMaskChange,
toggleFiltersBar,
width,
mobileMode,
}) => {
const theme = useTheme();
const [isScrolling, setIsScrolling] = useState(false);
@@ -265,55 +264,32 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
{...getFilterBarTestId()}
className={cx({ open: filtersOpen })}
width={width}
css={
mobileMode &&
css`
width: 100%;
&.open {
width: 100%;
}
`
}
>
{!mobileMode && (
<CollapsedBar
type="button"
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
offset={offset}
>
<Icons.VerticalAlignTopOutlined
iconSize="l"
css={{
transform: 'rotate(90deg)',
marginBottom: `${theme.sizeUnit * 3}px`,
}}
className="collapse-icon"
iconColor={theme.colorPrimary}
{...getFilterBarTestId('expand-button')}
/>
<Icons.FilterOutlined
{...getFilterBarTestId('filter-icon')}
iconColor={theme.colorTextTertiary}
iconSize="l"
/>
</CollapsedBar>
)}
<Bar
className={cx({ open: filtersOpen })}
width={width}
css={
mobileMode &&
css`
position: relative;
width: 100%;
border-right: none;
border-bottom: none;
`
}
<CollapsedBar
type="button"
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
offset={offset}
>
{!mobileMode && <Header toggleFiltersBar={toggleFiltersBar} />}
<Icons.VerticalAlignTopOutlined
iconSize="l"
css={{
transform: 'rotate(90deg)',
marginBottom: `${theme.sizeUnit * 3}px`,
}}
className="collapse-icon"
iconColor={theme.colorPrimary}
{...getFilterBarTestId('expand-button')}
/>
<Icons.FilterOutlined
{...getFilterBarTestId('filter-icon')}
iconColor={theme.colorTextTertiary}
iconSize="l"
/>
</CollapsedBar>
<Bar className={cx({ open: filtersOpen })} width={width}>
<Header toggleFiltersBar={toggleFiltersBar} />
{!isInitialized ? (
<div
css={{

View File

@@ -679,7 +679,6 @@ const FilterBar: FC<FiltersBarProps> = ({
}
toggleFiltersBar={verticalConfig.toggleFiltersBar}
width={verticalConfig.width}
mobileMode={verticalConfig.mobileMode}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={handleClearAllComplete}
/>

View File

@@ -53,11 +53,6 @@ interface VerticalBarConfig {
offset: number;
toggleFiltersBar: any;
width: number;
/**
* Renders the bar for a mobile drawer: full-width, in normal document
* flow, without the header or the collapsed-bar affordance.
*/
mobileMode?: boolean;
}
export interface FiltersBarProps {

View File

@@ -35,7 +35,6 @@ import {
BottomResizeHandle,
BottomRightResizeHandle,
} from './ResizableHandle';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import resizableConfig from '../../util/resizableConfig';
import {
GRID_BASE_UNIT,
@@ -179,21 +178,6 @@ const StyledResizable = styled(Resizable)`
& .resizable-container-handle--bottom {
bottom: 0 !important;
}
/* Mobile consumption mode stacks all grid components full-width.
!important is required to beat re-resizable's inline width, which is
sized for the desktop grid (Markdown, Column, and other non-chart
components don't get the JS-level mobile width that ChartHolder
computes). */
${({ theme }) =>
isMobileConsumptionEnabled() &&
css`
@media (max-width: ${theme.screenSMMax}px) {
width: 100% !important;
max-width: 100% !important;
min-width: 100% !important;
}
`}
`;
export default function ResizableContainer({

View File

@@ -49,6 +49,7 @@ export function CurrentCalendarFrame({ onChange, value }: FrameComponentProps) {
wrap: true,
}}
size="large"
value={value}
onChange={(e: any) => {
let newValue = e.target.value;
newValue = newValue.trim();

View File

@@ -16,9 +16,9 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import { CurrentCalendarFrame } from '../components/CurrentCalendarFrame';
import { CurrentWeek } from '../types';
import { CurrentDay, CurrentWeek } from '../types';
const mockOnChange = jest.fn();
@@ -33,3 +33,9 @@ test('returns null if value is not a valid CurrentRangeType', () => {
);
expect(container.childNodes.length).toBe(0);
});
test('selects the radio button matching the current value', () => {
render(<CurrentCalendarFrame onChange={mockOnChange} value={CurrentDay} />);
expect(screen.getByRole('radio', { name: CurrentDay })).toBeChecked();
});

View File

@@ -35,7 +35,6 @@ import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { KebabMenuButton } from 'src/components';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
const menuItemButtonCss = css`
appearance: none;
@@ -77,7 +76,6 @@ function DashboardCard({
onDelete,
}: DashboardCardProps) {
const userId = user?.userId;
const isMobile = useIsMobile();
const history = useHistory();
const canEdit = hasPerm('can_write');
@@ -208,12 +206,10 @@ function DashboardCard({
isStarred={favoriteStatus}
/>
)}
{!isMobile && (
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
)}
<KebabMenuButton
menuItems={menuItems}
dataTest="dashboard-card-menu"
/>
</ListViewCard.Actions>
}
/>

View File

@@ -35,6 +35,5 @@ export const Basic: StoryFn<typeof DatasetPanel> = args => (
Basic.args = {
tableName: 'example_table',
loading: false,
hasError: false,
columnList: exampleColumns,
};

View File

@@ -77,7 +77,6 @@ test('View Dataset opens a single-prefixed URL under a subdirectory deployment',
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith(`${APP_ROOT}/explore/?datasource=1__table`)}
@@ -97,7 +96,6 @@ test('View Dataset passes an external explore_url through unprefixed', async ()
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={datasetWith('https://external.example.com/custom-endpoint')}

View File

@@ -17,10 +17,12 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { ErrorTypeEnum } from '@superset-ui/core';
import DatasetPanel, {
REFRESHING,
tableColumnDefinition,
COLUMN_TITLE,
ERROR_TITLE,
} from 'src/features/datasets/AddDataset/DatasetPanel/DatasetPanel';
import { exampleColumns, exampleDataset } from './fixtures';
import { ITableColumn } from './types';
@@ -31,8 +33,6 @@ import {
SELECT_TABLE_TITLE,
NO_COLUMNS_TITLE,
NO_COLUMNS_DESCRIPTION,
ERROR_TITLE,
ERROR_DESCRIPTION,
} from './MessageContent';
jest.mock(
@@ -47,7 +47,7 @@ jest.mock(
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DatasetPanel', () => {
test('renders a blank state DatasetPanel', () => {
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />, {
render(<DatasetPanel columnList={[]} loading={false} />, {
useRouter: true,
});
@@ -70,17 +70,9 @@ describe('DatasetPanel', () => {
});
test('renders a no columns screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading={false}
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading={false} />, {
useRouter: true,
});
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible();
@@ -91,17 +83,9 @@ describe('DatasetPanel', () => {
});
test('renders a loading screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading
/>,
{
useRouter: true,
},
);
render(<DatasetPanel tableName="Name" columnList={[]} loading />, {
useRouter: true,
});
const loadingIndicator = screen.getByTestId('loading-indicator');
expect(loadingIndicator).toBeVisible();
@@ -113,7 +97,12 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="Name"
hasError
error={{
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message: 'Structured backend failure',
}}
columnList={[]}
loading={false}
/>,
@@ -124,8 +113,9 @@ describe('DatasetPanel', () => {
const errorTitle = screen.getByText(ERROR_TITLE);
expect(errorTitle).toBeVisible();
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
const errorDescription = screen.getByText('Structured backend failure');
expect(errorDescription).toBeVisible();
expect(screen.getByTitle('Name')).toHaveStyle({ position: 'relative' });
});
test('renders a table with columns displayed', async () => {
@@ -133,7 +123,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName={tableName}
hasError={false}
columnList={exampleColumns}
loading={false}
/>,
@@ -159,7 +148,6 @@ describe('DatasetPanel', () => {
render(
<DatasetPanel
tableName="example_table"
hasError={false}
columnList={exampleColumns}
loading={false}
datasets={exampleDataset}

View File

@@ -21,11 +21,13 @@ import { Alert } from '@apache-superset/core/components';
import { css, styled } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Loading } from '@superset-ui/core/components';
import type { SupersetError } from '@superset-ui/core';
import Table, {
ColumnsType,
TableSize,
} from '@superset-ui/core/components/Table';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { ErrorMessageWithStackTrace } from 'src/components';
import { openInNewTab, stripAppRoot } from 'src/utils/navigationUtils';
import { ITableColumn } from './types';
import MessageContent from './MessageContent';
@@ -146,6 +148,10 @@ const TableScrollContainer = styled.div`
right: 0;
`;
const ErrorContainer = styled.div`
padding: 0 ${({ theme }) => theme.sizeUnit * 6}px;
`;
const StyledAlert = styled(Alert)`
${({ theme }) => `
border: 1px solid ${theme.colorInfoText};
@@ -167,6 +173,7 @@ const StyledAlert = styled(Alert)`
export const REFRESHING = t('Refreshing columns');
export const COLUMN_TITLE = t('Table columns');
export const ERROR_TITLE = t('An Error Occurred');
const pageSizeOptions = ['5', '10', '15', '25'];
const DEFAULT_PAGE_SIZE = 25;
@@ -201,9 +208,13 @@ export interface IDatasetPanelProps {
*/
columnList: ITableColumn[];
/**
* Boolean indicating if there is an error state
* Error returned while loading the table metadata
*/
hasError: boolean;
error?: SupersetError;
/**
* Function used to retry loading the table metadata after error mitigation
*/
errorMitigationFunction?: () => void;
/**
* Boolean indicating if the component is in a loading state
*/
@@ -256,11 +267,11 @@ const DatasetPanel = ({
tableName,
columnList,
loading,
hasError,
error,
errorMitigationFunction,
datasets,
}: IDatasetPanelProps) => {
const hasColumns = Boolean(columnList?.length > 0);
const datasetNames = datasets?.map(dataset => dataset.table_name);
const hasColumns = columnList.length > 0;
const tableWithDataset = datasets?.find(
dataset => dataset.table_name === tableName,
);
@@ -278,7 +289,19 @@ const DatasetPanel = ({
);
}
if (!loading) {
if (!loading && tableName && hasColumns && !hasError) {
if (error) {
component = (
<ErrorContainer>
<ErrorMessageWithStackTrace
error={error}
errorMitigationFunction={errorMitigationFunction}
source="crud"
subtitle={error.message}
title={ERROR_TITLE}
/>
</ErrorContainer>
);
} else if (tableName && hasColumns) {
component = (
<>
<StyledTitle title={COLUMN_TITLE}>{COLUMN_TITLE}</StyledTitle>
@@ -312,13 +335,7 @@ const DatasetPanel = ({
</>
);
} else {
component = (
<MessageContent
hasColumns={hasColumns}
hasError={hasError}
tableName={tableName}
/>
);
component = <MessageContent tableName={tableName} />;
}
}
@@ -326,11 +343,12 @@ const DatasetPanel = ({
<>
{tableName && (
<>
{datasetNames?.includes(tableName) &&
renderExistingDatasetAlert(tableWithDataset)}
{tableWithDataset && renderExistingDatasetAlert(tableWithDataset)}
<StyledHeader
position={
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
!loading && (hasColumns || error)
? EPosition.RELATIVE
: EPosition.ABSOLUTE
}
title={tableName || ''}
>

View File

@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import { act, render, screen, waitFor } from 'spec/helpers/testing-library';
import { ErrorTypeEnum, SupersetClient } from '@superset-ui/core';
import type { SupersetClientResponse } from '@superset-ui/core';
import {
DatabaseErrorMessage,
getErrorMessageComponentRegistry,
OAuth2RedirectMessage,
} from 'src/components/ErrorMessage';
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
@@ -29,17 +35,29 @@ jest.mock(
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
afterEach(() => {
errorMessageRegistry.remove(ErrorTypeEnum.GENERIC_BACKEND_ERROR);
errorMessageRegistry.remove(ErrorTypeEnum.OAUTH2_REDIRECT);
jest.restoreAllMocks();
});
const tableMetadataResponse = (
name: string,
columnName: string,
): SupersetClientResponse => ({
response: new Response(),
json: {
name,
columns: [{ name: columnName, type: 'INTEGER', longType: 'INTEGER' }],
},
});
test('fetches table metadata for schema-less database without schema', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
name: 'my_table',
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
},
} as any);
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockResolvedValue(tableMetadataResponse('my_table', 'id'));
render(
<DatasetPanelWrapper
@@ -58,3 +76,99 @@ test('fetches table metadata for schema-less database without schema', async ()
);
});
});
test('renders a fallback message for an unstructured metadata error', async () => {
jest.spyOn(SupersetClient, 'get').mockRejectedValue({
response: new Response('{}', {
status: 500,
headers: { 'Content-Type': 'application/json' },
}),
});
errorMessageRegistry.registerValue(
ErrorTypeEnum.GENERIC_BACKEND_ERROR,
DatabaseErrorMessage,
);
render(
<DatasetPanelWrapper
tableName="broken_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{ useRouter: true },
);
expect(
await screen.findByText('Unable to load columns for the selected table.'),
).toBeVisible();
});
test('retries only table metadata after matching OAuth completion', async () => {
const oauthError = {
error_type: ErrorTypeEnum.OAUTH2_REDIRECT,
message: 'OAuth authorization is required.',
extra: {
url: 'https://example.com/authorize',
tab_id: 'dataset-oauth-tab',
},
level: 'warning',
};
const getSpy = jest
.spyOn(SupersetClient, 'get')
.mockRejectedValueOnce({
response: new Response(JSON.stringify({ errors: [oauthError] }), {
status: 403,
headers: { 'Content-Type': 'application/json' },
}),
})
.mockResolvedValueOnce(tableMetadataResponse('oauth_table', 'oauth_id'));
errorMessageRegistry.registerValue(
ErrorTypeEnum.OAUTH2_REDIRECT,
OAuth2RedirectMessage,
);
render(
<DatasetPanelWrapper
tableName="oauth_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{
initialState: {
charts: {},
dashboardInfo: {},
explore: {},
sqlLab: {
queries: {},
queryEditors: [],
tabHistory: [],
},
},
useRedux: true,
useRouter: true,
},
);
const authorizationLink = await screen.findByRole('link', {
name: /provide authorization/i,
});
expect(authorizationLink).toHaveAttribute(
'href',
'https://example.com/authorize',
);
expect(getSpy).toHaveBeenCalledTimes(1);
act(() => {
window.dispatchEvent(
new StorageEvent('storage', {
key: 'oauth2_auth_complete',
newValue: JSON.stringify({ tabId: 'dataset-oauth-tab' }),
}),
);
});
expect(await screen.findByText('oauth_id')).toBeVisible();
expect(getSpy).toHaveBeenCalledTimes(2);
expect(getSpy.mock.calls[1]).toEqual(getSpy.mock.calls[0]);
});

View File

@@ -65,27 +65,17 @@ export const NO_COLUMNS_TITLE = t('No table columns');
export const NO_COLUMNS_DESCRIPTION = t(
'This database table does not contain any data. Please select a different table.',
);
export const ERROR_TITLE = t('An Error Occurred');
export const ERROR_DESCRIPTION = t(
'Unable to load columns for the selected table. Please select a different table.',
);
interface MessageContentProps {
hasError: boolean;
tableName?: string | null;
hasColumns: boolean;
}
export const MessageContent = (props: MessageContentProps) => {
const { hasError, tableName, hasColumns } = props;
let currentImage: string | undefined = 'empty-dataset.svg';
const { tableName } = props;
let currentImage = 'empty-dataset.svg';
let currentTitle = SELECT_TABLE_TITLE;
let currentDescription = renderEmptyDescription();
if (hasError) {
currentTitle = ERROR_TITLE;
currentDescription = <>{ERROR_DESCRIPTION}</>;
currentImage = undefined;
} else if (tableName && !hasColumns) {
if (tableName) {
currentImage = 'no-columns.svg';
currentTitle = NO_COLUMNS_TITLE;
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;

View File

@@ -16,9 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState, useRef } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { SupersetClient } from '@superset-ui/core';
import {
ErrorTypeEnum,
getClientErrorObject,
SupersetClient,
} from '@superset-ui/core';
import type { SupersetError } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { addDangerToast } from 'src/components/MessageToasts/actions';
@@ -30,7 +35,7 @@ import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
/**
* Interface for the getTableMetadata API call
*/
interface IColumnProps {
interface TableMetadataRequest {
/**
* Unique id of the database
*/
@@ -43,6 +48,10 @@ interface IColumnProps {
* Name of the schema (optional for databases that don't support schemas)
*/
schema?: string | null;
/**
* Name of the catalog (optional for databases that don't support catalogs)
*/
catalog?: string | null;
}
export interface IDatasetPanelWrapperProps {
@@ -63,7 +72,7 @@ export interface IDatasetPanelWrapperProps {
* The selected database object (used to check engine capabilities)
*/
database?: Partial<DatabaseObject> | null;
setHasColumns?: Function;
setHasColumns?: (hasColumns: boolean) => void;
datasets?: DatasetObject[] | undefined;
}
@@ -78,74 +87,131 @@ const DatasetPanelWrapper = ({
}: IDatasetPanelWrapperProps) => {
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const tableNameRef = useRef(tableName);
const [error, setError] = useState<SupersetError>();
const requestIdRef = useRef(0);
const currentRequestRef = useRef<TableMetadataRequest>();
const supportsSchemas = database?.supports_schemas;
const getTableMetadata = async (props: IColumnProps) => {
const { dbId, tableName, schema } = props;
setLoading(true);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
const getTableMetadata = useCallback(
async (props: TableMetadataRequest) => {
const { dbId, tableName, catalog, schema } = props;
requestIdRef.current += 1;
const requestId = requestIdRef.current;
setLoading(true);
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table_metadata/${toQueryString({
name: tableName,
catalog,
schema,
})}`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
if (isIDatabaseTable(response?.json)) {
const table: IDatabaseTable = response.json as IDatabaseTable;
/**
* The user is able to click other table columns while the http call for last selected table column is made
* This check ensures we process the response that matches the last selected table name and ignore the others
*/
if (table.name === tableNameRef.current) {
if (requestId !== requestIdRef.current) {
return;
}
const table = isIDatabaseTable(response?.json)
? (response.json as IDatabaseTable)
: undefined;
if (table?.name === tableName) {
setColumnList(table.columns);
setHasColumns?.(table.columns.length > 0);
setHasError(false);
setError(undefined);
} else {
const message = t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
);
setColumnList([]);
setHasColumns?.(false);
setError({
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error',
message,
});
addDangerToast(message);
logging.error(message);
}
} else {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
addDangerToast(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
);
logging.error(
t(
'The API response from %s does not match the IDatabaseTable interface.',
path,
),
} catch (caughtError) {
const clientError = await getClientErrorObject(
caughtError as Parameters<typeof getClientErrorObject>[0],
);
if (requestId === requestIdRef.current) {
const parsedError = clientError.errors?.[0] ?? {
error_type: ErrorTypeEnum.GENERIC_BACKEND_ERROR,
extra: null,
level: 'error' as const,
message:
clientError.error ||
clientError.message ||
clientError.statusText ||
t('Unable to load columns for the selected table.'),
};
setColumnList([]);
setHasColumns?.(false);
setError(parsedError);
}
} finally {
if (requestId === requestIdRef.current) {
setLoading(false);
}
}
} catch (error) {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
} finally {
setLoading(false);
},
[setHasColumns],
);
const retryGetTableMetadata = useCallback(() => {
if (currentRequestRef.current) {
getTableMetadata(currentRequestRef.current);
}
};
}, [getTableMetadata]);
useEffect(() => {
tableNameRef.current = tableName;
const schemaRequired = database?.supports_schemas !== false;
const schemaRequired = supportsSchemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
getTableMetadata({ tableName, dbId, schema: schema || undefined });
const request = {
tableName,
dbId,
catalog,
schema: schema || undefined,
};
currentRequestRef.current = request;
getTableMetadata(request);
} else if (currentRequestRef.current) {
currentRequestRef.current = undefined;
requestIdRef.current += 1;
setColumnList([]);
setError(undefined);
setHasColumns?.(false);
setLoading(false);
}
// getTableMetadata is a const and should not be in dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema, database]);
return () => {
requestIdRef.current += 1;
};
}, [
tableName,
dbId,
catalog,
schema,
supportsSchemas,
getTableMetadata,
setHasColumns,
]);
return (
<DatasetPanel
columnList={columnList}
hasError={hasError}
error={error}
errorMitigationFunction={retryGetTableMetadata}
loading={loading}
tableName={tableName}
datasets={datasets}

View File

@@ -138,7 +138,6 @@ describe('DatasetLayout', () => {
<DatasetPanelComponent
tableName="large_table"
columnList={manyColumns}
hasError={false}
loading={false}
/>
}

View File

@@ -32,7 +32,6 @@ import {
} from 'src/views/CRUD/utils';
import { Chart } from 'src/types/Chart';
import { Icons } from '@superset-ui/core/components/Icons';
import { useIsMobile } from 'src/hooks/useIsMobile';
import SubMenu from './SubMenu';
import EmptyState from './EmptyState';
import { WelcomeTable, RecentActivity } from './types';
@@ -71,12 +70,6 @@ const Styles = styled.div`
const UNTITLED = t('[Untitled]');
const UNKNOWN_TIME = t('Unknown');
// Dashboards are the only activity entities viewable in the mobile
// consumption experience; charts and saved queries link to unsupported views
const isDashboardEntity = (entity: ActivityObject) =>
'dashboard_title' in entity ||
('item_type' in entity && entity.item_type === 'dashboard');
const getEntityTitle = (entity: ActivityObject) => {
if ('dashboard_title' in entity) return entity.dashboard_title || UNTITLED;
if ('slice_name' in entity) return entity.slice_name || UNTITLED;
@@ -128,7 +121,6 @@ export default function ActivityTable({
}: ActivityProps) {
const [editedCards, setEditedCards] = useState<ActivityData[]>();
const [isFetchingEditedCards, setIsFetchingEditedCards] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
let isMounted = true;
@@ -182,16 +174,12 @@ export default function ActivityTable({
},
});
}
const rawActivities =
(activeChild === TableTab.Edited
? editedCards
: activityData[activeChild as keyof ActivityData]) ?? [];
const activities = isMobile
? rawActivities.filter(isDashboardEntity)
: rawActivities;
const renderActivity = () =>
activities.map((entity: ActivityObject) => {
const renderActivity = () => {
const activities =
(activeChild === TableTab.Edited
? editedCards
: activityData[activeChild as keyof ActivityData]) ?? [];
return activities.map((entity: ActivityObject) => {
const url = getEntityUrl(entity);
const lastActionOn = getEntityLastActionOn(entity);
return (
@@ -209,6 +197,7 @@ export default function ActivityTable({
</CardStyles>
);
});
};
if ((isFetchingEditedCards && !editedCards) || isFetchingActivityData) {
return <LoadingCards />;
@@ -220,7 +209,8 @@ export default function ActivityTable({
tabs={tabs}
backgroundColor="transparent"
/>
{activities.length > 0 ? (
{Number(activityData[activeChild as keyof ActivityData]?.length) > 0 ||
(activeChild === TableTab.Edited && editedCards?.length) ? (
<CardContainer className="recentCards">
{renderActivity()}
</CardContainer>

View File

@@ -24,7 +24,6 @@ import { TableTab } from 'src/views/CRUD/types';
import { t } from '@apache-superset/core/translation';
import { styled } from '@apache-superset/core/theme';
import { navigateTo } from 'src/utils/navigationUtils';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { WelcomeTable } from './types';
const EmptyContainer = styled.div`
@@ -77,19 +76,12 @@ export interface EmptyStateProps {
}
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
const isMobile = useIsMobile();
const getActionButton = () => {
if (tableName === WelcomeTable.Recents) {
return null;
}
const isFavorite = tab === TableTab.Favorite;
// Creation flows aren't part of the mobile consumption experience;
// only offer the "See all ..." navigation there
if (isMobile && !isFavorite) {
return null;
}
const buttonText = isFavorite
? LABELS.viewAll[tableName]
: LABELS.create[tableName];
@@ -119,7 +111,7 @@ export default function EmptyState({ tableName, tab }: EmptyStateProps) {
<EmptyContainer>
<EmptyStateComponent
image={image}
size={isMobile ? 'small' : 'large'}
size="large"
description={t('Nothing here yet')}
>
{getActionButton()}

View File

@@ -1052,6 +1052,59 @@ describe('active tab highlighting (regression #36403)', () => {
expect(getMenuItemByText('Дашборды')).toHaveClass('ant-menu-item-selected');
});
test.each([
['/tablemodelview/list/', 'the legacy FAB dataset list route'],
['/dataset/add/', 'the modern React dataset create route'],
['/dataset/42', 'a dataset detail route'],
])(
'highlights the Datasets tab on %s (%s) — regression #42467',
async (route, _label) => {
// ``/tablemodelview/list/`` is the pre-existing legacy path (kept as
// a coverage anchor so future prefix-matching changes cannot silently
// regress it); the ``/dataset/*`` routes are the modern React ones
// added by #42467.
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', route);
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
// Datasets is a child under the Sources submenu — expand it first so
// the item is in the DOM (same pattern as the existing "render the top
// navbar child menu items" test).
const sources = await screen.findByText('Sources');
userEvent.hover(sources);
const datasets = await screen.findByText('Datasets');
expect(datasets.closest('li')).toHaveClass('ant-menu-item-selected');
},
);
test('does not highlight the Datasets tab on lookalike prefixes (e.g. /datasetXyz)', async () => {
// The active-tab matcher must use a boundary-aware startsWith so that
// an unrelated future route beginning with ``/dataset`` (e.g. a
// hypothetical ``/datasetXyz``) does not falsely trigger the highlight.
useSelectorMock.mockReturnValue({ roles: user.roles });
window.history.pushState({}, '', '/datasetXyz');
render(<Menu {...mockedProps} />, {
useRedux: true,
useQueryParams: true,
useRouter: true,
useTheme: true,
});
const sources = await screen.findByText('Sources');
userEvent.hover(sources);
const datasets = await screen.findByText('Datasets');
expect(datasets.closest('li')).not.toHaveClass('ant-menu-item-selected');
});
test('highlights the active SQL tab when the label is localized', async () => {
// The SQL Lab top-level entry is a FAB category: its stable `name` is
// "SQL Lab" while its label ("SQL") is localized.

View File

@@ -29,7 +29,6 @@ import { NavLink, useLocation } from 'react-router-dom';
import { Icons } from '@superset-ui/core/components/Icons';
import { Typography } from '@superset-ui/core/components/Typography';
import { useUiConfig } from 'src/components/UiConfigContext';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { URL_PARAMS } from 'src/constants';
import {
MenuObjectChildProps,
@@ -200,7 +199,6 @@ export function Menu({
isFrontendRoute = () => false,
}: MenuProps) {
const screens = useBreakpoint();
const isMobile = useIsMobile();
const uiConfig = useUiConfig();
const theme = useTheme();
// screens.md is undefined on the first render before breakpoints are measured;
@@ -214,6 +212,12 @@ export function Menu({
Dashboard = '/dashboard',
Chart = '/chart',
Datasets = '/tablemodelview',
// The legacy FAB dataset list still lives at ``/tablemodelview/list/``,
// but the modern React-managed dataset add + detail routes are under
// ``/dataset/*`` (``/dataset/add/``, ``/dataset/:datasetId``). Both
// prefixes must map to the Datasets tab so the top-nav highlight
// survives navigation into the create/edit flow. See #42467.
Dataset = '/dataset',
SqlLab = '/sqllab',
SavedQueries = '/savedqueryview',
}
@@ -240,7 +244,9 @@ export function Menu({
case path.startsWith(Paths.Chart) || path.startsWith(Paths.Explore):
setActiveTabs([MenuKeys.Charts]);
break;
case path.startsWith(Paths.Datasets):
case path.startsWith(Paths.Datasets) ||
path === Paths.Dataset ||
path.startsWith(`${Paths.Dataset}/`):
setActiveTabs([MenuKeys.Datasets]);
break;
case path.startsWith(Paths.SqlLab) || path.startsWith(Paths.SavedQueries):
@@ -403,18 +409,7 @@ export function Menu({
aria-label={t('Main navigation')}
>
<StyledRow>
{/* Mobile: left placeholder for future icon */}
{isMobile && <Col xs={4} />}
<StyledCol
md={16}
xs={isMobile ? 16 : 24}
css={
isMobile &&
css`
justify-content: center;
`
}
>
<StyledCol md={16} xs={24}>
{!brand.hide_logo && (
<Tooltip
id="brand-tooltip"
@@ -430,44 +425,40 @@ export function Menu({
<span>{brand.text}</span>
</StyledBrandText>
)}
{/* Consumption mode: hide nav items on mobile (drawer holds them) */}
{!isMobile && (
<StyledMainNav
mode={isMd ? 'horizontal' : 'inline'}
data-test="navbar-top"
className="main-nav"
selectedKeys={activeTabs}
disabledOverflow
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
return c;
}
<StyledMainNav
mode={isMd ? 'horizontal' : 'inline'}
data-test="navbar-top"
className="main-nav"
selectedKeys={activeTabs}
disabledOverflow
items={menu.map(item => {
const props = {
...item,
label: t(item.label),
isFrontendRoute: isFrontendRoute(item.url),
childs: item.childs?.map(c => {
if (typeof c === 'string') {
return c;
}
return {
...c,
isFrontendRoute: isFrontendRoute(c.url),
};
}),
};
return {
...c,
isFrontendRoute: isFrontendRoute(c.url),
};
}),
};
return buildMenuItem(props);
})}
/>
)}
return buildMenuItem(props);
})}
/>
</StyledCol>
<Col md={8} xs={isMobile ? 4 : 24}>
<Col md={8} xs={24}>
<RightMenu
align={isMd || isMobile ? 'flex-end' : 'flex-start'}
align={isMd ? 'flex-end' : 'flex-start'}
settings={settings}
navbarRight={navbarRight}
isFrontendRoute={isFrontendRoute}
environmentTag={environmentTag}
menu={menu}
/>
</Col>
</StyledRow>

View File

@@ -16,10 +16,6 @@
* 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 reactRedux from 'react-redux';
import fetchMock from 'fetch-mock';
import {
@@ -54,9 +50,6 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile menu rendering)
jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
jest.mock('src/features/databases/DatabaseModal', () => {
const DatabaseModal = () => <span />;
DatabaseModal.displayName = 'DatabaseModal';

View File

@@ -51,16 +51,9 @@ import {
Icons,
Typography,
TelemetryPixel,
Drawer,
Button,
} from '@superset-ui/core/components';
import type { ItemType, MenuItem } from '@superset-ui/core/components/Menu';
import {
ensureAppRoot,
navigateTo,
stripAppRoot,
} from 'src/utils/navigationUtils';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { ensureAppRoot, stripAppRoot } from 'src/utils/navigationUtils';
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { findPermission } from 'src/utils/findPermission';
import { isUserAdmin } from 'src/dashboard/util/permissionUtils';
@@ -126,7 +119,6 @@ const RightMenu = ({
navbarRight,
isFrontendRoute,
environmentTag,
menu,
setQuery,
}: RightMenuProps & {
setQuery: ({
@@ -138,8 +130,6 @@ const RightMenu = ({
}) => void;
}) => {
const theme = useTheme();
const isMobile = useIsMobile();
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const user = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -654,64 +644,6 @@ const RightMenu = ({
handleLogout,
]);
// Build mobile menu items - consumption only (no create/admin actions)
const mobileMenuItems = useMemo(() => {
const items: MenuItem[] = [];
// Add Dashboards link at top (from main menu)
// Match on the FAB-internal `name`, which is stable across locales
// (`label` is translated and would break in non-English deployments)
const dashboardsMenu = menu?.find(item => item.name === 'Dashboards');
if (dashboardsMenu) {
const dashboardUrl = dashboardsMenu.url || '/dashboard/list/';
items.push({
key: 'dashboards',
label: isFrontendRoute(dashboardUrl) ? (
<Link to={dashboardUrl}>{t('Dashboards')}</Link>
) : (
<Typography.Link href={dashboardUrl}>
{t('Dashboards')}
</Typography.Link>
),
icon: <Icons.DashboardOutlined />,
});
}
// Add theme menu (flatten children directly)
menuItems.forEach(item => {
if (!item || !('key' in item)) return;
// Only include theme-sub-menu and language picker
if (item.key === 'theme-sub-menu' || item.key === 'language-picker') {
items.push({ type: 'divider', key: `divider-before-${item.key}` });
if ('children' in item && item.children) {
// Theme menu already has a nested group, so just add its children directly
item.children.forEach(child => {
items.push(child);
});
} else {
items.push(item);
}
}
// Extract user-related items from settings
if (item.key === 'settings' && 'children' in item && item.children) {
item.children.forEach(child => {
if (!child || !('key' in child)) return;
// Only include user-section and about-section
if (child.key === 'user-section' || child.key === 'about-section') {
items.push({ type: 'divider', key: `divider-before-${child.key}` });
items.push(child);
}
});
}
});
return items;
}, [menu, menuItems, isFrontendRoute]);
return (
<StyledDiv align={align}>
{canDatabase && (
@@ -772,98 +704,48 @@ const RightMenu = ({
</Tag>
);
})()}
{/* Mobile: hamburger menu with drawer */}
{isMobile && (
<>
<Button
buttonStyle="link"
onClick={() => setMobileMenuOpen(true)}
aria-label={t('Menu')}
>
<Icons.MenuOutlined iconSize="l" />
</Button>
<Drawer
title={null}
placement="right"
onClose={() => setMobileMenuOpen(false)}
open={mobileMenuOpen}
width={280}
styles={{
header: { display: 'none' },
body: { padding: 0 },
}}
>
<Menu
mode="inline"
selectable={false}
onClick={info => {
handleMenuSelection(info);
// The reused desktop items navigate via anchors that only
// span their label text, but the drawer's tap target is the
// full menu row — navigate explicitly so row taps work.
if (info.key === 'info' && navbarRight.user_info_url) {
navigateTo(navbarRight.user_info_url);
return;
}
if (info.key === 'logout' && navbarRight.user_logout_url) {
navigateTo(navbarRight.user_logout_url);
return;
}
setMobileMenuOpen(false);
}}
items={mobileMenuItems}
css={css`
border-inline-end: none !important;
`}
/>
</Drawer>
</>
)}
{/* Desktop: horizontal menu */}
{!isMobile && (
<Menu
css={css`
display: flex;
flex-direction: row;
align-items: center;
<Menu
css={css`
display: flex;
flex-direction: row;
align-items: center;
height: 100%;
border-bottom: none !important;
/* Remove the underline from menu items */
.ant-menu-item:after,
.ant-menu-submenu:after {
content: none !important;
}
.submenu-with-caret {
height: 100%;
border-bottom: none !important;
/* Remove the underline from menu items */
.ant-menu-item:after,
.ant-menu-submenu:after {
content: none !important;
}
.submenu-with-caret {
padding: 0;
.ant-menu-submenu-title {
align-items: center;
display: flex;
gap: ${theme.sizeUnit * 2}px;
flex-direction: row-reverse;
height: 100%;
padding: 0;
.ant-menu-submenu-title {
align-items: center;
display: flex;
gap: ${theme.sizeUnit * 2}px;
flex-direction: row-reverse;
height: 100%;
}
&.ant-menu-submenu::after {
inset-inline: ${theme.sizeUnit}px;
}
&.ant-menu-submenu:hover,
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
&.ant-menu-submenu::after {
inset-inline: ${theme.sizeUnit}px;
}
&.ant-menu-submenu:hover,
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
`}
selectable={false}
mode="horizontal"
onClick={handleMenuSelection}
onOpenChange={onMenuOpen}
disabledOverflow
items={menuItems}
/>
)}
}
`}
selectable={false}
mode="horizontal"
onClick={handleMenuSelection}
onOpenChange={onMenuOpen}
disabledOverflow
items={menuItems}
/>
{navbarRight.documentation_url && (
<>
<StyledAnchor

View File

@@ -127,35 +127,3 @@ test('should render the buttons', async () => {
userEvent.click(testButton);
expect(mockFunc).toHaveBeenCalled();
});
// Mobile support tests
test('should render leftIcon when provided', async () => {
setup({
leftIcon: (
<button type="button" data-test="left-icon-button">
Search
</button>
),
});
expect(await screen.findByTestId('left-icon-button')).toBeInTheDocument();
});
test('should render rightIcon when provided', async () => {
setup({
rightIcon: (
<button type="button" data-test="right-icon-button">
Menu
</button>
),
});
expect(await screen.findByTestId('right-icon-button')).toBeInTheDocument();
});
test('should render both leftIcon and rightIcon together', async () => {
setup({
leftIcon: <span data-test="mobile-left">Left</span>,
rightIcon: <span data-test="mobile-right">Right</span>,
});
expect(await screen.findByTestId('mobile-left')).toBeInTheDocument();
expect(await screen.findByTestId('mobile-right')).toBeInTheDocument();
});

View File

@@ -37,7 +37,6 @@ import {
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { MenuObjectProps } from 'src/types/bootstrapTypes';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import { Typography } from '@superset-ui/core/components/Typography';
const StyledHeader = styled.div<{ backgroundColor?: string }>`
@@ -110,44 +109,13 @@ const StyledHeader = styled.div<{ backgroundColor?: string }>`
.btn-link {
padding: 10px 0;
}
${({ theme }) =>
isMobileConsumptionEnabled()
? css`
@media (max-width: ${theme.screenSMMax}px) {
.header {
position: relative;
margin-left: 0;
flex: 1;
text-align: center;
}
/* Consumption mode: hide all buttons on mobile */
.nav-right,
.nav-right-collapse {
display: none !important;
}
/* Compact horizontal tabs on mobile (segmented-control style) */
.menu > .ant-menu {
padding-left: 0;
.ant-menu-item {
padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px;
margin-right: ${theme.sizeUnit / 2}px;
font-size: ${theme.fontSizeSM}px;
}
}
}
`
: css`
@media (max-width: ${theme.screenSMMax}px) {
.header,
.nav-right {
position: relative;
margin-left: ${theme.sizeUnit * 2}px;
}
}
`}
@media (max-width: 767px) {
.header,
.nav-right {
position: relative;
margin-left: ${({ theme }) => theme.sizeUnit * 2}px;
}
}
`;
const styledDisabled = (theme: SupersetTheme) => css`
@@ -197,10 +165,6 @@ export interface SubMenuProps {
dropDownLinks?: Array<MenuObjectProps>;
backgroundColor?: string;
children?: ReactNode;
/** Left icon for mobile - shown before the header */
leftIcon?: ReactNode;
/** Right icon for mobile - shown after the header */
rightIcon?: ReactNode;
}
const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
@@ -222,16 +186,8 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
function handleResize() {
if (!isMounted) return;
// In consumption mode the tabs stay horizontal on mobile (the CSS
// renders them compact); otherwise fall back to the inline layout.
if (
!isMobileConsumptionEnabled() &&
window.innerWidth <= theme.screenSMMax
) {
setMenu('inline');
} else {
setMenu('horizontal');
}
if (window.innerWidth <= 767) setMenu('inline');
else setMenu('horizontal');
if (
props.buttons &&
@@ -255,15 +211,13 @@ const SubMenuComponent: FunctionComponent<SubMenuProps> = props => {
resize.cancel();
window.removeEventListener('resize', resize);
};
}, [props.buttons, theme.screenSMMax]);
}, [props.buttons]);
return (
<StyledHeader backgroundColor={props.backgroundColor}>
<nav aria-label={t('Page navigation')}>
<Row className="menu">
{props.leftIcon}
{props.name && <div className="header">{props.name}</div>}
{props.rightIcon}
<Menu
mode={showMenu}
disabledOverflow

View File

@@ -48,7 +48,6 @@ export interface RightMenuProps {
color: string;
};
children?: ReactNode;
menu?: MenuObjectProps[];
}
export enum GlobalMenuDataOptions {

View File

@@ -1,71 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect, useState } from 'react';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { useTheme } from '@apache-superset/core/theme';
// Matches antd's screenSMMax token; used only when no theme is in scope.
const FALLBACK_MOBILE_MAX_WIDTH = 767;
/**
* Whether the mobile consumption-only experience is enabled for this
* deployment. Non-hook variant for use inside styled-component
* interpolations; prefer `useIsMobile` in components.
*/
export function isMobileConsumptionEnabled(): boolean {
return isFeatureEnabled(FeatureFlag.MobileConsumptionMode);
}
/**
* Returns true when MOBILE_CONSUMPTION_MODE is enabled AND the viewport is
* at or below the theme's `screenSMMax` breakpoint. All mobile-specific
* behavior (route guarding, consumption-only chrome, drawer navigation)
* should key off this hook so the flag remains a single kill switch.
*
* The matchMedia subscription is only installed when the flag is on, and
* state only changes when the match flips, so with the flag off (or on
* desktop) this hook never causes a re-render — consumers are inert.
*
* The initial value is always false (desktop), so the first paint never
* takes the mobile branch by accident.
*/
export function useIsMobile(): boolean {
const enabled = isMobileConsumptionEnabled();
const theme = useTheme();
const maxWidth = theme?.screenSMMax ?? FALLBACK_MOBILE_MAX_WIDTH;
const [isSmallScreen, setIsSmallScreen] = useState(false);
useEffect(() => {
if (!enabled) {
return undefined;
}
const mediaQuery = window.matchMedia(`(max-width: ${maxWidth}px)`);
const update = () => setIsSmallScreen(mediaQuery.matches);
update();
// Safari < 14 lacks addEventListener on MediaQueryList
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', update);
return () => mediaQuery.removeEventListener('change', update);
}
mediaQuery.addListener(update);
return () => mediaQuery.removeListener(update);
}, [enabled, maxWidth]);
return enabled && isSmallScreen;
}

View File

@@ -16,10 +16,6 @@
* 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 { isFeatureEnabled } from '@superset-ui/core';
import { mockUserSubjectsBootstrapData } from 'spec/helpers/mockBootstrapData';
@@ -54,9 +50,6 @@ jest.mock('src/utils/getBootstrapData', () =>
mockUserSubjectsBootstrapData([1]),
);
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;

View File

@@ -22,7 +22,7 @@ import {
FeatureFlag,
SupersetClient,
} from '@superset-ui/core';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { styled } from '@apache-superset/core/theme';
import { useSelector } from 'react-redux';
import { useState, useMemo, useCallback } from 'react';
import { Link } from 'react-router-dom';
@@ -38,10 +38,8 @@ import Subject from 'src/types/Subject';
import { SUBJECT_OPTION_FILTER_PROPS } from 'src/features/subjects/SubjectSelectLabel';
import { SubjectPile } from 'src/features/subjects/SubjectPile';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import { useIsMobile } from 'src/hooks/useIsMobile';
import {
ActionButton,
Button,
CertifiedBadge,
ConfirmStatusChange,
DeleteModal,
@@ -175,9 +173,6 @@ const DASHBOARD_COLUMNS_TO_FETCH = [
function DashboardList(props: DashboardListProps) {
const { addDangerToast, addSuccessToast, user } = props;
const isNotMobile = !useIsMobile();
const theme = useTheme();
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const { roles } = useSelector<any, UserWithPermissionsAndRoles>(
state => state.user,
);
@@ -825,25 +820,7 @@ function DashboardList(props: DashboardListProps) {
}
return (
<>
<SubMenu
name={t('Dashboards')}
buttons={subMenuButtons}
leftIcon={
!isNotMobile ? (
<Button
buttonStyle="link"
onClick={() => setMobileFiltersOpen(true)}
aria-label={t('Search')}
css={css`
padding: 0;
margin-right: ${theme.sizeUnit * 2}px;
`}
>
<Icons.SearchOutlined iconSize="l" />
</Button>
) : undefined
}
/>
<SubMenu name={t('Dashboards')} buttons={subMenuButtons} />
<ConfirmStatusChange
title={t('Please confirm')}
description={t(
@@ -932,14 +909,8 @@ function DashboardList(props: DashboardListProps) {
? 'card'
: 'table'
}
forceViewMode={!isNotMobile ? 'card' : undefined}
enableBulkTag={enableBulkTag}
bulkTagResourceName="dashboard"
mobileFiltersOpen={mobileFiltersOpen}
setMobileFiltersOpen={
!isNotMobile ? setMobileFiltersOpen : undefined
}
mobileFiltersDrawerTitle={t('Search Dashboards')}
/>
</>
);

View File

@@ -1,179 +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.
*/
/**
* Mobile-specific tests for the Home/Welcome page.
*
* These tests verify that certain desktop-only sections are hidden
* on mobile viewports.
*/
import fetchMock from 'fetch-mock';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import Welcome from 'src/pages/Home';
import { mockMobileMatchMedia } from 'spec/helpers/mobileTestUtils';
// Enable only the mobile consumption mode flag so the mobile branch renders
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(
(flag: string) => flag === 'MOBILE_CONSUMPTION_MODE',
),
}));
// Simulate a mobile viewport for the useIsMobile hook
mockMobileMatchMedia();
// API mocks
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const chartInfoEndpoint = 'glob:*/api/v1/chart/_info?*';
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status?*';
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info?*';
const dashboardFavoriteStatusEndpoint =
'glob:*/api/v1/dashboard/favorite_status/?*';
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
const savedQueryInfoEndpoint = 'glob:*/api/v1/saved_query/_info?*';
const recentActivityEndpoint = 'glob:*/api/v1/log/recent_activity/*';
fetchMock.get(chartsEndpoint, {
result: [
{
slice_name: 'ChartyChart',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/explore',
id: '4',
table: {},
},
],
});
fetchMock.get(dashboardsEndpoint, {
result: [
{
dashboard_title: 'Dashboard_Test',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/dashboard',
id: '3',
},
],
});
fetchMock.get(savedQueryEndpoint, {
result: [],
});
fetchMock.get(recentActivityEndpoint, {
result: [
{
action: 'dashboard',
item_title: "World Bank's Data",
item_type: 'dashboard',
item_url: '/superset/dashboard/world_health/',
time: 1741644942130.566,
time_delta_humanized: 'a day ago',
},
],
});
fetchMock.get(chartInfoEndpoint, { permissions: [] });
fetchMock.get(chartFavoriteStatusEndpoint, { result: [] });
fetchMock.get(dashboardInfoEndpoint, { permissions: [] });
fetchMock.get(dashboardFavoriteStatusEndpoint, { result: [] });
fetchMock.get(savedQueryInfoEndpoint, { permissions: [] });
const mockedProps = {
user: {
username: 'alpha',
firstName: 'alpha',
lastName: 'alpha',
createdOn: '2016-11-11T12:34:17',
userId: 5,
email: 'alpha@alpha.com',
isActive: true,
isAnonymous: false,
permissions: {},
roles: {
sql_lab: [['can_read', 'SavedQuery']],
},
},
};
const renderWelcome = (props = mockedProps) =>
waitFor(() => {
render(<Welcome {...props} />, {
useRedux: true,
useRouter: true,
});
});
afterEach(() => {
fetchMock.clearHistory();
});
test('Mobile view - renders Dashboards panel', async () => {
await renderWelcome();
expect(await screen.findByText('Dashboards')).toBeInTheDocument();
});
test('Mobile view - renders Recents panel', async () => {
await renderWelcome();
expect(await screen.findByText('Recents')).toBeInTheDocument();
});
test('Mobile view - does NOT render Charts panel', async () => {
await renderWelcome();
// Wait for Dashboards to ensure the component has rendered
await screen.findByText('Dashboards');
// Charts panel should NOT be present on mobile
// Look specifically for the Charts collapse panel header
const chartsPanel = screen.queryByRole('button', { name: /Charts/i });
expect(chartsPanel).not.toBeInTheDocument();
});
test('Mobile view - does NOT render Saved queries panel', async () => {
await renderWelcome();
// Wait for Dashboards to ensure the component has rendered
await screen.findByText('Dashboards');
// Saved queries panel should NOT be present on mobile
const savedQueriesPanel = screen.queryByRole('button', {
name: /Saved queries/i,
});
expect(savedQueriesPanel).not.toBeInTheDocument();
});
test('Mobile view - only shows 2 panels (Recents and Dashboards)', async () => {
await renderWelcome();
// Wait for content to load
await screen.findByText('Dashboards');
// Should only have Recents and Dashboards panels visible
// Charts and Saved queries are hidden on mobile
const recentsPanel = screen.queryByText('Recents');
const dashboardsPanel = screen.queryByText('Dashboards');
expect(recentsPanel).toBeInTheDocument();
expect(dashboardsPanel).toBeInTheDocument();
});

View File

@@ -16,10 +16,6 @@
* 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 {
render,
@@ -150,9 +146,6 @@ jest.mock('@superset-ui/core', () => ({
isFeatureEnabled: jest.fn(),
}));
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => mockAntdWithDesktopBreakpoint());
const mockedIsFeatureEnabled = isFeatureEnabled as jest.Mock;
const renderWelcome = (props = mockedProps) =>

View File

@@ -28,7 +28,6 @@ import { styled } from '@apache-superset/core/theme';
import rison from 'rison';
import { Collapse, ListViewCard } from '@superset-ui/core/components';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { reject } from 'lodash-es';
import {
dangerouslyGetItemDoNotUse,
@@ -148,7 +147,6 @@ export const LoadingCards = ({ cover }: LoadingProps) => (
);
function Welcome({ user, addDangerToast }: WelcomeProps) {
const isNotMobile = !useIsMobile();
const canReadSavedQueries = userHasPermission(user, 'SavedQuery', 'can_read');
const userid = user.userId;
const id = userid!.toString(); // confident that user is not a guest user
@@ -399,29 +397,24 @@ function Welcome({ user, addDangerToast }: WelcomeProps) {
/>
),
},
// Hide Charts and Saved queries on mobile - consumption-only mode
...(isNotMobile
? [
{
key: 'charts',
label: t('Charts'),
children:
!chartData || isRecentActivityLoading ? (
<LoadingCards cover={checked} />
) : (
<ChartTable
showThumbnails={checked}
user={user}
mine={chartData}
otherTabData={activityData?.[TableTab.Other]}
otherTabFilters={otherTabFilters}
otherTabTitle={otherTabTitle}
/>
),
},
]
: []),
...(isNotMobile && canReadSavedQueries
{
key: 'charts',
label: t('Charts'),
children:
!chartData || isRecentActivityLoading ? (
<LoadingCards cover={checked} />
) : (
<ChartTable
showThumbnails={checked}
user={user}
mine={chartData}
otherTabData={activityData?.[TableTab.Other]}
otherTabFilters={otherTabFilters}
otherTabTitle={otherTabTitle}
/>
),
},
...(canReadSavedQueries
? [
{
key: 'saved-queries',

View File

@@ -1,95 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router-dom';
import MobileUnsupported from './index';
// Mock useHistory
const mockPush = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useHistory: () => ({
push: mockPush,
}),
}));
beforeEach(() => {
jest.clearAllMocks();
});
const renderComponent = () =>
render(
<MemoryRouter initialEntries={['/chart/list/']}>
<MobileUnsupported />
</MemoryRouter>,
);
test('renders the page title', () => {
renderComponent();
expect(
screen.getByText("This view isn't available on mobile"),
).toBeInTheDocument();
});
test('renders the description text', () => {
renderComponent();
expect(
screen.getByText(
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
),
).toBeInTheDocument();
});
test('renders the View Dashboards button', () => {
renderComponent();
expect(
screen.getByRole('button', { name: 'View Dashboards' }),
).toBeInTheDocument();
});
test('renders the Go to Welcome Page button', () => {
renderComponent();
expect(
screen.getByRole('button', { name: 'Go to Welcome Page' }),
).toBeInTheDocument();
});
test('does not render a Continue anyway bypass', () => {
renderComponent();
expect(screen.queryByText(/Continue anyway/)).not.toBeInTheDocument();
});
test('View Dashboards button navigates to dashboard list', async () => {
renderComponent();
const button = screen.getByRole('button', { name: 'View Dashboards' });
await userEvent.click(button);
expect(mockPush).toHaveBeenCalledWith('/dashboard/list/');
});
test('Go to Welcome Page button navigates to welcome page', async () => {
renderComponent();
const button = screen.getByRole('button', { name: 'Go to Welcome Page' });
await userEvent.click(button);
expect(mockPush).toHaveBeenCalledWith('/welcome/');
});

View File

@@ -1,141 +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 { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { t } from '@apache-superset/core/translation';
import { css, useTheme } from '@apache-superset/core/theme';
import { Button } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
/**
* A mobile-friendly page shown when users try to access
* features that aren't supported on mobile devices. Growing the window
* past the mobile breakpoint unblocks the route automatically (useIsMobile
* subscribes to the breakpoint), so no manual bypass is offered.
*/
function MobileUnsupported() {
const theme = useTheme();
const history = useHistory();
const actionButtonCss = css`
width: 280px;
height: 48px;
font-size: ${theme.fontSizeSM}px;
`;
const handleViewDashboards = useCallback(() => {
history.push('/dashboard/list/');
}, [history]);
const handleGoHome = useCallback(() => {
history.push('/welcome/');
}, [history]);
return (
<div
css={css`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: calc(100vh - 60px);
padding: ${theme.sizeUnit * 6}px;
text-align: center;
background: ${theme.colorBgContainer};
`}
>
{/* Icon */}
<div
css={css`
width: 120px;
height: 120px;
border-radius: 50%;
background: ${theme.colorBgLayout};
display: flex;
align-items: center;
justify-content: center;
margin-bottom: ${theme.sizeUnit * 6}px;
`}
>
<Icons.DesktopOutlined
iconSize="xxl"
iconColor={theme.colorTextSecondary}
css={css`
font-size: 48px;
`}
/>
</div>
{/* Title */}
<h1
css={css`
font-size: ${theme.fontSizeXL}px;
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorText};
margin: 0 0 ${theme.sizeUnit * 2}px 0;
`}
>
{t("This view isn't available on mobile")}
</h1>
{/* Description */}
<p
css={css`
font-size: ${theme.fontSizeSM}px;
color: ${theme.colorTextSecondary};
margin: 0 0 ${theme.sizeUnit * 8}px 0;
max-width: 300px;
line-height: 1.5;
`}
>
{t(
'Some features require a larger screen. Try viewing dashboards for the best mobile experience.',
)}
</p>
{/* Primary action */}
<div>
<Button
buttonStyle="primary"
onClick={handleViewDashboards}
css={actionButtonCss}
>
{t('View Dashboards')}
</Button>
</div>
{/* Secondary action */}
<div
css={css`
margin-top: ${theme.sizeUnit * 3}px;
`}
>
<Button
buttonStyle="secondary"
onClick={handleGoHome}
css={actionButtonCss}
>
{t('Go to Welcome Page')}
</Button>
</div>
</div>
);
}
export default MobileUnsupported;

View File

@@ -35,7 +35,6 @@ import { css, useTheme } from '@apache-superset/core/theme';
import { Flex, Layout, Loading } from '@superset-ui/core/components';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import { ErrorBoundary } from 'src/components';
import MobileRouteGuard from 'src/components/MobileRouteGuard';
import MenuWrapper from 'src/features/home/Menu';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
@@ -98,29 +97,19 @@ const RouteSwitch = () => {
const theme = useTheme();
return (
<Switch>
{routes.map(
({
path,
Component,
props = {},
Fallback = Loading,
mobileSupported,
}) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<MobileRouteGuard mobileSupported={mobileSupported}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</MobileRouteGuard>
</Suspense>
</Route>
),
)}
{routes.map(({ path, Component, props = {}, Fallback = Loading }) => (
<Route path={path} key={path}>
<Suspense fallback={<Fallback />}>
<ErrorBoundary
css={css`
margin: ${theme.sizeUnit * 4}px;
`}
>
<Component user={bootstrapData.user} {...props} />
</ErrorBoundary>
</Suspense>
</Route>
))}
<Redirect from="/" to="/welcome/" exact />
</Switch>
);

View File

@@ -26,7 +26,6 @@ import {
lruCache,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import Chart from 'src/types/Chart';
import { intersection } from 'lodash-es';
import rison from 'rison';
@@ -458,17 +457,6 @@ export const CardContainer = styled.div<{
? `${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;
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
}`
: ''
}
`}
`;

View File

@@ -194,36 +194,18 @@ type Routes = {
Component: ComponentType<any>;
Fallback?: ComponentType<any>;
props?: ComponentProps<any>;
/**
* Marks a route as usable in the mobile consumption-only experience.
* Routes without this flag show the MobileUnsupported screen on small
* screens when MOBILE_CONSUMPTION_MODE is enabled.
*/
mobileSupported?: boolean;
}[];
export const routes: Routes = [
{
path: RoutePaths.REDIRECT,
Component: RedirectWarning,
mobileSupported: true,
},
{ path: RoutePaths.LOGIN, Component: Login, mobileSupported: true },
{
path: RoutePaths.REGISTER_ACTIVATION,
Component: Register,
mobileSupported: true,
},
{ path: RoutePaths.REGISTER, Component: Register, mobileSupported: true },
{ path: RoutePaths.LOGOUT, Component: Login, mobileSupported: true },
{ path: RoutePaths.HOME, Component: Home, mobileSupported: true },
{ path: RoutePaths.REDIRECT, Component: RedirectWarning },
{ path: RoutePaths.LOGIN, Component: Login },
{ path: RoutePaths.REGISTER_ACTIVATION, Component: Register },
{ path: RoutePaths.REGISTER, Component: Register },
{ path: RoutePaths.LOGOUT, Component: Login },
{ path: RoutePaths.HOME, Component: Home },
{ path: RoutePaths.FILE_HANDLER, Component: FileHandler },
{
path: RoutePaths.DASHBOARD_LIST,
Component: DashboardList,
mobileSupported: true,
},
{ path: RoutePaths.DASHBOARD, Component: Dashboard, mobileSupported: true },
{ path: RoutePaths.DASHBOARD_LIST, Component: DashboardList },
{ path: RoutePaths.DASHBOARD, Component: Dashboard },
{ path: RoutePaths.CHART_ADD, Component: ChartCreation },
{ path: RoutePaths.CHART_LIST, Component: ChartList },
{ path: RoutePaths.DATASET_LIST, Component: DatasetList },
@@ -253,7 +235,7 @@ export const routes: Routes = [
{ path: RoutePaths.ROW_LEVEL_SECURITY, Component: RowLevelSecurityList },
{ path: RoutePaths.TASKS, Component: TaskList },
{ path: RoutePaths.SQLLAB, Component: SqlLab },
{ path: RoutePaths.USER_INFO, Component: UserInfo, mobileSupported: true },
{ path: RoutePaths.USER_INFO, Component: UserInfo },
{ path: RoutePaths.ACTION_LOG, Component: ActionLogList },
{ path: RoutePaths.REGISTRATIONS, Component: UserRegistrations },
];

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