Compare commits

..

51 Commits

Author SHA1 Message Date
Superset Dev
a175324cff chore(mobile): enable MOBILE_CONSUMPTION_MODE in the docker dev config
The shipped default in superset/config.py remains False; this only turns
the flag on for the docker compose development environment, matching the
other in-development flags enabled there.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 18:55:28 -07:00
Superset Dev
9da3908bec refactor(mobile): gate consumption mode behind MOBILE_CONSUMPTION_MODE flag, fix chart sizing
Review-pass hardening of the mobile consumption mode:

- Add MOBILE_CONSUMPTION_MODE feature flag (default off, @lifecycle:
  development) and a shared useIsMobile() hook; all mobile behavior and
  mobile CSS now keys off the flag, so default behavior is unchanged
- Fix chart sizing: ChartHolder reports the full grid column count on
  mobile so plugins get the true stacked width; drop the
  width:100%!important CSS overrides on Chart/Row/ResizableContainer
- ListView: mount a single FilterControls instance (inline or drawer)
  so filtersRef/clearFilters always target the visible instance; drop
  the desktop-filters/desktop-sort CSS-hidden duplicates
- Route gating: routes declare mobileSupported in routes.tsx and
  MobileRouteGuard receives it as a prop; remove the regex allowlist,
  the unregistered /mobile-unsupported entry, and dead ?from= handling
- FilterBar: first-class mobileMode in verticalConfig replaces the
  :has()/!important drawer CSS surgery in DashboardBuilder
- Consumption-only controls (explore links, slice kebab menus, card
  kebab menus) are render-gated instead of CSS-hidden for a11y
- Use theme.screenSMMax instead of hardcoded 767px media queries
- RightMenu: match the Dashboards nav item by FAB-internal name, not
  translated label; fix getOwnerName -> getUserName and owners ->
  editors after the Subject-model refactor
- MobileUnsupported: use Button/link + icon instead of raw styled
  buttons and a trailing arrow outside the translated string
- Regenerate docs/static/feature-flags.json

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 23:53:04 -07:00
Claude Code
76c247513e feat(mobile): Add mobile-friendly dashboard consumption mode
Adds a mobile-optimized dashboard consumption experience:

- Drawer-based nav and filter menus on mobile
- Full-width chart layout on small screens, with charts stacked
- Compact dashboard header (hidden action buttons that don't apply in
  consumption mode)
- Force card view (and hide the table/card toggle) on the dashboard list
- Full-width cards on the welcome page when viewed on mobile
- Filter drawer for the dashboard list page
- Route guard for pages that don't yet have a mobile experience, with a
  dedicated MobileUnsupported page
- Mock-friendly `Grid.useBreakpoint` plumbing for existing component
  tests, plus a new mobile-focused test suite and Playwright coverage

Squashed from 32 commits on the long-running mobile-dashboard-support
branch and rebased onto current master.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-15 22:48:31 -07:00
Evan Rusackas
f697a0c24d test(charts): assert drill-to-detail carries applied filters (#28562) (#41960)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:33 -07:00
Evan Rusackas
13d38a9cbd test(security): assert intended trailing-slash behavior of security API (#29934) (#41965)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:15 -07:00
yousoph
8603048518 fix(dashboard): block dependent filter from fetching until defaultToFirstItem parent selects (#40978)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:01:56 -07:00
Mike Bridge
635b18103d fix(reports): null-guard execution against missing target (#39973)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-15 22:00:46 -07:00
gr33nak
d57569c54a feat(reports): add XLSX (Excel) attachments for Alerts & Reports (#41424)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Kominek <martin.kominek@stratox.cz>
Co-authored-by: kominma3 <127758497+kominma3@users.noreply.github.com>
2026-07-15 21:49:47 -07:00
Benedict Jin
409605de70 fix(plugin-chart-echarts): key Mixed Timeseries label maps by rendered series names (#41933) 2026-07-15 21:37:59 -07:00
Greg Neighbors
8ce6d42942 feat(mcp): add restore_chart and restore_dashboard tools (#41842)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:51:15 -07:00
Greg Neighbors
2bbb7d0638 feat(mcp): histogram and box plot chart type plugins (#41860)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:50:09 -07:00
Evan Rusackas
1b0c6aaed3 chore(a11y): enable jsx-a11y/click-events-have-key-events as error (#42009)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:49:45 -07:00
dependabot[bot]
1f786f1949 chore(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /superset-frontend (#42094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 15:49:15 -07:00
Evan Rusackas
c540f782a3 fix(ci): remove Python 3.10 from the test matrix to unblock CI (#42058)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:48:47 -07:00
Joe Li
6fe4655ba2 fix(semantic-layers): separate grain and offset time-axis resolvers (#42093)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:33:52 -07:00
Elizabeth Thompson
689fc34ac2 fix: replace deprecated appbuilder.app with current_app in test_explore_redirect (#42086) 2026-07-15 15:05:15 -07:00
Elizabeth Thompson
e564389a01 fix(a11y): associate Name label with input in CssTemplateModal (#42081) 2026-07-15 15:04:33 -07:00
Elizabeth Thompson
da518d7a10 fix(a11y): associate Description label with TagModal input (#42034) 2026-07-15 15:03:40 -07:00
Joe Li
c6da740ce2 fix(app-root): restore legacy redirects for HEAD and the /superset app root (#42015)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:13:46 -07:00
Beto Dealmeida
e28b259de0 fix(semantic-layers): expose time grains in Explore for SemanticView datasources (#41456) 2026-07-15 12:25:41 -07:00
Elizabeth Thompson
beb9d53687 fix(plugin-chart-echarts): use echarts 5.6.0 i18n export path for locale import (#42055)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 11:45:14 -07:00
Mike Bridge
90f9238f8a fix(charts): preserve time filter for expression axes (#42052)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-15 14:27:50 -03:00
Amin Ghadersohi
f38fff2a19 test(mcp): close systematic test-coverage gaps in mcp_service (#41924) 2026-07-15 12:30:04 -04:00
Amin Ghadersohi
cec9afb165 fix(mcp): await ctx.info calls in update_dashboard tool (#41920)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-15 12:28:44 -04:00
dependabot[bot]
753113d169 chore(deps-dev): bump trino from 0.337.0 to 0.338.0 (#42059)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-15 08:13:33 -07:00
dependabot[bot]
5c8e14e9dc chore(deps-dev): bump hdbcli from 2.28.21 to 2.29.23 (#42060)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 08:13:30 -07:00
dependabot[bot]
e66d58361a chore(deps-dev): update clickhouse-connect requirement from <2.0,>=1.1.1 to >=1.4.2,<2.0 (#42062)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 08:13:26 -07:00
dependabot[bot]
7bc1895050 chore(deps-dev): update pydoris requirement from <2.0.0,>=1.0.0 to >=1.2.0,<2.0.0 (#42063)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 08:13:22 -07:00
dependabot[bot]
071c431580 chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1 in /superset-websocket (#42076)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 08:13:19 -07:00
dependabot[bot]
53c4603c8c chore(deps-dev): bump @types/node from 26.1.0 to 26.1.1 in /superset-frontend (#42077)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 08:13:15 -07:00
Mehmet Salih Yavuz
f010affbc2 fix(reports): paginate Slack recipient picker for large workspaces (#41998) 2026-07-15 18:00:53 +03:00
Amin Ghadersohi
6c2b7aceb5 fix(mcp): correct decorator order on query_dataset tool (#41925) 2026-07-15 10:42:11 -04:00
Daniel Vaz Gaspar
2af66b2c9b fix(async-queries): add Celery task expiry to GAQ async query tasks (#42084)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-07-15 15:04:56 +01:00
Evan Rusackas
a540f56f5c fix(reports): raise typed executor-not-found error in alert-query path (#41486)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 23:53:03 -07:00
aikawa-ohno
aa85455a5c fix(i18n): Update Japanese translations (#41984)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 22:07:38 -07:00
dependabot[bot]
d80267d00b chore(deps): bump github/codeql-action/init from 4.36.3 to 4.37.0 (#42068)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 21:21:25 -07:00
yousoph
7953382d10 feat(submenu_export): Add export to png and pdf options for charts (#38535)
Co-authored-by: Ramiro Aquino Romero <ramiroaquinoromero@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
Co-authored-by: Claude <claude@anthropic.com>
2026-07-14 21:21:12 -07:00
dependabot[bot]
abafe195bf chore(deps): bump click from 8.4.1 to 8.4.2 (#42064)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 20:38:32 -07:00
dependabot[bot]
b4373f60b3 chore(deps-dev): bump fastmcp from 3.4.2 to 3.4.3 (#42065)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 20:38:30 -07:00
dependabot[bot]
6dcd95e14a chore(deps-dev): bump syntaqlite from 0.6.0 to 0.7.0 (#42067)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-14 20:38:27 -07:00
dependabot[bot]
ad8f2cf268 chore(deps): bump github/codeql-action/analyze from 4.36.3 to 4.37.0 (#42069)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-14 20:38:21 -07:00
Mafi
9db88203e1 fix(migration): use sqlalchemy.orm.declarative_base to avoid MovedIn20Warning (#42056)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 21:08:43 -04:00
Joe Li
13121fcd58 fix(ci): skip unsupported React 19 Dependabot updates (#42047) 2026-07-15 08:03:14 +07:00
Evan Rusackas
be768efc0f fix(ci): make pre-commit fail closed instead of silently passing on an empty file list (#42044)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 16:20:56 -07:00
Elizabeth Thompson
d68e84e731 fix(databricks): classify insufficient-permissions errors as 4xx (#41945)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-14 13:44:41 -07:00
Mafi
0a3c263606 fix(migration): strip metricSqlExpressions from ag_grid_table params (#41591)
Co-authored-by: Matt Fitzgerald <matt.fitzgerald@preset.io>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 16:42:32 -04:00
Amin Ghadersohi
bd9ba24266 fix(mcp): add AliasChoices to chart/dashboard/dataset request schemas (#41597)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-14 14:24:12 -04:00
Evan Rusackas
4446967d0f chore(lint): convert Storybook stories and shared helpers to function components (#39451)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-14 11:13:47 -07:00
Evan Rusackas
c04a0295ef fix(chart): keep query-context updates bound to the chart's datasource (#40955)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-14 11:13:33 -07:00
Mike Bridge
e3a0d0e41e fix(versioning): isolate action metadata writes (#41943)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-14 11:10:12 -07:00
Hans Yu
256cc71c87 refactor: import from sqlalchemy.orm (#41981) 2026-07-14 10:29:18 -07:00
352 changed files with 13393 additions and 2275 deletions

View File

@@ -50,8 +50,9 @@ body:
attributes:
label: Python version
options:
- "3.9"
- "3.10"
- "3.11"
- "3.12"
- Not applicable
- I don't know
validations:

View File

@@ -11,7 +11,15 @@ updates:
- package-ecosystem: "npm"
ignore:
- dependency-name: "@rjsf/*"
# TODO: remove below entries until React >= 19.0.0
# TODO: remove below entries once the application supports React >= 19.0.0
- dependency-name: "react"
update-types: ["version-update:semver-major"]
- dependency-name: "react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react"
update-types: ["version-update:semver-major"]
- dependency-name: "@types/react-dom"
update-types: ["version-update:semver-major"]
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
# Source: https://jestjs.io/blog#known-issues

View File

@@ -42,7 +42,7 @@ jobs:
- name: Set up Python ${{ inputs.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.11"
python-version: "3.10"
- name: Install uv
run: pip install uv

View File

@@ -64,7 +64,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -75,6 +75,6 @@ jobs:
# queries: security-extended,security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@54f647b7e1bb85c95cddabcd46b0c578ec92bc1a # v4.36.3
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
category: "/language:${{matrix.language}}"

View File

@@ -47,7 +47,7 @@ jobs:
steps:
- id: set_matrix
run: |
MATRIX_CONFIG=$(if [ "${{ github.event_name }}" == "pull_request" ]; then echo '["dev", "lean"]'; else echo '["dev", "lean", "websocket", "dockerize", "py311", "py312"]'; fi)
MATRIX_CONFIG=$(if [ "${{ github.event_name }}" == "pull_request" ]; then echo '["dev", "lean"]'; else echo '["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]'; fi)
echo "matrix_config=${MATRIX_CONFIG}" >> $GITHUB_OUTPUT
echo $GITHUB_OUTPUT
@@ -123,20 +123,13 @@ jobs:
# pull timeouts, 504/401 on push, ECONNRESET) that otherwise fail
# the whole job. buildx reuses the buildkit layer cache from the
# failed attempt, so a retry mostly re-does just the failed push.
#
# supersetbot's "dev"/"lean" presets pin their own --build-arg
# PY_VER, which lands ahead of --extra-flags on the assembled
# buildx command line; docker/buildx keeps the last value for a
# repeated --build-arg key, so appending PY_VER here overrides
# supersetbot's pin and keeps the build on the Dockerfile's own
# supported Python version.
for attempt in 1 2 3; do
if supersetbot docker \
$PUSH_OR_LOAD \
--preset "$BUILD_PRESET" \
--context "$EVENT" \
--context-ref "$RELEASE" $FORCE_LATEST \
--extra-flags "--build-arg PY_VER=3.11.14-slim-trixie --build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
--extra-flags "--build-arg INCLUDE_CHROMIUM=false --tag $IMAGE_TAG" \
$PLATFORM_ARG; then
break
fi

View File

@@ -7,6 +7,11 @@ on:
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
# Nightly full-tree sweep. Per-PR runs only lint changed files, so a change
# that invalidates an untouched file (e.g. a type change that breaks an
# importing test) can pass every PR yet leave master red. This catches that.
schedule:
- cron: "0 6 * * *"
permissions:
contents: read
@@ -32,6 +37,9 @@ jobs:
with:
persist-credentials: false
submodules: recursive
# Full history so we can diff a PR/push against its base commit to
# determine changed files (see "Determine changed files" below).
fetch-depth: 0
- name: Setup Python
uses: ./.github/actions/setup-backend/
@@ -69,19 +77,82 @@ jobs:
restore-keys: |
pre-commit-v2-${{ runner.os }}-py${{ matrix.python-version }}-
- name: Get changed files
- name: Determine changed files
id: changed_files
uses: ./.github/actions/file-changes-action
with:
output: " "
env:
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
BEFORE_SHA: ${{ github.event.before }}
run: |
set -euo pipefail
# Scheduled runs check the whole tree (see the pre-commit step).
if [ "${EVENT_NAME}" = "schedule" ]; then
echo "mode=all" >> "$GITHUB_OUTPUT"
exit 0
fi
# Resolve the commit to diff against.
base=""
if [ "${EVENT_NAME}" = "pull_request" ]; then
base="${BASE_SHA}"
elif [ -n "${BEFORE_SHA:-}" ] && \
[ "${BEFORE_SHA}" != "0000000000000000000000000000000000000000" ]; then
base="${BEFORE_SHA}"
fi
# Fail closed: if the diff base can't be resolved, check every file
# instead of silently checking nothing. Previously an empty file list
# made `pre-commit run --files` a no-op that still reported success,
# which let unlinted code reach master.
if [ -z "${base}" ] || ! git cat-file -e "${base}^{commit}" 2>/dev/null; then
echo "::notice::Could not resolve a diff base; falling back to --all-files."
echo "mode=all" >> "$GITHUB_OUTPUT"
exit 0
fi
# Files present in HEAD that changed since the base (drop deletions).
files="$(git diff --name-only --diff-filter=ACMRT "${base}...HEAD")"
if [ -z "${files}" ]; then
echo "mode=none" >> "$GITHUB_OUTPUT"
else
echo "mode=files" >> "$GITHUB_OUTPUT"
{
echo "files<<__CHANGED_FILES_EOF__"
echo "${files}"
echo "__CHANGED_FILES_EOF__"
} >> "$GITHUB_OUTPUT"
fi
- name: pre-commit
env:
MODE: ${{ steps.changed_files.outputs.mode }}
CHANGED_FILES: ${{ steps.changed_files.outputs.files }}
run: |
set +e # Don't exit immediately on failure
export SKIP=type-checking-frontend
pre-commit run --files $CHANGED_FILES
case "${MODE}" in
all)
echo " Running pre-commit on all files."
pre-commit run --all-files
;;
files)
echo " Running pre-commit on changed files:"
echo "${CHANGED_FILES}"
# shellcheck disable=SC2086
pre-commit run --files ${CHANGED_FILES}
;;
none)
echo " No source files changed; nothing for pre-commit to check."
exit 0
;;
*)
echo "⚠️ Unrecognized changed-files mode '${MODE}'; checking all files."
pre-commit run --all-files
;;
esac
PRE_COMMIT_EXIT_CODE=$?
git diff --quiet --exit-code
GIT_DIFF_EXIT_CODE=$?

View File

@@ -98,7 +98,7 @@ jobs:
# Mirror the same matrix the release publisher uses so every variant
# operators consume from Docker Hub gets the refreshed base.
matrix:
build_preset: ["dev", "lean", "websocket", "dockerize", "py311", "py312"]
build_preset: ["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout release tag: ${{ needs.config.outputs.latest-release }}"

View File

@@ -51,7 +51,7 @@ jobs:
git show -s --format=raw HEAD
docker buildx build \
-t $TAG \
--cache-from=type=registry,ref=apache/superset-cache:3.11-slim-trixie \
--cache-from=type=registry,ref=apache/superset-cache:3.10-slim-trixie \
--target superset-node-ci \
.

View File

@@ -56,7 +56,7 @@ jobs:
strategy:
matrix:
build_preset:
["dev", "lean", "websocket", "dockerize", "py311", "py312"]
["dev", "lean", "py310", "websocket", "dockerize", "py311", "py312"]
fail-fast: false
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"

3
.gitmodules vendored
View File

@@ -21,9 +21,6 @@
[submodule ".github/actions/pr-lint-action"]
path = .github/actions/pr-lint-action
url = https://github.com/morrisoncole/pr-lint-action
[submodule ".github/actions/file-changes-action"]
path = .github/actions/file-changes-action
url = https://github.com/trilom/file-changes-action
[submodule ".github/actions/cached-dependencies"]
path = .github/actions/cached-dependencies
url = https://github.com/apache-superset/cached-dependencies

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.11-slim-trixie
FROM python:3.10-slim-trixie
RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.11-slim-trixie
FROM python:3.10-slim-trixie
RUN useradd --user-group --create-home --no-log-init --shell /bin/bash superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.11-slim-trixie
FROM python:3.10-slim-trixie
ARG VERSION
RUN git clone --depth 1 --branch ${VERSION} https://github.com/apache/superset.git /superset

View File

@@ -14,7 +14,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
FROM python:3.11-slim-trixie
FROM python:3.10-slim-trixie
RUN apt-get update -y
RUN apt-get install -y \

View File

@@ -24,13 +24,6 @@ assists people when migrating to a new version.
## Next
### Python 3.10 support removed
Python 3.10 is no longer supported. Superset now requires **Python 3.11 or higher**.
Update your environment (virtualenv, Docker base image, CI configuration, etc.) to
Python 3.11+ before upgrading. The `apache/superset-cache:3.10-slim-trixie` and
`py310` Docker image variants are no longer published.
### Owners, dashboard roles, and RLS roles replaced by Subjects
Superset now uses subject-based access assignments for dashboards, charts, datasets,

View File

@@ -71,7 +71,7 @@ x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
cache_from:
- apache/superset-cache:3.11-slim-trixie
- apache/superset-cache:3.10-slim-trixie
args:
DEV_MODE: "true"
INCLUDE_CHROMIUM: ${INCLUDE_CHROMIUM:-false}

View File

@@ -33,7 +33,7 @@ x-common-build: &common-build
context: .
target: dev
cache_from:
- apache/superset-cache:3.11-slim-trixie
- apache/superset-cache:3.10-slim-trixie
services:
redis:

View File

@@ -38,7 +38,7 @@ x-common-build: &common-build
context: .
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
cache_from:
- apache/superset-cache:3.11-slim-trixie
- apache/superset-cache:3.10-slim-trixie
args:
DEV_MODE: "true"
INCLUDE_CHROMIUM: ${INCLUDE_CHROMIUM:-false}

View File

@@ -109,6 +109,7 @@ FEATURE_FLAGS = {
"ALERT_REPORTS": True,
"DATASET_FOLDERS": True,
"ENABLE_EXTENSIONS": True,
"MOBILE_CONSUMPTION_MODE": True,
"SEMANTIC_LAYERS": True,
}
EXTENSIONS_PATH = "/app/docker/extensions"

View File

@@ -135,7 +135,7 @@ Superset sends an HTTP POST with `Content-Type: application/json`:
}
```
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
When a report includes file attachments (CSV, Excel, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
```
POST /webhook HTTP/1.1

View File

@@ -99,7 +99,7 @@ See [Connecting AI Clients](#connecting-ai-clients) for Claude Code, Claude Web,
## Prerequisites
- Apache Superset 5.0+ running and accessible
- Python 3.11+
- Python 3.10+
- The `fastmcp` package (`pip install fastmcp`)
---

View File

@@ -379,7 +379,7 @@ functioning across environments.
Make sure your machine meets the [OS dependencies](https://superset.apache.org/docs/installation/pypi#os-dependencies) before following these steps.
You also need to install MySQL.
Ensure that you are using Python version 3.11 or 3.12, then proceed with:
Ensure that you are using Python version 3.9, 3.10 or 3.11, then proceed with:
```bash
# Create a virtual environment and activate it (recommended)

View File

@@ -82,7 +82,7 @@ If applicable, add screenshots or recordings.
### Environment
- Superset version: [e.g., 3.0.0]
- Python version: [e.g., 3.11.7]
- Python version: [e.g., 3.9.7]
- Node version: [e.g., 18.17.0]
- Database: [e.g., PostgreSQL 14]
- Browser: [e.g., Chrome 120]
@@ -125,7 +125,7 @@ No error messages in browser console or server logs.
### Environment
- Superset version: 3.0.0
- Python version: 3.11.16
- Python version: 3.9.16
- Database: PostgreSQL 14.9
- Browser: Chrome 120.0.6099.71
- OS: macOS 14.2

View File

@@ -52,7 +52,7 @@ Everything you need to contribute to the Apache Superset project. This section i
## Development Resources
### Prerequisites
- **Python**: 3.11 or 3.12
- **Python**: 3.9, 3.10, or 3.11
- **Node.js**: 18.x or 20.x
- **npm**: 9.x or 10.x
- **Git**: Basic understanding

View File

@@ -69,6 +69,12 @@
"lifecycle": "development",
"description": "Enable Matrixify feature for matrix-style chart layouts"
},
{
"name": "MOBILE_CONSUMPTION_MODE",
"default": false,
"lifecycle": "development",
"description": "Serve a consumption-only mobile experience (dashboards, dashboard list, and home page) on small screens; other views show a \"not supported on mobile\" screen. Authoring features are hidden on mobile when enabled."
},
{
"name": "OPTIMIZE_SQL",
"default": false,

View File

@@ -24,12 +24,13 @@ name = "apache_superset"
description = "A modern, enterprise-ready business intelligence web application"
readme = "README.md"
dynamic = ["version", "scripts", "entry-points"]
requires-python = ">=3.11"
requires-python = ">=3.10"
license = { file="LICENSE.txt" }
authors = [
{ name = "Apache Software Foundation", email = "dev@superset.apache.org" },
]
classifiers = [
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
]
@@ -44,7 +45,7 @@ dependencies = [
# without the ``base.txt`` lock file (#40962).
"cachetools>=7.1.4, <8",
"celery>=5.6.3, <6.0.0",
"click>=8.4.0",
"click>=8.4.2",
"click-option-group",
"colorama",
"flask-cors>=6.0.5, <7.0",
@@ -129,7 +130,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.17.0",
"google-cloud-bigquery>=3.42.1",
]
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
clickhouse = ["clickhouse-connect>=1.4.2, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.41.0, <1"]
d1 = [
@@ -154,7 +155,7 @@ elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
exasol = ["sqlalchemy-exasol>=2.4.0, <8.0"]
excel = ["xlrd>=2.0.2, <2.1"]
fastmcp = [
"fastmcp>=3.4.2,<4.0",
"fastmcp>=3.4.3,<4.0",
# tiktoken backs the response-size-guard token estimator. Without
# it, the middleware falls back to a coarser character-based
# heuristic that under-counts JSON-heavy MCP responses.
@@ -164,8 +165,9 @@ firebird = ["sqlalchemy-firebird>=0.8.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=26.4.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
hana = ["hdbcli==2.28.21", "sqlalchemy_hana==3.0.3"]
hana = ["hdbcli==2.29.23", "sqlalchemy_hana==3.0.3"]
hive = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7.0",
"tableschema",
"thrift>=0.23.0, <1.0.0",
@@ -190,15 +192,16 @@ pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.61.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.337.0"]
trino = ["trino>=0.338.0"]
prophet = ["prophet>=1.1.6, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
sqlite = ["syntaqlite>=0.6.0,<0.7.0"]
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
spark = [
"pyhive[hive]>=0.6.5;python_version<'3.11'",
"pyhive[hive_pure_sasl]>=0.7",
"tableschema",
"thrift>=0.23.0, <1",
@@ -212,7 +215,7 @@ thumbnails = [] # deprecated, will be removed in 7.0
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
netezza = ["nzalchemy>=11.0.2, < 11.2"]
starrocks = ["starrocks>=1.3.3, <2"]
doris = ["pydoris>=1.0.0, <2.0.0"]
doris = ["pydoris>=1.2.0, <2.0.0"]
oceanbase = ["oceanbase_py>=0.0.1.2"]
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
development = [
@@ -241,7 +244,7 @@ development = [
"ruff",
"sqloxide",
"statsd",
"syntaqlite>=0.6.0,<0.7.0",
"syntaqlite>=0.7.0,<0.8.0",
]
[project.urls]
@@ -341,8 +344,8 @@ exclude = [
line-length = 88
indent-width = 4
# Assume Python 3.11
target-version = "py311"
# Assume Python 3.10
target-version = "py310"
[tool.ruff.lint]
# Enable Pyflakes (`F`) and a subset of the pycodestyle (`E`) codes by default.

View File

@@ -40,7 +40,7 @@ filterwarnings =
error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
error:The connection.execute\(\) method:sqlalchemy.exc.RemovedIn20Warning
# error:The current statement is being autocommitted using implicit autocommit:sqlalchemy.exc.RemovedIn20Warning
# error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
error:The ``declarative_base\(\)`` function is now available:sqlalchemy.exc.RemovedIn20Warning
error:The Engine.execute\(\) method is considered legacy:sqlalchemy.exc.RemovedIn20Warning
error:The legacy calling style of select\(\) is deprecated:sqlalchemy.exc.RemovedIn20Warning
error:The "whens" argument to case:sqlalchemy.exc.RemovedIn20Warning

View File

@@ -60,7 +60,7 @@ cffi==2.0.0
# pynacl
charset-normalizer==3.4.2
# via requests
click==8.4.1
click==8.4.2
# via
# apache-superset (pyproject.toml)
# celery

View File

@@ -130,7 +130,7 @@ charset-normalizer==3.4.2
# via
# -c requirements/base-constraint.txt
# requests
click==8.4.1
click==8.4.2
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -238,9 +238,9 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp-slim
fastmcp==3.4.2
fastmcp==3.4.4
# via apache-superset
fastmcp-slim==3.4.2
fastmcp-slim==3.4.4
# via fastmcp
filelock==3.20.3
# via
@@ -1017,7 +1017,7 @@ starlette==1.3.1
# mcp
statsd==4.0.1
# via apache-superset
syntaqlite==0.6.0
syntaqlite==0.7.1
# via apache-superset
tabulate==0.10.0
# via
@@ -1033,7 +1033,7 @@ tqdm==4.67.1
# via
# cmdstanpy
# prophet
trino==0.337.0
trino==0.338.0
# via apache-superset
trio==0.33.0
# via

View File

@@ -133,8 +133,8 @@ def main(docker: bool, frontend: bool, backend: bool) -> None: # noqa: C901
requirements = [
Requirement(
"python",
(Version("3.11.0"), Version("3.11.999")),
(Version("3.11.0"), Version("3.12.999")),
(Version("3.10.0"), Version("3.10.999")),
(Version("3.9.0"), Version("3.11.999")),
"backend",
"python --version",
),

View File

@@ -21,7 +21,7 @@ under the License.
[![PyPI version](https://badge.fury.io/py/apache-superset-core.svg)](https://badge.fury.io/py/apache-superset-core)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
The official core package for building Apache Superset backend extensions and integrations. This package provides essential building blocks including base classes, API utilities, type definitions, and decorators for both the host application and extensions.

View File

@@ -26,7 +26,7 @@ authors = [
]
license = "Apache-2.0"
license-files = ["LICENSE.txt"]
requires-python = ">=3.11"
requires-python = ">=3.10"
keywords = ["superset", "apache", "analytics", "business-intelligence", "extensions", "visualization"]
classifiers = [
"Development Status :: 3 - Alpha",
@@ -34,6 +34,7 @@ classifiers = [
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Database",

View File

@@ -21,7 +21,7 @@ under the License.
[![PyPI version](https://badge.fury.io/py/apache-superset-extensions-cli.svg)](https://badge.fury.io/py/apache-superset-extensions-cli)
[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0)
[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
Official command-line interface for building, bundling, and managing Apache Superset extensions. This CLI tool provides developers with everything needed to create, develop, and package extensions for the Superset ecosystem.

View File

@@ -25,7 +25,7 @@ authors = [
]
license = "Apache-2.0"
license-files = ["LICENSE.txt"]
requires-python = ">=3.11"
requires-python = ">=3.10"
keywords = ["superset", "apache", "cli", "extensions", "analytics", "business-intelligence", "development-tools"]
classifiers = [
"Development Status :: 3 - Alpha",
@@ -33,6 +33,7 @@ classifiers = [
"Intended Audience :: Developers",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.10",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Topic :: Database",

View File

@@ -17,45 +17,38 @@
* under the License.
*/
import { Component, ReactNode } from 'react';
import { useState, useCallback, ReactNode } from 'react';
export type Props = {
children: ReactNode;
expandableWhat?: string;
};
type State = {
open: boolean;
};
export default function Expandable({ children, expandableWhat }: Props) {
const [open, setOpen] = useState(false);
export default class Expandable extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { open: false };
this.handleToggle = this.handleToggle.bind(this);
}
const handleToggle = useCallback(() => {
setOpen(prevOpen => !prevOpen);
}, []);
handleToggle() {
this.setState(({ open }) => ({ open: !open }));
}
const label = expandableWhat
? `${open ? 'Hide' : 'Show'} ${expandableWhat}`
: open
? 'Hide'
: 'Show';
render() {
const { open } = this.state;
const { children, expandableWhat } = this.props;
return (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={this.handleToggle}
>
{`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
</button>
<br />
<br />
{open ? children : null}
</div>
);
}
return (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleToggle}
>
{label}
</button>
<br />
<br />
{open ? children : null}
</div>
);
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { Component, ReactNode } from 'react';
import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { t } from '@apache-superset/core/translation';
import {
SupersetClient,
@@ -36,12 +36,6 @@ export type Props = {
postPayload?: string;
};
type State = {
didVerify: boolean;
error?: Error | SupersetApiError;
payload?: object;
};
export const renderError = (error: Error) => (
<div>
The following error occurred, make sure you have <br />
@@ -54,29 +48,37 @@ export const renderError = (error: Error) => (
</div>
);
export default class VerifyCORS extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { didVerify: false };
this.handleVerify = this.handleVerify.bind(this);
}
export default function VerifyCORS({
children,
endpoint,
host,
method,
postPayload,
}: Props): JSX.Element {
const [didVerify, setDidVerify] = useState(false);
const [error, setError] = useState<Error | SupersetApiError | undefined>(
undefined,
);
const [payload, setPayload] = useState<object | undefined>(undefined);
componentDidUpdate(prevProps: Props) {
const { endpoint, host, postPayload, method } = this.props;
const prevPropsRef = useRef({ endpoint, host, postPayload, method });
useEffect(() => {
const prevProps = prevPropsRef.current;
if (
(this.state.didVerify || this.state.error) &&
(didVerify || error) &&
(prevProps.endpoint !== endpoint ||
prevProps.host !== host ||
prevProps.postPayload !== postPayload ||
prevProps.method !== method)
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ didVerify: false, error: undefined });
setDidVerify(false);
setError(undefined);
}
}
prevPropsRef.current = { endpoint, host, postPayload, method };
}, [endpoint, host, postPayload, method, didVerify, error]);
handleVerify() {
const { endpoint, host, postPayload, method } = this.props;
const handleVerify = useCallback(() => {
SupersetClient.reset();
SupersetClient.configure({
credentials: 'include',
@@ -94,43 +96,40 @@ export default class VerifyCORS extends Component<Props, State> {
}
return { error: 'Must provide valid endpoint and payload.' };
})
.then(result =>
this.setState({ didVerify: true, error: undefined, payload: result }),
)
.catch(error => this.setState({ error }));
}
.then(result => {
setDidVerify(true);
setError(undefined);
setPayload(result);
})
.catch(err => setError(err));
}, [endpoint, host, method, postPayload]);
render() {
const { didVerify, error, payload } = this.state;
const { children } = this.props;
return didVerify ? (
children({ payload })
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={this.handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
</div>
)}
return didVerify ? (
<>{children({ payload })}</>
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
);
}
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
</div>
)}
</div>
);
}

View File

@@ -201,7 +201,7 @@
"jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }],
"jsx-a11y/aria-unsupported-elements": "error",
"jsx-a11y/autocomplete-valid": "error",
"jsx-a11y/click-events-have-key-events": "off",
"jsx-a11y/click-events-have-key-events": "error",
"jsx-a11y/control-has-associated-label": "error",
"jsx-a11y/heading-has-content": "error",
"jsx-a11y/html-has-lang": "error",

View File

@@ -202,7 +202,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",
@@ -12266,9 +12266,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
"version": "26.1.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.3.0"
@@ -42324,9 +42324,9 @@
}
},
"node_modules/websocket-driver": {
"version": "0.7.4",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz",
"integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==",
"version": "0.7.5",
"resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.5.tgz",
"integrity": "sha512-ZL2+3c7kMBdIRCMz6l8jQMHyGVxj+UL+xVk74Ombiciboca8rHa15L86B19E5oh1pL9Ii/uj54gtsIrZGMo6zA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -43691,7 +43691,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -287,7 +287,7 @@
"@types/json-bigint": "^1.0.4",
"@types/lodash-es": "^4.17.12",
"@types/mousetrap": "^1.6.15",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react-loadable": "^5.5.11",

View File

@@ -78,7 +78,7 @@
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",
"@types/lodash": "^4.17.24",
"@types/node": "^26.1.0",
"@types/node": "^26.1.1",
"@types/prop-types": "^15.7.15",
"@types/react-syntax-highlighter": "^15.5.13",
"@types/react-table": "^7.7.20",

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { render, screen, userEvent, fireEvent } from '@superset-ui/core/spec';
import { Icons } from '@superset-ui/core/components/Icons';
import { ActionButton } from '.';
@@ -45,6 +45,18 @@ test('calls onClick when clicked', async () => {
expect(onClick).toHaveBeenCalledTimes(1);
});
test('calls onClick when activated with the keyboard', () => {
const onClick = jest.fn();
render(<ActionButton {...defaultProps} onClick={onClick} />);
const button = screen.getByRole('button');
fireEvent.keyDown(button, { key: 'Enter' });
expect(onClick).toHaveBeenCalledTimes(1);
fireEvent.keyDown(button, { key: ' ' });
expect(onClick).toHaveBeenCalledTimes(2);
});
test('renders with tooltip when tooltip prop is provided', async () => {
const tooltipText = 'This is a tooltip';
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { handleKeyboardActivation } from '../../utils';
import type { ReactElement, ReactNode } from 'react';
import { Tooltip, type TooltipPlacement } from '@superset-ui/core/components';
import { css, useTheme } from '@apache-superset/core/theme';
@@ -55,6 +56,7 @@ export const ActionButton = ({
className="action-button"
data-test={label}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
>
{icon}
</span>

View File

@@ -16,12 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '../../utils';
import {
forwardRef,
ForwardedRef,
useState,
ReactNode,
MouseEvent,
SyntheticEvent,
} from 'react';
import { Button } from '../Button';
@@ -96,7 +97,7 @@ export const ModalTrigger = forwardRef(
onExit?.();
};
const open = (e: MouseEvent) => {
const open = (e: SyntheticEvent) => {
e.preventDefault();
beforeOpen?.();
setShowModal(true);
@@ -126,7 +127,13 @@ export const ModalTrigger = forwardRef(
</Button>
)}
{!isButton && (
<div data-test="span-modal-trigger" onClick={open} role="button">
<div
data-test="span-modal-trigger"
onClick={open}
onKeyDown={handleKeyboardActivation(open)}
role="button"
tabIndex={0}
>
{triggerNode}
</div>
)}

View File

@@ -20,6 +20,7 @@ import { ReactNode, ReactElement, memo } from 'react';
import { t } from '@apache-superset/core/translation';
import { css, SupersetTheme, useTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { FeatureFlag, isFeatureEnabled } from '../../utils/featureFlags';
import type { DropdownProps } from '../Dropdown/types';
import type { TooltipPlacement } from '../Tooltip/types';
import type { CertifiedBadgeProps } from '../CertifiedBadge/types';
@@ -82,6 +83,18 @@ const headerStyles = (theme: SupersetTheme) => css`
display: flex;
align-items: center;
}
/* Mobile consumption mode: center the title between left/right panels */
${isFeatureEnabled(FeatureFlag.MobileConsumptionMode) &&
css`
@media (max-width: ${theme.screenSMMax}px) {
.title-panel {
flex: 1;
justify-content: center;
margin-right: 0;
}
}
`}
`;
const buttonsStyles = (theme: SupersetTheme) => css`
@@ -109,6 +122,7 @@ export type PageHeaderWithActionsProps = {
showFaveStar: boolean;
showMenuDropdown?: boolean;
faveStarProps: FaveStarProps;
leftPanelItems?: ReactNode;
titlePanelAdditionalItems: ReactNode;
rightPanelAdditionalItems: ReactNode;
additionalActionsMenu: ReactElement;
@@ -126,6 +140,7 @@ export const PageHeaderWithActions = memo(
certificatiedBadgeProps,
showFaveStar,
faveStarProps,
leftPanelItems,
titlePanelAdditionalItems,
rightPanelAdditionalItems,
additionalActionsMenu,
@@ -136,6 +151,7 @@ export const PageHeaderWithActions = memo(
const theme = useTheme();
return (
<div css={headerStyles} className="header-with-actions">
{leftPanelItems}
<div className="title-panel">
<DynamicEditableTitle {...editableTitleProps} />
{showTitlePanelItems && (

View File

@@ -16,7 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import { MouseEventHandler, ReactNode } from 'react';
import { handleKeyboardActivation } from '../../utils';
import { ReactNode, SyntheticEvent } from 'react';
import { css, useTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import { Tooltip } from '../Tooltip';
@@ -24,7 +25,10 @@ import { Tooltip } from '../Tooltip';
export interface PopoverSectionProps {
title: string;
isSelected?: boolean;
onSelect?: MouseEventHandler<HTMLDivElement>;
// `SyntheticEvent` (rather than `MouseEventHandler`) so the same callback
// can be reused as the keyboard-activation handler via
// `handleKeyboardActivation`, which invokes it with a `KeyboardEvent`.
onSelect?: (event: SyntheticEvent) => void;
info?: string;
children?: ReactNode;
}
@@ -48,6 +52,7 @@ export default function PopoverSection({
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={onSelect ? handleKeyboardActivation(onSelect) : undefined}
css={css`
display: flex;
align-items: center;

View File

@@ -17,126 +17,108 @@
* under the License.
*/
import { PureComponent } from 'react';
import { useState, useCallback } from 'react';
import { formatNumber } from '@superset-ui/core';
interface NumberFormatValidatorState {
formatString: string;
testValues: (number | null | undefined)[];
}
const testValues: (number | null | undefined)[] = [
987654321,
12345.6789,
3000,
400.14,
70.00002,
1,
0,
-1,
-70.00002,
-400.14,
-3000,
-12345.6789,
-987654321,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
NaN,
null,
undefined,
];
class NumberFormatValidator extends PureComponent<
Record<string, never>,
NumberFormatValidatorState
> {
state: NumberFormatValidatorState = {
formatString: '.3~s',
testValues: [
987654321,
12345.6789,
3000,
400.14,
70.00002,
1,
0,
-1,
-70.00002,
-400.14,
-3000,
-12345.6789,
-987654321,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
NaN,
null,
undefined,
],
};
function NumberFormatValidator() {
const [formatString, setFormatString] = useState('.3~s');
constructor(props: Record<string, never>) {
super(props);
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
this.handleFormatChange = this.handleFormatChange.bind(this);
}
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format
string. See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format string.
See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
);
}
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default {

View File

@@ -17,115 +17,96 @@
* under the License.
*/
import { PureComponent } from 'react';
import { useState, useCallback } from 'react';
import { formatTime } from '@superset-ui/core';
interface TimeFormatValidatorState {
formatString: string;
testValues: (Date | number | null | undefined)[];
}
const testValues: (Date | number | null | undefined)[] = [
new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
0,
null,
undefined,
];
class TimeFormatValidator extends PureComponent<
Record<string, never>,
TimeFormatValidatorState
> {
state: TimeFormatValidatorState = {
formatString: '%Y-%m-%d %H:%M:%S',
testValues: [
new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
0,
null,
undefined,
],
};
function TimeFormatValidator() {
const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S');
constructor(props: Record<string, never>) {
super(props);
this.handleFormatChange = this.handleFormatChange.bind(this);
}
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>
{v instanceof Date ? v.toUTCString() : `${v}`}
</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
);
}
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{v instanceof Date ? v.toUTCString() : `${v}`}</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default {

View File

@@ -59,6 +59,7 @@ export enum FeatureFlag {
GranularExportControls = 'GRANULAR_EXPORT_CONTROLS',
ListviewsDefaultCardView = 'LISTVIEWS_DEFAULT_CARD_VIEW',
Matrixify = 'MATRIXIFY',
MobileConsumptionMode = 'MOBILE_CONSUMPTION_MODE',
ScheduledQueries = 'SCHEDULED_QUERIES',
SemanticLayers = 'SEMANTIC_LAYERS',
SqllabBackendPersistence = 'SQLLAB_BACKEND_PERSISTENCE',

View File

@@ -0,0 +1,62 @@
/**
* 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 { KeyboardEvent } from 'react';
import { handleKeyboardActivation } from './handleKeyboardActivation';
const makeEvent = (key: string, repeat = false) => {
const preventDefault = jest.fn();
return {
event: { key, repeat, preventDefault } as unknown as KeyboardEvent,
preventDefault,
};
};
test('invokes the callback and prevents default on Enter', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('Enter');
handleKeyboardActivation(callback)(event);
expect(callback).toHaveBeenCalledWith(event);
expect(preventDefault).toHaveBeenCalled();
});
test('invokes the callback and prevents default on Space', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent(' ');
handleKeyboardActivation(callback)(event);
expect(callback).toHaveBeenCalledWith(event);
expect(preventDefault).toHaveBeenCalled();
});
test('ignores other keys', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('a');
handleKeyboardActivation(callback)(event);
expect(callback).not.toHaveBeenCalled();
expect(preventDefault).not.toHaveBeenCalled();
});
test('ignores auto-repeat keydown events fired while a key is held', () => {
const callback = jest.fn();
const { event, preventDefault } = makeEvent('Enter', true);
handleKeyboardActivation(callback)(event);
expect(callback).not.toHaveBeenCalled();
// preventDefault still fires on the repeat event itself, matching the
// non-repeat Enter case above.
expect(preventDefault).toHaveBeenCalled();
});

View File

@@ -0,0 +1,48 @@
/**
* 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 { KeyboardEvent } from 'react';
/**
* Builds an `onKeyDown` handler that invokes `callback` when the user presses
* Enter or Space, mirroring a click for keyboard users. Pair it with an
* element's `onClick` so `role="button"` (or similar) controls are operable
* from the keyboard, satisfying `jsx-a11y/click-events-have-key-events`.
*
* <div role="button" onClick={handleClick}
* onKeyDown={handleKeyboardActivation(handleClick)} />
*/
export function handleKeyboardActivation(
callback: (event: KeyboardEvent) => void,
) {
return (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
// Prevent the page from scrolling on Space and stop any duplicate
// default activation on Enter.
event.preventDefault();
// Ignore auto-repeat keydown events fired while the key is held, so
// a long press activates the callback once, matching a mouse click.
if (event.repeat) {
return;
}
callback(event);
}
};
}
export default handleKeyboardActivation;

View File

@@ -27,6 +27,7 @@ export { default as makeSingleton } from './makeSingleton';
export { default as promiseTimeout } from './promiseTimeout';
export { default as removeDuplicates } from './removeDuplicates';
export { default as withLabel } from './withLabel';
export { handleKeyboardActivation } from './handleKeyboardActivation';
export { lruCache } from './lruCache';
export { getSelectedText } from './getSelectedText';
export * from './featureFlags';

View File

@@ -0,0 +1,326 @@
/**
* 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 { TIMEOUT } from '../../utils/constants';
/**
* 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'];
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('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
if (cardCount > 0) {
await expect(cards.first()).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
} else {
// No dashboards - that's OK for the test environment
await expect(
page
.getByText('No dashboards yet')
.or(page.locator('[data-test="listview-table"]')),
).toBeVisible({ timeout: TIMEOUT.PAGE_LOAD });
}
});
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 }) => {
const page = await browser.newPage({
viewport: mobileViewport.viewport,
userAgent: mobileViewport.userAgent,
});
await page.goto('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 }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard to load
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// 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 }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// 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 }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Open the actions menu
const menuButton = page
.locator('[data-test="actions-trigger"]')
.or(page.locator('[aria-label="Menu actions trigger"]'));
if ((await menuButton.count()) > 0) {
await menuButton.first().click();
// Look for refresh option
const refreshOption = page.getByText('Refresh dashboard');
if ((await refreshOption.count()) > 0) {
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 }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Give filters time to load
await page.waitForTimeout(2000);
// Check for filter button (only visible if dashboard has filters)
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();
// The test passes whether filters exist or not
// If filters exist, button should be visible
// If no filters, that's also valid
if (filterCount > 0) {
await expect(filterButton.first()).toBeVisible();
}
}
});
test('filter drawer opens when filter button is tapped', async ({ page }) => {
// Navigate to dashboard list
await page.goto('dashboard/list/');
await page.waitForLoadState('networkidle');
// Click first dashboard
const cards = page.locator('[data-test="styled-card"]');
const cardCount = await cards.count();
if (cardCount > 0) {
await cards.first().click();
// Wait for dashboard
await page.waitForURL(url => /\/dashboard\/(?!list)/.test(url.pathname), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Give filters time to load
await page.waitForTimeout(2000);
// Check for filter button
const filterButton = page
.locator('[data-test="filter-icon"]')
.or(page.locator('[aria-label="Filters"]'));
if ((await filterButton.count()) > 0) {
await filterButton.first().click();
// Filter drawer should open
const drawer = page
.locator('.ant-drawer-open')
.or(page.locator('[data-test="filter-bar"]'));
await expect(drawer.first()).toBeVisible({
timeout: TIMEOUT.FORM_LOAD,
});
}
}
});
});

View File

@@ -0,0 +1,221 @@
/**
* 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 }) => {
// Clear any previous bypass flags
await page.goto('/');
await page.evaluate(() => {
try {
sessionStorage.removeItem('mobile-bypass');
} catch {
// Ignore storage errors
}
});
});
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('Continue anyway bypasses guard for the session', 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 Continue anyway
await page.getByText('Continue anyway').click();
// Should navigate to the original destination
await page.waitForURL(url => url.pathname.includes('chart/list'), {
timeout: TIMEOUT.PAGE_LOAD,
});
// Verify bypass is set in sessionStorage
const bypassValue = await page.evaluate(() =>
sessionStorage.getItem('mobile-bypass'),
);
expect(bypassValue).toBe('true');
// Navigate away and back - should still be bypassed
await page.goto(URL.WELCOME);
await page.goto(URL.CHART_LIST);
// Should NOT show MobileUnsupported page anymore
await expect(
page.getByText("This view isn't available on mobile"),
).not.toBeVisible({ timeout: TIMEOUT.FORM_LOAD });
});
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

@@ -19,7 +19,8 @@
* under the License.
*/
import { useRef, useState, useEffect } from 'react';
import { handleKeyboardActivation } from '@superset-ui/core';
import { useRef, useState, useEffect, SyntheticEvent } from 'react';
import { t } from '@apache-superset/core/translation';
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
import { Column } from '@superset-ui/core/components/ThemedAgGridReact';
@@ -186,7 +187,10 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
return undefined;
}, [lastFilteredColumn, colId, lastFilteredInputPosition]);
const handleMenuClick = (e: React.MouseEvent) => {
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
// used as the keyboard-activation handler via `handleKeyboardActivation`,
// which invokes it with a `KeyboardEvent`.
const handleMenuClick = (e: SyntheticEvent) => {
e.stopPropagation();
setMenuVisible(!isMenuVisible);
};
@@ -201,17 +205,35 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
const menuContent = (
<MenuContainer>
{shouldShowAsc && (
<div onClick={() => applySort('asc')} className="menu-item">
<div
role="button"
tabIndex={0}
onClick={() => applySort('asc')}
onKeyDown={handleKeyboardActivation(() => applySort('asc'))}
className="menu-item"
>
<ArrowUpOutlined /> {t('Sort Ascending')}
</div>
)}
{shouldShowDesc && (
<div onClick={() => applySort('desc')} className="menu-item">
<div
role="button"
tabIndex={0}
onClick={() => applySort('desc')}
onKeyDown={handleKeyboardActivation(() => applySort('desc'))}
className="menu-item"
>
<ArrowDownOutlined /> {t('Sort Descending')}
</div>
)}
{currentSort && currentSort?.colId === colId && (
<div onClick={clearSort} className="menu-item">
<div
role="button"
tabIndex={0}
onClick={clearSort}
onKeyDown={handleKeyboardActivation(clearSort)}
className="menu-item"
>
<span style={{ fontSize: 16 }}></span> {t('Clear Sort')}
</div>
)}
@@ -247,7 +269,13 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
isOpen={isMenuVisible}
onClose={() => setMenuVisible(false)}
>
<div className="three-dots-menu" onClick={handleMenuClick}>
<div
role="button"
tabIndex={0}
className="three-dots-menu"
onClick={handleMenuClick}
onKeyDown={handleKeyboardActivation(handleMenuClick)}
>
<KebabMenu />
</div>
</CustomPopover>

View File

@@ -75,8 +75,12 @@ export default function EchartsMixedTimeseries({
return {
dataMask: {
extraFormData: {
// An empty lookup result means the clicked series could not be
// resolved through the label map; emitting column filters anyway
// would produce bogus `IS NULL` clauses (an empty array makes the
// `every` below vacuously true), so clear the filters instead.
filters:
values.length === 0
values.length === 0 || groupbyValues.length === 0
? []
: currentGroupBy.map((col, idx) => {
const val: DataRecordValue[] = groupbyValues.map(v => {
@@ -148,10 +152,11 @@ export default function EchartsMixedTimeseries({
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
const isFirst = isFirstQuery(seriesIndex);
const values = [
...(eventParams.name ? [eventParams.name] : []),
...((isFirst ? labelMap : labelMapB)[eventParams.seriesName] || []),
];
const currentGroupBy = isFirst ? formData.groupby : formData.groupbyB;
const seriesValues = (isFirst ? labelMap : labelMapB)[seriesName] || [];
// Label map values may carry metric/offset labels ahead of the
// dimension values — anchor from the tail, like getCrossFilterDataMask.
const metricsCount = seriesValues.length - currentGroupBy.length;
if (data && xAxis.type === AxisType.Time) {
drillToDetailFilters.push({
col:
@@ -164,31 +169,39 @@ export default function EchartsMixedTimeseries({
formattedVal: xValueFormatter(data[0]),
});
}
[
...(data && xAxis.type === AxisType.Category ? [xAxis.label] : []),
...(isFirst ? formData.groupby : formData.groupbyB),
].forEach((dimension, i) =>
if (
data &&
xAxis.type === AxisType.Category &&
eventParams.name != null
) {
drillToDetailFilters.push({
col: dimension,
col: xAxis.label,
op: '==',
val: values[i],
formattedVal: String(values[i]),
}),
);
[...(isFirst ? formData.groupby : formData.groupbyB)].forEach(
(dimension, i) =>
val: eventParams.name,
formattedVal: String(eventParams.name),
});
}
if (metricsCount >= 0) {
currentGroupBy.forEach((dimension, i) => {
const value = seriesValues[metricsCount + i];
drillToDetailFilters.push({
col: dimension,
op: '==',
val: value,
formattedVal: String(value),
});
drillByFilters.push({
col: dimension,
op: '==',
val: values[i],
formattedVal: formatSeriesName(values[i], {
val: value,
formattedVal: formatSeriesName(value, {
timeFormatter: getTimeFormatter(formData.dateFormat),
numberFormatter: getNumberFormatter(formData.numberFormat),
coltype: coltypeMapping?.[getColumnLabel(dimension)],
}),
}),
);
});
});
}
const hasCrossFilter =
(isFirst && groupby.length > 0) || (!isFirst && groupbyB.length > 0);

View File

@@ -146,10 +146,14 @@ export default function transformProps(
columnFormats = {},
currencyCodeColumn,
} = datasource;
const { label_map: labelMap, detected_currency: backendDetectedCurrency } =
// "raw" because these are keyed by the backend column labels; the maps
// returned to the component are re-keyed by the rendered series names below.
const { label_map: rawLabelMap, detected_currency: backendDetectedCurrency } =
queriesData[0] as TimeseriesChartDataResponseResult;
const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } =
queriesData[1] as TimeseriesChartDataResponseResult;
const {
label_map: rawLabelMapB,
detected_currency: backendDetectedCurrencyB,
} = queriesData[1] as TimeseriesChartDataResponseResult;
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
const annotationData = getAnnotationData(chartProps);
@@ -438,6 +442,16 @@ export default function transformProps(
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
const inverted = invert(verboseMap);
// The rendered ECharts series names are display names that can diverge from
// the backend `label_map` keys: the metric display name is prepended when
// dimensions are present, query identifiers may be appended, and verbose
// names replace the raw column labels. Cross-filtering and drill lookups in
// EchartsMixedTimeseries resolve the clicked series name through the label
// map, so expose maps re-keyed by the rendered series names to keep those
// lookups working (#41622).
const displayLabelMap: Record<string, string[]> = {};
const displayLabelMapB: Record<string, string[]> = {};
rawSeriesA.forEach(entry => {
const entryName = String(entry.name || '');
const seriesName = inverted[entryName] || entryName;
@@ -457,13 +471,19 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
}
const labelMapValues = rawLabelMap?.[seriesName];
if (labelMapValues) {
displayLabelMap[displayName] = labelMapValues;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
const seriesFormatter = getFormatter(
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metrics,
labelMap?.[seriesName]?.[0],
labelMapValues?.[0],
!!contributionMode,
);
@@ -514,7 +534,6 @@ export default function transformProps(
rawSeriesB.forEach(entry => {
const entryName = String(entry.name || '');
const seriesEntry = inverted[entryName] || entryName;
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName: string;
@@ -531,13 +550,19 @@ export default function transformProps(
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
}
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
if (labelMapValuesB) {
displayLabelMapB[displayName] = labelMapValuesB;
}
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
const seriesFormatter = getFormatter(
axisFormatterConfig.customFormatters,
axisFormatterConfig.formatter,
metricsB,
labelMapB?.[seriesName]?.[0],
labelMapValuesB?.[0],
!!contributionMode,
);
@@ -809,15 +834,15 @@ export default function transformProps(
.filter(key => keys.includes(key))
.forEach(key => {
const value = forecastValues[key];
// if there are no dimensions, key is a verbose name of a metric,
// otherwise it is a comma separated string where the first part is metric name
// The tooltip key is the rendered series name; resolve it through
// the display-keyed maps, whose values lead with the raw metric
// label both with and without dimensions. Fall back to the
// verbose-name inversion for series absent from the maps.
let formatterKey;
if (primarySeries.has(key)) {
formatterKey =
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
formatterKey = displayLabelMap[key]?.[0] ?? inverted[key];
} else {
formatterKey =
groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0];
formatterKey = displayLabelMapB[key]?.[0] ?? inverted[key];
}
const tooltipFormatter = getFormatter(
customFormatters,
@@ -912,8 +937,8 @@ export default function transformProps(
echartOptions: mergedEchartOptions,
setDataMask,
emitCrossFilters,
labelMap,
labelMapB,
labelMap: displayLabelMap,
labelMapB: displayLabelMapB,
groupby,
groupbyB,
seriesBreakdown: rawSeriesA.length,

View File

@@ -124,7 +124,7 @@ use([
const loadLocale = async (locale: string) => {
let lang;
try {
lang = await import(`echarts/lib/i18n/lang${locale}`);
lang = await import(`echarts/i18n/lang${locale}.js`);
} catch {
// Locale not supported in ECharts
}

View File

@@ -0,0 +1,223 @@
/**
* 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 } from '@testing-library/react';
import {
ChartDataResponseResult,
DataRecord,
VizType,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import transformProps from '../../src/MixedTimeseries/transformProps';
import EchartsMixedTimeseries from '../../src/MixedTimeseries/EchartsMixedTimeseries';
import {
DEFAULT_FORM_DATA,
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps,
} from '../../src/MixedTimeseries/types';
import Echart from '../../src/components/Echart';
import { createEchartsTimeseriesTestChartProps } from '../helpers';
jest.mock('../../src/components/Echart', () => ({
__esModule: true,
default: jest.fn(() => null),
}));
const mockedEchart = jest.mocked(Echart);
const ts1 = 1704067200000;
const ts2 = 1704153600000;
/**
* Backend-shaped fixture: flattened column names keep the metric label, so
* label_map values lead with the metric label ahead of the dimension value.
*/
function createQueryData(): ChartDataResponseResult {
const rows = [
{ ds: ts1, 'sum__num, boy': 1, 'sum__num, girl': 2 },
{ ds: ts2, 'sum__num, boy': 3, 'sum__num, girl': 4 },
];
return {
annotation_data: null,
cache_key: null,
cache_timeout: null,
cached_dttm: null,
queried_dttm: null,
data: rows as DataRecord[],
colnames: ['ds', 'sum__num, boy', 'sum__num, girl'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
error: null,
is_cached: false,
query: '',
rowcount: rows.length,
sql_rowcount: rows.length,
stacktrace: null,
status: 'success',
from_dttm: null,
to_dttm: null,
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'sum__num, girl': ['sum__num', 'girl'],
},
} as ChartDataResponseResult;
}
function setup(
formDataOverrides: Partial<EchartsMixedTimeseriesFormData> = {},
) {
const queryData = createQueryData();
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
defaultFormData: DEFAULT_FORM_DATA as EchartsMixedTimeseriesFormData,
defaultVizType: 'mixed_timeseries',
defaultQueriesData: [queryData, queryData],
formData: {
colorScheme: 'bnbColors',
metrics: ['sum__num'],
metricsB: ['sum__num'],
groupby: ['gender'],
groupbyB: ['gender'],
x_axis: 'ds',
viz_type: VizType.MixedTimeseries,
...formDataOverrides,
},
queriesData: [queryData, queryData],
});
const transformed = transformProps(chartProps);
const setDataMask = jest.fn();
const onContextMenu = jest.fn();
const onFocusedSeries = jest.fn();
render(
<EchartsMixedTimeseries
{...transformed}
setDataMask={setDataMask}
onContextMenu={onContextMenu}
onFocusedSeries={onFocusedSeries}
emitCrossFilters
/>,
);
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
const { eventHandlers } = lastCall[0] as any;
return { eventHandlers, setDataMask, onContextMenu, onFocusedSeries };
}
beforeEach(() => {
mockedEchart.mockClear();
});
test('EchartsMixedTimeseries click emits cross-filter with tail-anchored dimension values', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ seriesName: 'sum__num, boy', seriesIndex: 0 });
expect(setDataMask).toHaveBeenCalledTimes(1);
const dataMask = setDataMask.mock.calls[0][0];
// label_map values are ['sum__num', 'boy'] — the metric label must be
// skipped and the dimension value emitted.
expect(dataMask.extraFormData.filters).toEqual([
{ col: 'gender', op: 'IN', val: ['boy'] },
]);
expect(dataMask.filterState.selectedValues).toEqual(['sum__num, boy']);
});
test('EchartsMixedTimeseries click clears filters when the series misses the label map', () => {
const { eventHandlers, setDataMask } = setup();
eventHandlers.click({ seriesName: 'not a series', seriesIndex: 0 });
expect(setDataMask).toHaveBeenCalledTimes(1);
const dataMask = setDataMask.mock.calls[0][0];
// An unresolvable series must not emit bogus IS NULL filters (#41622).
expect(dataMask.extraFormData.filters).toEqual([]);
expect(dataMask.filterState.value).toBeNull();
});
test('EchartsMixedTimeseries context menu drills with tail-anchored dimension values', async () => {
const { eventHandlers, onContextMenu } = setup();
await eventHandlers.contextmenu({
data: [ts1, 1],
seriesName: 'sum__num, boy',
seriesIndex: 0,
name: '',
event: { stop: jest.fn(), event: { clientX: 11, clientY: 22 } },
});
expect(onContextMenu).toHaveBeenCalledTimes(1);
const [x, y, payload] = onContextMenu.mock.calls[0];
expect(x).toBe(11);
expect(y).toBe(22);
expect(payload.drillToDetail).toEqual([
expect.objectContaining({ col: 'ds', op: '==', val: ts1 }),
expect.objectContaining({
col: 'gender',
op: '==',
val: 'boy',
formattedVal: 'boy',
}),
]);
expect(payload.drillBy.filters).toEqual([
expect.objectContaining({ col: 'gender', op: '==', val: 'boy' }),
]);
expect(payload.drillBy.groupbyFieldName).toBe('groupby');
expect(payload.crossFilter.dataMask.extraFormData.filters).toEqual([
{ col: 'gender', op: 'IN', val: ['boy'] },
]);
});
test('EchartsMixedTimeseries context menu emits the category x-axis filter', async () => {
const { eventHandlers, onContextMenu } = setup({
xAxisForceCategorical: true,
});
await eventHandlers.contextmenu({
data: ['boy-cat', 1],
seriesName: 'sum__num, girl',
seriesIndex: 1,
name: 'boy-cat',
event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
});
const payload = onContextMenu.mock.calls[0][2];
expect(payload.drillToDetail).toEqual([
expect.objectContaining({
col: 'ds',
op: '==',
val: 'boy-cat',
formattedVal: 'boy-cat',
}),
expect.objectContaining({ col: 'gender', op: '==', val: 'girl' }),
]);
});
test('EchartsMixedTimeseries hover focuses and unfocuses the series', () => {
const { eventHandlers, onFocusedSeries } = setup();
eventHandlers.mouseover({ seriesName: 'sum__num, boy' });
expect(onFocusedSeries).toHaveBeenLastCalledWith('sum__num, boy');
eventHandlers.mouseout();
expect(onFocusedSeries).toHaveBeenLastCalledWith(null);
});

View File

@@ -777,6 +777,198 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
expect(xAxis.type).toBe(AxisType.Category);
});
// labelMap/labelMapB must be keyed by the rendered series names or the
// cross-filter/drill lookups in EchartsMixedTimeseries miss (#41622);
// see the re-key comment in transformProps.ts.
test('cross-filter label maps are keyed by the rendered series names', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showQueryIdentifiers: false },
queriesData,
});
const transformed = transformProps(chartProps);
// The backend label_map is keyed by the flattened column names
// ("boy"/"girl") while the rendered series are "sum__num, boy" etc.
expect(transformed.labelMap).toEqual({
'sum__num, boy': ['boy'],
'sum__num, girl': ['girl'],
});
expect(transformed.labelMapB).toEqual({
'sum__num, boy': ['boy'],
'sum__num, girl': ['girl'],
});
});
test('cross-filter label maps resolve every rendered series name', () => {
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: queriesData,
formData: { ...formData, showQueryIdentifiers: true },
queriesData,
});
const transformed = transformProps(chartProps);
const names = (transformed.echartOptions.series as SeriesOption[]).map(
series => String(series.name),
);
expect(names).toHaveLength(4);
names
.slice(0, transformed.seriesBreakdown)
.forEach(name => expect(transformed.labelMap[name]).toBeDefined());
names
.slice(transformed.seriesBreakdown)
.forEach(name => expect(transformed.labelMapB[name]).toBeDefined());
});
test('cross-filter label maps resolve verbose series names to raw label_map values', () => {
const verboseRows = [
{ ds: 599616000000, sum__num: 1 },
{ ds: 599916000000, sum__num: 3 },
];
const verboseQueryData = createTestQueryData(verboseRows, {
label_map: { ds: ['ds'], sum__num: ['sum__num'] },
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [verboseQueryData, verboseQueryData],
formData: { ...formData, groupby: [], groupbyB: [] },
queriesData: [verboseQueryData, verboseQueryData],
datasource: {
verboseMap: { sum__num: 'Total Births' },
},
});
const transformed = transformProps(chartProps);
// rebaseForecastDatum renames data columns to their verbose names, so the
// rendered series is "Total Births" while label_map stays keyed by
// "sum__num" — the display-keyed map bridges the two.
expect(transformed.labelMap['Total Births']).toEqual(['sum__num']);
expect(transformed.labelMapB['Total Births']).toEqual(['sum__num']);
});
test('tooltip resolves per-metric formats through the display-keyed label map', () => {
// Multi-metric so getCustomFormatter cannot short-circuit on a single
// saved metric: the formatter key must come from resolving the rendered
// series name through the display-keyed map.
const rows = [{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 }];
const queryData = createTestQueryData(rows, {
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'avg__num, boy': ['avg__num', 'boy'],
},
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryData, queryData],
formData: {
...formData,
metrics: ['sum__num', 'avg__num'],
x_axis: 'ds',
yAxisFormat: undefined,
},
queriesData: [queryData, queryData],
datasource: {
columnFormats: { sum__num: '.2%' },
},
});
const transformed = transformProps(chartProps);
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
params: unknown,
) => string;
const html = formatter({
value: [599616000000, 0.5],
seriesId: 'sum__num, boy',
marker: '',
color: '#333',
});
expect(html).toContain('50.00%');
});
test('tooltip resolves per-metric formats for secondary-query series', () => {
const rowsA = [
{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 },
];
const queryDataA = createTestQueryData(rowsA, {
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
coltypes: [
GenericDataType.Temporal,
GenericDataType.Numeric,
GenericDataType.Numeric,
],
label_map: {
ds: ['ds'],
'sum__num, boy': ['sum__num', 'boy'],
'avg__num, boy': ['avg__num', 'boy'],
},
});
const rowsB = [{ ds: 599616000000, 'count__num, boy': 2.5 }];
const queryDataB = createTestQueryData(rowsB, {
colnames: ['ds', 'count__num, boy'],
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
label_map: {
ds: ['ds'],
'count__num, boy': ['count__num', 'boy'],
},
});
const chartProps = createEchartsTimeseriesTestChartProps<
EchartsMixedTimeseriesFormData,
EchartsMixedTimeseriesProps
>({
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
defaultQueriesData: [queryDataA, queryDataB],
formData: {
...formData,
metrics: ['sum__num', 'avg__num'],
metricsB: ['count__num', 'max__num'],
x_axis: 'ds',
yAxisFormat: undefined,
yAxisFormatSecondary: undefined,
yAxisIndex: 0,
yAxisIndexB: 1,
},
queriesData: [queryDataA, queryDataB],
datasource: {
columnFormats: { count__num: '.1f' },
},
});
const transformed = transformProps(chartProps);
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
params: unknown,
) => string;
const html = formatter({
value: [599616000000, 2.5],
seriesId: 'count__num, boy',
marker: '',
color: '#333',
});
expect(html).toContain('2.5');
});
test('temporal x coltype wires the time formatter and Time axis', () => {
// Regression guard: the happy path for mixed-timeseries charts. Ensures
// Temporal coltype still routes through the TimeFormatter so the time axis

View File

@@ -20,13 +20,14 @@
import {
ReactNode,
MouseEvent,
SyntheticEvent,
useState,
useCallback,
useRef,
useMemo,
useEffect,
} from 'react';
import { safeHtmlSpan } from '@superset-ui/core';
import { safeHtmlSpan, handleKeyboardActivation } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
@@ -154,7 +155,10 @@ function displayCell(value: unknown, allowRenderHtml?: boolean): ReactNode {
function displayHeaderCell(
needToggle: boolean,
ArrowIcon: ReactNode,
onArrowClick: ((e: MouseEvent<HTMLSpanElement>) => void) | null,
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
// used as the keyboard-activation handler via `handleKeyboardActivation`,
// which invokes it with a `KeyboardEvent`.
onArrowClick: ((e: SyntheticEvent) => void) | null,
value: unknown,
namesMapping: Record<string, string>,
allowRenderHtml?: boolean,
@@ -172,6 +176,9 @@ function displayHeaderCell(
tabIndex={0}
className="toggle"
onClick={onArrowClick || undefined}
onKeyDown={
onArrowClick ? handleKeyboardActivation(onArrowClick) : undefined
}
>
{ArrowIcon}
</span>
@@ -462,7 +469,7 @@ export function TableRenderer(props: TableRendererProps) {
);
const toggleRowKey = useCallback(
(flatRowKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
(flatRowKey: string) => (e: SyntheticEvent) => {
e.stopPropagation();
setCollapsedRows(state => ({
...state,
@@ -473,7 +480,7 @@ export function TableRenderer(props: TableRendererProps) {
);
const toggleColKey = useCallback(
(flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
(flatColKey: string) => (e: SyntheticEvent) => {
e.stopPropagation();
setCollapsedCols(state => ({
...state,
@@ -1046,6 +1053,7 @@ export function TableRenderer(props: TableRendererProps) {
onClick={e => {
e.stopPropagation();
}}
onKeyDown={e => e.stopPropagation()}
aria-label={
activeSortColumn === i
? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`

View File

@@ -0,0 +1,164 @@
/**
* 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.
*/
import { FeatureFlag } from '@superset-ui/core';
/**
* Standard mobile breakpoint values (below md breakpoint)
*/
export const mobileBreakpoints = {
xs: true,
sm: true,
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,
},
});
/**
* Enables the MOBILE_CONSUMPTION_MODE feature flag on window.featureFlags.
* Mobile behavior requires BOTH a small viewport (mock Grid.useBreakpoint)
* 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 },
};
/**
* Helper to mock sessionStorage for tests
*/
export const mockSessionStorage = () => {
const store: Record<string, string> = {};
return {
getItem: jest.fn((key: string) => store[key] ?? null),
setItem: jest.fn((key: string, value: string) => {
store[key] = value;
}),
removeItem: jest.fn((key: string) => {
delete store[key];
}),
clear: jest.fn(() => {
Object.keys(store).forEach(key => delete store[key]);
}),
get length() {
return Object.keys(store).length;
},
key: jest.fn((index: number) => Object.keys(store)[index] ?? null),
};
};
/**
* Helper to create a failing sessionStorage mock (simulates privacy mode)
*/
export const mockSessionStorageFailure = () => ({
getItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
setItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
removeItem: jest.fn(() => {
throw new Error('Storage access denied');
}),
clear: jest.fn(() => {
throw new Error('Storage access denied');
}),
length: 0,
key: jest.fn(() => null),
});

View File

@@ -119,7 +119,7 @@ jest.mock('@superset-ui/core/components/Icons/AsyncIcon', () => ({
const label =
ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
// eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events
<span
ref={ref}
role={role || (onClick ? 'button' : 'img')}

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import type { NodeRendererProps } from 'react-arborist';
@@ -186,6 +187,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<span
className="col-copy-action"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
<ActionButton
label={`copy-col-${data.name}`}
@@ -215,6 +217,13 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
node.toggle();
}
}}
onKeyDown={handleKeyboardActivation(() => {
if (node.isLeaf) {
node.select();
} else {
node.toggle();
}
})}
>
<span
className="tree-node-icon"
@@ -239,6 +248,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<div
className="side-action-container"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{pinnedSchemas.has(schema) && (
<div className="action-static">
@@ -306,6 +316,7 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
<div
className="side-action-container"
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{isPinned && (
<div className="action-static">

View File

@@ -30,6 +30,7 @@ import {
type FilterState,
type JsonObject,
type AgGridChartState,
handleKeyboardActivation,
} from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import type { ChartState, Datasource, ChartStatus } from 'src/explore/types';
@@ -465,7 +466,12 @@ function Chart({
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span role="button" tabIndex={0} onClick={onQuery}>
<span
role="button"
tabIndex={0}
onClick={onQuery}
onKeyDown={handleKeyboardActivation(() => onQuery?.())}
>
{t('click here')}
</span>
.

View File

@@ -17,7 +17,14 @@
* under the License.
*/
import { useCallback, useContext, useEffect, useMemo, useState } from 'react';
import {
SyntheticEvent,
useCallback,
useContext,
useEffect,
useMemo,
useState,
} from 'react';
import { t } from '@apache-superset/core/translation';
import {
BinaryQueryObjectFilterClause,
@@ -28,6 +35,7 @@ import {
isDefined,
ContextMenuFilters,
AdhocFilter,
handleKeyboardActivation,
} from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
import { css, useTheme } from '@apache-superset/core/theme';
@@ -554,6 +562,12 @@ export default function DrillByModal({
items={breadcrumbItems}
itemRender={(route, _, routes, paths) => {
const isLastElement = routes.indexOf(route) === routes.length - 1;
// `route.onClick` is typed by antd as a `MouseEventHandler`, but
// the underlying handler ignores its argument, so it's safe to
// broaden it to an optional `SyntheticEvent` callback here to
// reuse it as the keyboard-activation handler below.
const onRouteClick = route.onClick as
((event?: SyntheticEvent) => void) | undefined;
return isLastElement ? (
<span data-test="drill-by-breadcrumb-item">
{route.title}
@@ -564,7 +578,8 @@ export default function DrillByModal({
data-test="drill-by-breadcrumb-item"
role="button"
tabIndex={0}
onClick={route.onClick}
onClick={onRouteClick}
onKeyDown={handleKeyboardActivation(() => onRouteClick?.())}
css={css`
cursor: pointer;
`}

View File

@@ -217,6 +217,7 @@ export const DrillBySubmenu = ({
role="menu"
tabIndex={0}
data-test="drill-by-submenu"
onKeyDown={e => e.stopPropagation()}
css={css`
width: 220px;
max-width: 220px;

View File

@@ -36,6 +36,7 @@ import {
SupersetClient,
getClientErrorObject,
getExtensionsRegistry,
handleKeyboardActivation,
} from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { t } from '@apache-superset/core/translation';
@@ -1760,6 +1761,7 @@ function DatasourceEditor({
role="button"
tabIndex={0}
onClick={onChangeEditMode}
onKeyDown={handleKeyboardActivation(onChangeEditMode)}
>
{isEditMode ? (
<Icons.UnlockOutlined

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
@@ -103,6 +104,7 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
role="button"
tabIndex={0}
onClick={toggleDescription}
onKeyDown={handleKeyboardActivation(toggleDescription)}
style={{ textDecoration: 'underline', cursor: 'pointer' }}
>
{isDescriptionVisible ? t('See less') : t('See more')}
@@ -127,7 +129,12 @@ export const ErrorAlert: React.FC<ErrorAlertProps> = ({
return (
<>
<Tooltip title={`${errorType}: ${message}`}>
<span role="button" onClick={() => setShowModal(true)} tabIndex={0}>
<span
role="button"
onClick={() => setShowModal(true)}
onKeyDown={handleKeyboardActivation(() => setShowModal(true))}
tabIndex={0}
>
{renderTrigger()}
</span>
</Tooltip>

View File

@@ -19,6 +19,7 @@
import { ReactNode, MouseEvent as ReactMouseEvent } from 'react';
import { TableInstance, Row, UseRowSelectRowProps } from 'react-table';
import { styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import cx from 'classnames';
interface CardCollectionProps {
@@ -42,6 +43,18 @@ const CardContainer = styled.div<{ showThumbnails?: boolean }>`
? `${theme.sizeUnit * 8 + 3}px ${theme.sizeUnit * 20}px`
: `${theme.sizeUnit * 8 + 1}px ${theme.sizeUnit * 20}px`
};
/* Full-width cards on mobile (consumption mode) */
${
isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
grid-template-columns: 1fr;
grid-gap: ${theme.sizeUnit * 4}px;
padding-left: ${theme.sizeUnit * 4}px;
padding-right: ${theme.sizeUnit * 4}px;
}`
: ''
}
`}
`;

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, within, waitFor } from 'spec/helpers/testing-library';
import {
render,
screen,
within,
waitFor,
fireEvent,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -359,4 +365,248 @@ describe('ListView', () => {
expect(mockedPropsComprehensive.fetchData).toHaveBeenCalled();
});
test('switches view mode via keyboard activation of the toggle buttons', () => {
const { container } = factory({
renderCard: jest.fn(),
data: [],
count: 0,
initialSort: [{ id: 'something' }],
});
const [cardToggle, tableToggle] =
container.querySelectorAll<HTMLElement>('.toggle-button');
expect(screen.getByTestId('empty-state')).toHaveClass('card');
fireEvent.keyDown(tableToggle, { key: 'Enter' });
expect(screen.getByTestId('empty-state')).toHaveClass('table');
fireEvent.keyDown(cardToggle, { key: ' ' });
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, ...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, ...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, ...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, ...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, ...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, ...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, ...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, ...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, ...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

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { Alert } from '@apache-superset/core/components';
import { styled } from '@apache-superset/core/theme';
@@ -33,6 +34,7 @@ import BulkTagModal from 'src/features/tags/BulkTagModal';
import {
Button,
Tooltip,
Drawer,
Icons,
EmptyState,
Loading,
@@ -238,6 +240,30 @@ const EmptyWrapper = styled.div`
`}
`;
const MobileFilterDrawerContent = styled.div`
${({ theme }) => `
display: flex;
flex-direction: column;
gap: ${theme.sizeUnit * 4}px;
padding: ${theme.sizeUnit * 2}px;
/* Make filter inputs stack vertically and full-width */
> * {
width: 100%;
}
/* Override inline filter styling for vertical layout */
.filter-container {
width: 100%;
}
input[type="text"],
.ant-select {
width: 100% !important;
}
`}
`;
const ViewModeToggle = ({
mode,
setMode,
@@ -255,6 +281,7 @@ const ViewModeToggle = ({
e.currentTarget.blur();
setMode('card');
}}
onKeyDown={handleKeyboardActivation(() => setMode('card'))}
className={cx('toggle-button', { active: mode === 'card' })}
>
<Icons.AppstoreOutlined iconSize="xl" />
@@ -269,6 +296,7 @@ const ViewModeToggle = ({
e.currentTarget.blur();
setMode('table');
}}
onKeyDown={handleKeyboardActivation(() => setMode('table'))}
className={cx('toggle-button', { active: mode === 'table' })}
>
<Icons.UnorderedListOutlined iconSize="xl" />
@@ -302,6 +330,7 @@ export interface ListViewProps<T extends object = any> {
renderCard?: (row: T & { loading: boolean }) => ReactNode;
cardSortSelectOptions?: Array<CardSortSelectOption>;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
highlightRowId?: number;
showThumbnails?: boolean;
emptyState?: EmptyStateProps;
@@ -317,6 +346,12 @@ export interface ListViewProps<T extends object = any> {
expandable?: Record<string, unknown>;
/** Content rendered between the filter bar and the table/card body. */
headerContent?: ReactNode;
/** Whether mobile filters drawer is open (controlled externally) */
mobileFiltersOpen?: boolean;
/** Callback to set mobile filters drawer open state */
setMobileFiltersOpen?: (open: boolean) => void;
/** Title for the mobile filters drawer */
mobileFiltersDrawerTitle?: string;
}
export function ListView<T extends object = any>({
@@ -338,6 +373,7 @@ export function ListView<T extends object = any>({
showThumbnails,
cardSortSelectOptions,
defaultViewMode = 'card',
forceViewMode,
highlightRowId,
emptyState,
columnsForWrapText,
@@ -348,6 +384,9 @@ export function ListView<T extends object = any>({
headerContent,
addSuccessToast,
addDangerToast,
mobileFiltersOpen = false,
setMobileFiltersOpen,
mobileFiltersDrawerTitle,
}: ListViewProps<T>) {
const {
getTableProps,
@@ -374,6 +413,7 @@ export function ListView<T extends object = any>({
initialFilters: filters,
renderCard: Boolean(renderCard),
defaultViewMode,
forceViewMode,
});
const allowBulkTagActions = bulkTagResourceName && enableBulkTag;
const filterable = Boolean(filters.length);
@@ -450,11 +490,15 @@ export function ListView<T extends object = any>({
)}
<div data-test={className} className={`superset-list-view ${className} `}>
<div className="header">
{cardViewEnabled && (
{cardViewEnabled && !forceViewMode && (
<ViewModeToggle mode={viewMode} setMode={setViewMode} />
)}
<div className="controls" data-test="filters-select">
{filterable && (
{/* When a mobile drawer callback is provided, filters and sort
render inside the drawer instead of inline. Only one
FilterControls instance is ever mounted, so filtersRef and
filterControlsRef always point at the visible instance. */}
{filterable && !setMobileFiltersOpen && (
<FilterControls
ref={filterControlsRef}
filters={filters}
@@ -462,13 +506,15 @@ export function ListView<T extends object = any>({
updateFilterValue={applyFilterValue}
/>
)}
{viewMode === 'card' && cardSortSelectOptions && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{viewMode === 'card' &&
cardSortSelectOptions &&
!setMobileFiltersOpen && (
<CardSortSelect
initialSort={sortBy}
onChange={(value: SortColumn[]) => setSortBy(value)}
options={cardSortSelectOptions}
/>
)}
{filterable && (
<Tooltip
title={!hasActiveFilters ? t('No filters applied') : undefined}
@@ -509,6 +555,9 @@ export function ListView<T extends object = any>({
tabIndex={0}
className="deselect-all"
onClick={() => toggleAllRowsSelected(false)}
onKeyDown={handleKeyboardActivation(() =>
toggleAllRowsSelected(false),
)}
>
{t('Deselect all')}
</span>
@@ -544,6 +593,9 @@ export function ListView<T extends object = any>({
tabIndex={0}
className="tag-btn"
onClick={() => setShowBulkTagModal(true)}
onKeyDown={handleKeyboardActivation(() =>
setShowBulkTagModal(true),
)}
>
{t('Add Tag')}
</span>
@@ -650,6 +702,33 @@ 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}
/>
)}
</MobileFilterDrawerContent>
</Drawer>
)}
</ListViewStyles>
);
}

View File

@@ -195,6 +195,7 @@ interface UseListViewConfig {
initialFilters?: Filter[];
renderCard?: boolean;
defaultViewMode?: ViewModeType;
forceViewMode?: ViewModeType;
}
export function useListViewState({
@@ -207,6 +208,7 @@ export function useListViewState({
initialSort = [],
renderCard = false,
defaultViewMode = 'card',
forceViewMode,
}: UseListViewConfig) {
const [query, setQuery] = useQueryParams({
filters: RisonParam,
@@ -234,10 +236,19 @@ export function useListViewState({
};
const [viewMode, setViewMode] = useState<ViewModeType>(
(query.viewMode as ViewModeType) ||
// forceViewMode overrides everything (used for mobile)
forceViewMode ||
(query.viewMode as ViewModeType) ||
(renderCard ? defaultViewMode : 'table'),
);
// Update viewMode when forceViewMode changes (e.g., screen resize)
useEffect(() => {
if (forceViewMode) {
setViewMode(forceViewMode);
}
}, [forceViewMode]);
const columnsWithFilter = useMemo(
// add exact filter type so filters with falsy values are not filtered out
() => columns.map(f => ({ ...f, filter: 'exact' })),

View File

@@ -0,0 +1,73 @@
/**
* 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 '.';
import { MOBILE_BYPASS_STORAGE_KEY } from '../../pages/MobileUnsupported';
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(() => {
sessionStorage.clear();
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();
});
test('renders children on mobile when the bypass flag is set', () => {
mockedUseIsMobile.mockReturnValue(true);
sessionStorage.setItem(MOBILE_BYPASS_STORAGE_KEY, 'true');
renderGuard(undefined);
expect(screen.getByTestId('guarded-content')).toBeInTheDocument();
});

View File

@@ -0,0 +1,78 @@
/**
* 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, useEffect, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useIsMobile } from 'src/hooks/useIsMobile';
import MobileUnsupported, {
MOBILE_BYPASS_STORAGE_KEY,
} 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.
*
* Users can bypass this by clicking "Continue anyway", which sets a
* sessionStorage flag for the rest of the browser session.
*/
function MobileRouteGuard({
children,
mobileSupported,
}: MobileRouteGuardProps) {
const isMobile = useIsMobile();
const location = useLocation();
const [bypassEnabled, setBypassEnabled] = useState(() => {
try {
return sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
} catch {
return false;
}
});
// Check for bypass flag when location changes
useEffect(() => {
try {
const bypass =
sessionStorage.getItem(MOBILE_BYPASS_STORAGE_KEY) === 'true';
setBypassEnabled(bypass);
} catch {
// Storage access denied, keep current state
}
}, [location.pathname]);
if (!isMobile || bypassEnabled || mobileSupported) {
return <>{children}</>;
}
return (
<MobileUnsupported originalPath={location.pathname + location.search} />
);
}
export default MobileRouteGuard;

View File

@@ -50,6 +50,15 @@ 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', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
jest.mock('src/dashboard/actions/dashboardState', () => ({
...jest.requireActual('src/dashboard/actions/dashboardState'),
fetchFaveStar: jest.fn(),
@@ -422,6 +431,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -448,6 +458,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: false,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { getByTestId } = setup();
@@ -474,6 +485,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { getByTestId } = setup();
@@ -533,6 +545,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: false,
hasFilters: false,
});
const { queryByTestId } = setup();
@@ -546,6 +559,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup();
@@ -559,6 +573,7 @@ describe('DashboardBuilder', () => {
dashboardFiltersOpen: true,
toggleDashboardFiltersOpen: jest.fn(),
nativeFiltersEnabled: true,
hasFilters: true,
});
const { queryByTestId } = setup({
dashboardState: { ...mockState.dashboardState, editMode: true },
@@ -719,3 +734,85 @@ 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 dashboard content wrapper', () => {
(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 { getByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// Dashboard content wrapper should always be present
expect(getByTestId('dashboard-content-wrapper')).toBeInTheDocument();
});
test('should render header container', () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { queryByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayout,
}),
useDnd: true,
useTheme: true,
useRouter: true,
});
// Header container should be present
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
});

View File

@@ -23,7 +23,7 @@ import { t } from '@apache-superset/core/translation';
import { addAlpha, JsonObject, useElementOnScreen } from '@superset-ui/core';
import { css, styled, useTheme } from '@apache-superset/core/theme';
import { useDispatch, useSelector } from 'react-redux';
import { EmptyState, Loading } from '@superset-ui/core/components';
import { Drawer, EmptyState, Loading } from '@superset-ui/core/components';
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/components/Header';
@@ -59,6 +59,7 @@ import {
} from 'src/dashboard/util/constants';
import FilterBar from 'src/dashboard/components/nativeFilters/FilterBar';
import { useUiConfig } from 'src/components/UiConfigContext';
import { useIsMobile } from 'src/hooks/useIsMobile';
import ResizableSidebar from 'src/components/ResizableSidebar';
import {
BUILDER_SIDEPANEL_WIDTH,
@@ -142,9 +143,8 @@ const DashboardContentWrapper = styled.div`
& .dashboard-component-tabs {
box-shadow: 0 ${theme.sizeUnit}px ${theme.sizeUnit}px 0
${addAlpha(theme.colorBorderSecondary, 0.1)};
padding-left: ${
theme.sizeUnit * 2
}px; /* note this is added to tab-level padding, to match header */
padding-left: ${theme.sizeUnit *
2}px; /* note this is added to tab-level padding, to match header */
}
.dropdown-toggle.btn.btn-primary .caret {
@@ -297,14 +297,12 @@ const StyledDashboardContent = styled.div<{
margin: ${theme.sizeUnit * 4}px;
margin-left: ${marginLeft}px;
${
editMode &&
`
${editMode &&
`
max-width: calc(100% - ${
BUILDER_SIDEPANEL_WIDTH + theme.sizeUnit * 16
}px);
`
}
`}
/* this is the ParentSize wrapper */
& > div:first-of-type {
@@ -341,10 +339,8 @@ const StyledDashboardContent = styled.div<{
}
&.fade-out {
box-shadow: ${
theme.dashboardTileBoxShadow ??
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`
};
box-shadow: ${theme.dashboardTileBoxShadow ??
`0 0 0 1px ${addAlpha(theme.colorBorder, 0.5)}`};
}
& .missing-chart-container {
@@ -372,6 +368,8 @@ const DashboardBuilder = () => {
const dispatch = useDispatch();
const uiConfig = useUiConfig();
const theme = useTheme();
const isNotMobile = !useIsMobile();
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
const dashboardId = useSelector<RootState, string>(
({ dashboardInfo }) => `${dashboardInfo.id}`,
@@ -462,13 +460,14 @@ const DashboardBuilder = () => {
dashboardFiltersOpen,
toggleDashboardFiltersOpen,
nativeFiltersEnabled,
hasFilters,
} = useNativeFilters();
const [containerRef, isSticky] = useElementOnScreen<HTMLDivElement>(
ELEMENT_ON_SCREEN_OPTIONS,
);
const showFilterBar = !editMode && nativeFiltersEnabled;
const showFilterBar = isNotMobile && !editMode && nativeFiltersEnabled;
const offset =
FILTER_BAR_HEADER_HEIGHT +
@@ -480,6 +479,7 @@ const DashboardBuilder = () => {
const draggableStyle = useMemo(
() => ({
marginLeft:
!isNotMobile ||
dashboardFiltersOpen ||
editMode ||
!nativeFiltersEnabled ||
@@ -488,6 +488,7 @@ const DashboardBuilder = () => {
: -32,
}),
[
isNotMobile,
dashboardFiltersOpen,
editMode,
filterBarOrientation,
@@ -519,7 +520,15 @@ const DashboardBuilder = () => {
const headerContent = useMemo(
() => (
<>
{!hideDashboardHeader && <DashboardHeader />}
{!hideDashboardHeader && (
<DashboardHeader
onOpenMobileFilters={
!isNotMobile && nativeFiltersEnabled && hasFilters
? () => setMobileFiltersOpen(true)
: undefined
}
/>
)}
{/* Report mode is a one-shot screenshot render (reports, thumbnails),
so it must never start a refresh timer that could re-fetch charts
mid-capture. */}
@@ -533,7 +542,15 @@ const DashboardBuilder = () => {
)}
</>
),
[hideDashboardHeader, showFilterBar, filterBarOrientation, isReport],
[
hideDashboardHeader,
isNotMobile,
nativeFiltersEnabled,
hasFilters,
showFilterBar,
filterBarOrientation,
isReport,
],
);
const renderDraggableContent = useCallback(
@@ -577,6 +594,8 @@ const DashboardBuilder = () => {
topLevelTabs,
uiConfig.hideTab,
uiConfig.hideNav,
isNotMobile,
theme,
],
);
@@ -739,6 +758,36 @@ const DashboardBuilder = () => {
`}
/>
)}
{/* Mobile filters drawer */}
{!isNotMobile && nativeFiltersEnabled && (
<Drawer
title={t('Filters')}
placement="left"
onClose={() => setMobileFiltersOpen(false)}
open={mobileFiltersOpen}
width="85vw"
styles={{
body: {
padding: 0,
display: 'flex',
flexDirection: 'column',
},
}}
>
<FilterBar
orientation={FilterBarOrientation.Vertical}
verticalConfig={{
filtersOpen: true,
toggleFiltersBar: () => {},
width: 300,
height: '100%',
offset: 0,
mobileMode: true,
}}
hidden={false}
/>
</Drawer>
)}
</DashboardWrapper>
);
};

View File

@@ -25,6 +25,7 @@ import { useSelector } from 'react-redux';
import { useDragDropManager } from 'react-dnd';
import classNames from 'classnames';
import { debounce } from 'lodash-es';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
const StyledDiv = styled.div`
${({ theme }) => css`
@@ -110,6 +111,20 @@ const StyledDiv = styled.div`
i.warning {
color: ${theme.colorWarning};
}
/* Mobile consumption mode: show the full chart title without
truncation (controls and links are render-gated in SliceHeader) */
${isMobileConsumptionEnabled()
? `@media (max-width: ${theme.screenSMMax}px) {
[data-test='slice-header'] .header-title {
-webkit-line-clamp: unset;
display: block;
white-space: normal;
overflow: visible;
text-overflow: unset;
}
}`
: ''}
`}
`;

View File

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

View File

@@ -186,6 +186,15 @@ const recordError = jest.fn();
const setPaused = jest.fn();
const setPausedByTab = jest.fn();
// Mock useBreakpoint to return desktop breakpoints (prevents mobile rendering)
jest.mock('antd', () => ({
...jest.requireActual('antd'),
Grid: {
...jest.requireActual('antd').Grid,
useBreakpoint: () => ({ xs: true, sm: true, md: true, lg: true, xl: true }),
},
}));
jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({
useUnsavedChangesPrompt: jest.fn(),
}));

View File

@@ -23,7 +23,12 @@ import {
FeatureFlag,
getExtensionsRegistry,
} from '@superset-ui/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/theme';
import {
styled,
css,
SupersetTheme,
useTheme,
} from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { Global } from '@emotion/react';
import { shallowEqual, useDispatch, useSelector } from 'react-redux';
@@ -37,6 +42,7 @@ import {
UnsavedChangesModal,
} from '@superset-ui/core/components';
import { findPermission } from 'src/utils/findPermission';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { safeStringify } from 'src/utils/safeStringify';
import Subject from 'src/types/Subject';
import { DashboardLayout, RootState } from 'src/dashboard/types';
@@ -219,8 +225,14 @@ const discardChanges = () => {
window.location.assign(url);
};
const Header = (): JSX.Element => {
interface HeaderComponentProps {
onOpenMobileFilters?: () => void;
}
const Header = ({ onOpenMobileFilters }: HeaderComponentProps): JSX.Element => {
const dispatch = useDispatch();
const theme = useTheme();
const isMobile = useIsMobile();
const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] =
useState(false);
const [emphasizeUndo, setEmphasizeUndo] = useState(false);
@@ -640,7 +652,7 @@ const Header = (): JSX.Element => {
onTogglePause={handlePauseToggle}
/>
),
!editMode && (
!editMode && !isMobile && (
<PublishedStatus
key="published-status"
dashboardId={dashboardInfo.id}
@@ -650,12 +662,13 @@ const Header = (): JSX.Element => {
userCanSave={userCanSaveAs}
/>
),
!editMode && !isEmbedded && metadataBar,
!editMode && !isEmbedded && !isMobile && metadataBar,
],
[
boundActionCreators.savePublished,
dashboardInfo.id,
editMode,
isMobile,
metadataBar,
isEmbedded,
isPublished,
@@ -750,7 +763,7 @@ const Header = (): JSX.Element => {
) : (
<div css={actionButtonsStyle}>
{NavExtension && <NavExtension />}
{userCanEdit && !isEmbedded && (
{userCanEdit && !isEmbedded && !isMobile && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
@@ -778,6 +791,7 @@ const Header = (): JSX.Element => {
handleEnterEditMode,
hasUnsavedChanges,
isEmbedded,
isMobile,
overwriteDashboard,
redoLength,
undoLength,
@@ -814,6 +828,10 @@ const Header = (): JSX.Element => {
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar: boundActionCreators.saveFaveStar,
showReportModal,
showPropertiesModal,
showRefreshModal,
@@ -833,6 +851,21 @@ const Header = (): JSX.Element => {
editableTitleProps={editableTitleProps}
certificatiedBadgeProps={certifiedBadgeProps}
faveStarProps={faveStarProps}
leftPanelItems={
onOpenMobileFilters && (
<Button
buttonStyle="link"
aria-label={t('Open filters')}
onClick={onOpenMobileFilters}
data-test="mobile-filters-trigger"
>
<Icons.FilterOutlined
iconColor={theme.colorPrimary}
iconSize="l"
/>
</Button>
)
}
titlePanelAdditionalItems={titlePanelAdditionalItems}
rightPanelAdditionalItems={rightPanelAdditionalItems}
menuDropdownProps={{
@@ -840,7 +873,7 @@ const Header = (): JSX.Element => {
onOpenChange: setIsDropdownVisible,
}}
additionalActionsMenu={menu}
showFaveStar={Boolean(user?.userId && dashboardInfo?.id)}
showFaveStar={!!(user?.userId && dashboardInfo?.id && !isMobile)}
showTitlePanelItems
/>
{showingPropertiesModal && (

View File

@@ -43,6 +43,10 @@ export interface HeaderDropdownProps {
forceRefreshAllCharts: () => unknown;
hasUnsavedChanges: boolean;
isLoading: boolean;
isMobile?: boolean;
isStarred?: boolean;
isPublished?: boolean;
saveFaveStar?: (id: number, isStarred: boolean) => void;
layout: Layout;
onSave: (...args: unknown[]) => unknown;
refreshFrequency: number;

View File

@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
import { usePermissions } from 'src/hooks/usePermissions';
import getUserName from 'src/utils/getUserName';
export const useHeaderActionsMenu = ({
customCss,
@@ -56,6 +57,10 @@ export const useHeaderActionsMenu = ({
userCanCurate,
userCanExport,
isLoading,
isMobile,
isStarred,
isPublished,
saveFaveStar,
lastModifiedTime,
addSuccessToast,
addDangerToast,
@@ -117,6 +122,11 @@ export const useHeaderActionsMenu = ({
case MenuKeys.ManageEmbedded:
manageEmbedded();
break;
case 'toggle-favorite':
if (saveFaveStar && isStarred !== undefined) {
saveFaveStar(dashboardId, isStarred);
}
break;
default:
break;
}
@@ -128,6 +138,9 @@ export const useHeaderActionsMenu = ({
showPropertiesModal,
showRefreshModal,
manageEmbedded,
saveFaveStar,
dashboardId,
isStarred,
history,
location,
],
@@ -205,6 +218,52 @@ export const useHeaderActionsMenu = ({
const menuItems: MenuItem[] = [];
// Mobile-only: show dashboard info items in menu
if (isMobile && !editMode) {
// Favorite toggle
if (saveFaveStar) {
menuItems.push({
key: 'toggle-favorite',
label: isStarred ? t('Remove from favorites') : t('Add to favorites'),
});
}
// Published status
menuItems.push({
key: 'status-info',
label: isPublished ? t('Status: Published') : t('Status: Draft'),
disabled: true,
});
// Editor info
const editorNames = dashboardInfo?.editors?.length
? dashboardInfo.editors
.map((editor: { label?: string }) => editor.label)
.filter(Boolean)
.join(', ')
: t('None');
menuItems.push({
key: 'owner-info',
label: t('Owner: %(names)s', { names: editorNames }),
disabled: true,
});
// Last modified
const modifiedBy =
getUserName(dashboardInfo?.changed_by) || t('Not available');
const modifiedDate = dashboardInfo?.changed_on_delta_humanized || '';
menuItems.push({
key: 'modified-info',
label: t('Modified %(date)s by %(user)s', {
date: modifiedDate,
user: modifiedBy,
}),
disabled: true,
});
menuItems.push({ type: 'divider' });
}
// Refresh dashboard
if (!editMode) {
menuItems.push({
@@ -224,8 +283,8 @@ export const useHeaderActionsMenu = ({
});
}
// Toggle fullscreen
if (!editMode && !isEmbedded) {
// Toggle fullscreen (hide on mobile)
if (!editMode && !isEmbedded && !isMobile) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
@@ -293,15 +352,15 @@ export const useHeaderActionsMenu = ({
// Only add divider if there are items after it
const hasItemsAfterDivider =
(!editMode && reportMenuItem) ||
(!editMode && reportMenuItem && !isMobile) ||
(editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes));
if (hasItemsAfterDivider) {
menuItems.push({ type: 'divider' });
}
// Report dropdown
if (!editMode && reportMenuItem) {
// Report dropdown (hide on mobile)
if (!editMode && reportMenuItem && !isMobile) {
menuItems.push(reportMenuItem);
}
@@ -339,11 +398,15 @@ export const useHeaderActionsMenu = ({
expandedSlices,
handleMenuClick,
isLoading,
isMobile,
isPublished,
isStarred,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
reportMenuItem,
saveFaveStar,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,

View File

@@ -43,6 +43,7 @@ import { isEmbedded } from 'src/dashboard/util/isEmbedded';
import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
import { useSelector } from 'react-redux';
import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
import { useIsMobile } from 'src/hooks/useIsMobile';
import { SliceHeaderControlsProps } from 'src/dashboard/components/SliceHeaderControls/types';
import FiltersBadge from 'src/dashboard/components/FiltersBadge';
import CustomizationsBadge from 'src/dashboard/components/CustomizationsBadge';
@@ -229,7 +230,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
0,
);
const canExplore = !editMode && supersetCanExplore;
// Consumption-only mobile mode: no explore link, no chart controls
const isMobile = useIsMobile();
const canExplore = !editMode && supersetCanExplore && !isMobile;
const showRowLimitWarning =
shouldShowRowLimitWarning && sqlRowCount >= rowLimit && rowLimit > 0;
@@ -355,7 +358,7 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
}
/>
)}
{!uiConfig.hideChartControls && (
{!uiConfig.hideChartControls && !isMobile && (
<SliceHeaderControls
slice={slice}
isCached={isCached}

View File

@@ -26,9 +26,17 @@ import {
import { FeatureFlag, VizType } from '@superset-ui/core';
import mockState from 'spec/fixtures/mockState';
import { cachedSupersetGet } from 'src/utils/cachedSupersetGet';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import SliceHeaderControls, { SliceHeaderControlsProps } from '.';
jest.mock('src/utils/cachedSupersetGet');
jest.mock('src/utils/downloadAsImage', () =>
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
);
jest.mock('src/utils/downloadAsPdf', () =>
jest.fn(() => jest.fn().mockResolvedValue(undefined)),
);
const mockCachedSupersetGet = cachedSupersetGet as jest.MockedFunction<
typeof cachedSupersetGet
@@ -127,6 +135,12 @@ const openMenu = () => {
userEvent.click(screen.getByRole('button', { name: 'More Options' }));
};
const mockDownloadAsImage = downloadAsImage as jest.MockedFunction<
typeof downloadAsImage
>;
const mockDownloadAsPdf = downloadAsPdf as jest.MockedFunction<
typeof downloadAsPdf
>;
const mockFullscreenElement = (getElement: () => Element | null) => {
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
@@ -136,6 +150,8 @@ const mockFullscreenElement = (getElement: () => Element | null) => {
beforeEach(() => {
mockCachedSupersetGet.mockClear();
mockDownloadAsImage.mockClear();
mockDownloadAsPdf.mockClear();
mockCachedSupersetGet.mockResolvedValue({
response: {} as Response,
json: {
@@ -675,6 +691,109 @@ test('Should pass formData to Share menu for embed code feature', () => {
expect(screen.getByText('Share')).toBeInTheDocument();
});
test('Download submenu shows standardized export screenshot and PDF labels', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
expect(
await screen.findByText('Export screenshot (jpeg)'),
).toBeInTheDocument();
expect(screen.getByText('Export screenshot (png)')).toBeInTheDocument();
expect(screen.getByText('Export as PDF')).toBeInTheDocument();
});
test('Clicking "Export screenshot (jpeg)" calls downloadAsImage and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export screenshot (jpeg)'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ chartId: SLICE_ID }),
);
});
test('Export screenshot (png) submenu shows Transparent and Solid options', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
expect(await screen.findByText('Transparent background')).toBeInTheDocument();
expect(screen.getByText('Solid background')).toBeInTheDocument();
});
test('Clicking "Transparent background" calls downloadAsImage with transparent option and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
userEvent.click(await screen.findByText('Transparent background'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
{ format: 'png', backgroundType: 'transparent' },
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
chartId: SLICE_ID,
backgroundType: 'transparent',
}),
);
});
test('Clicking "Solid background" calls downloadAsImage with solid option and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.hover(await screen.findByText('Export screenshot (png)'));
userEvent.click(await screen.findByText('Solid background'));
expect(downloadAsImage).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
expect.anything(),
{ format: 'png', backgroundType: 'solid' },
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
chartId: SLICE_ID,
backgroundType: 'solid',
}),
);
});
test('Clicking "Export as PDF" calls downloadAsPdf and logEvent', async () => {
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Download'));
userEvent.click(await screen.findByText('Export as PDF'));
expect(downloadAsPdf).toHaveBeenCalledWith(
`.dashboard-chart-id-${SLICE_ID}`,
props.slice.slice_name,
true,
);
expect(props.logEvent).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({ chartId: SLICE_ID }),
);
});
test('Should show single fetched query tooltip with timestamp', async () => {
const updatedDttm = Date.parse('2024-01-28T10:00:00.000Z');
const props = createProps();

View File

@@ -50,12 +50,17 @@ import {
} from '@superset-ui/core/components';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { Icons } from '@superset-ui/core/components/Icons';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import { useDrillDetailMenuItems } from 'src/components/Chart/useDrillDetailMenuItems';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG,
LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF,
} from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
import { openInNewTab } from 'src/utils/navigationUtils';
@@ -302,29 +307,90 @@ const SliceHeaderControls = (
props.exportXLSX?.(props.slice.slice_id);
break;
case MenuKeys.DownloadAsImage: {
// menu closes with a delay, we need to hide it manually,
// so that we don't capture it on the screenshot
// Hide the dropdown menu so it is not captured in the screenshot
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement;
) as HTMLElement | null;
if (menu) {
menu.style.visibility = 'hidden';
}
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
)(domEvent).then(() => {
Promise.resolve(
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
)(domEvent),
).finally(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: props.slice.slice_id,
});
break;
}
case MenuKeys.DownloadAsPngTransparent:
case MenuKeys.DownloadAsPngSolid: {
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement | null;
if (menu) {
menu.style.visibility = 'hidden';
}
const backgroundType =
key === MenuKeys.DownloadAsPngTransparent ? 'transparent' : 'solid';
Promise.resolve(
downloadAsImage(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
theme,
{ format: 'png', backgroundType },
)(domEvent),
).finally(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_PNG, {
chartId: props.slice.slice_id,
backgroundType,
});
break;
}
case MenuKeys.DownloadAsPdf: {
const menu = document.querySelector(
'.ant-dropdown:not(.ant-dropdown-hidden)',
) as HTMLElement | null;
if (menu) {
menu.style.visibility = 'hidden';
}
Promise.resolve(
downloadAsPdf(
getScreenshotNodeSelector(props.slice.slice_id),
props.slice.slice_name,
true,
)(domEvent),
).finally(() => {
if (menu) {
menu.style.visibility = 'visible';
}
});
// eslint-disable-next-line no-unused-expressions
props.logEvent?.(LOG_ACTIONS_CHART_DOWNLOAD_AS_PDF, {
chartId: props.slice.slice_id,
});
break;
}
case MenuKeys.ExportPivotXlsx: {
const sliceSelector = `#chart-id-${props.slice.slice_id}`;
props.exportPivotExcel?.(
@@ -614,9 +680,30 @@ const SliceHeaderControls = (
: []),
{
key: MenuKeys.DownloadAsImage,
label: t('Download as image'),
label: t('Export screenshot (jpeg)'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
},
{
type: 'submenu',
key: 'download_as_png_submenu',
label: t('Export screenshot (png)'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
children: [
{
key: MenuKeys.DownloadAsPngTransparent,
label: t('Transparent background'),
},
{
key: MenuKeys.DownloadAsPngSolid,
label: t('Solid background'),
},
],
},
{
key: MenuKeys.DownloadAsPdf,
label: t('Export as PDF'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
],
});
}

View File

@@ -106,6 +106,7 @@ export default function URLShortLinkButton({
onClick={e => {
e.stopPropagation();
}}
onKeyDown={e => e.stopPropagation()}
>
<CopyToClipboard
text={shortUrl}

View File

@@ -36,10 +36,12 @@ 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;
@@ -96,6 +98,7 @@ const ChartHolder = ({
isInView,
}: ChartHolderProps) => {
const theme = useTheme();
const isMobile = useIsMobile();
const fullSizeStyle = css`
&& {
position: fixed !important;
@@ -167,6 +170,14 @@ const ChartHolder = ({
}, [outlinedComponentId]);
const widthMultiple = useMemo(() => {
// Mobile consumption mode stacks charts vertically at full width, so
// report the full column count. This keeps the pixel width handed to the
// chart plugin (and to ResizableContainer's inline size) in sync with the
// stacked layout instead of the desktop grid fraction.
if (isMobile && !editMode) {
return GRID_COLUMN_COUNT;
}
const columnParentWidth = getComponentById(
parentComponent.parents?.find(parent => parent.startsWith(COLUMN_TYPE)),
)?.meta?.width;
@@ -182,6 +193,8 @@ const ChartHolder = ({
}, [
component,
getComponentById,
isMobile,
editMode,
parentComponent.meta.width,
parentComponent.parents,
parentComponent.type,

View File

@@ -37,6 +37,7 @@ import {
Droppable,
} from 'src/dashboard/components/dnd/DragDroppable';
import DragHandle from 'src/dashboard/components/dnd/DragHandle';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
@@ -120,6 +121,18 @@ 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;
}
}
`}
`}
`;

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import {
Fragment,
useCallback,
@@ -351,6 +352,9 @@ const Tab = (props: TabProps): ReactElement => {
role="button"
tabIndex={0}
onClick={() => dispatch(setEditMode(true))}
onKeyDown={handleKeyboardActivation(() =>
dispatch(setEditMode(true)),
)}
>
{t('edit mode')}
</span>

View File

@@ -25,7 +25,8 @@ import {
useRef,
useState,
} from 'react';
import { styled } from '@apache-superset/core/theme';
import { css, styled } from '@apache-superset/core/theme';
import { isMobileConsumptionEnabled } from 'src/hooks/useIsMobile';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
@@ -81,6 +82,20 @@ 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};
}
}
`}
`;
export interface TabItem {

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { ReactNode, FC, memo } from 'react';
import { getFilterBarTestId } from '../utils';
@@ -32,6 +33,7 @@ export const FilterConfigurationLink: FC<FCBProps> = ({
<div
{...getFilterBarTestId('create-filter')}
onClick={onClick}
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
role="button"
tabIndex={0}
>

View File

@@ -190,6 +190,115 @@ test('does not render loading spinner when filter has no data source', () => {
expect(screen.getByTestId('mock-super-chart')).toBeInTheDocument();
});
const defaultFirstItemParentFilter = createMockFilter({
id: 'NATIVE_FILTER-PARENT',
controlValues: { defaultToFirstItem: true },
});
const guardChildFilter = createMockFilter({
id: 'NATIVE_FILTER-CHILD',
cascadeParentIds: ['NATIVE_FILTER-PARENT'],
});
const stateWithDefaultFirstItemParent = {
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': guardChildFilter,
'NATIVE_FILTER-PARENT': defaultFirstItemParentFilter,
},
filterSets: {},
},
};
test('guard: does not fetch while a defaultToFirstItem parent has not yet auto-selected', () => {
// Reproduces sc-108451: B should not fetch from unfiltered data before A selects.
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({});
renderFilterValue(
{
filter: guardChildFilter,
// Parent entry exists but filterState.value is undefined (no selection yet).
dataMaskSelected: {
'NATIVE_FILTER-PARENT': { filterState: {}, extraFormData: {} },
},
},
stateWithDefaultFirstItemParent,
);
expect(mockGetChartDataRequest).not.toHaveBeenCalled();
});
test('guard: fetches once a defaultToFirstItem parent has set its first value', async () => {
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [{ model: 'Corolla' }] }] },
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
});
renderFilterValue(
{
filter: guardChildFilter,
dataMaskSelected: {
'NATIVE_FILTER-PARENT': {
// Parent has auto-selected its first value → guard should pass.
filterState: { value: ['Toyota'] },
extraFormData: {
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
},
},
},
},
stateWithDefaultFirstItemParent,
);
await waitFor(() => {
expect(mockGetChartDataRequest).toHaveBeenCalled();
});
});
test('guard: does not block fetch for a parent without defaultToFirstItem', async () => {
// Non-defaultToFirstItem parents with values should pass the guard as before.
mockGetChartDataRequest.mockResolvedValue({
response: { status: 200 },
json: { result: [{ data: [] }] },
});
mockUseTransitiveParentIds.mockReturnValue(['NATIVE_FILTER-PARENT']);
mockUseFilterDependencies.mockReturnValue({
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
});
const regularParent = createMockFilter({ id: 'NATIVE_FILTER-PARENT' });
renderFilterValue(
{
filter: guardChildFilter,
dataMaskSelected: {
'NATIVE_FILTER-PARENT': {
filterState: { value: ['Toyota'] },
extraFormData: {
filters: [{ col: 'make', op: 'IN', val: ['Toyota'] }],
},
},
},
},
{
nativeFilters: {
filters: {
'NATIVE_FILTER-CHILD': guardChildFilter,
'NATIVE_FILTER-PARENT': regularParent,
},
filterSets: {},
},
},
);
await waitFor(() => {
expect(mockGetChartDataRequest).toHaveBeenCalled();
});
});
test('skips data fetch when cascade parent filters have no values selected', () => {
// useFilterDependencies returns dependencies with a filter (from parent defaults),
// but dataMaskSelected has no extraFormData for the parent -- counts disagree, so

View File

@@ -44,7 +44,7 @@ import {
} from '@superset-ui/core';
import { styled, SupersetTheme } from '@apache-superset/core/theme';
import { useTheme } from '@emotion/react';
import { useDispatch, useSelector } from 'react-redux';
import { useDispatch, useSelector, shallowEqual } from 'react-redux';
import { isEqual, isEqualWith } from 'lodash-es';
import { getChartDataRequest } from 'src/components/Chart/chartAction';
import { ErrorAlert, ErrorMessageWithStackTrace } from 'src/components';
@@ -125,6 +125,19 @@ const FilterValue: FC<FilterValueProps> = ({
const transitiveParentIds = useTransitiveParentIds(id);
const shouldRefresh = useShouldFilterRefresh();
// Derive only the defaultToFirstItem flag per filter to avoid re-renders
// when unrelated filter config fields change.
const parentDefaultToFirstItem = useSelector(
(state: RootState) =>
Object.fromEntries(
Object.entries(state.nativeFilters?.filters ?? {}).map(([fId, f]) => [
fId,
Boolean(f.controlValues?.defaultToFirstItem),
]),
),
shallowEqual,
);
const behaviors = useMemo(
() => [
isCustomization ? Behavior.ChartCustomization : Behavior.NativeFilter,
@@ -203,6 +216,21 @@ const FilterValue: FC<FilterValueProps> = ({
// direct parents) so the counts line up with `dependencies`, which is
// itself built from the transitive chain by `useFilterDependencies`.
// Block if any parent with defaultToFirstItem hasn't auto-selected yet.
// Without this, the child fetches unfiltered options before the parent
// auto-selects, leading to a stale first-value dispatch that never
// gets corrected because subsequent re-selections are not first-initialization.
const hasDefaultFirstParentPending = transitiveParentIds.some(pId => {
const parentMask = dataMaskSelected?.[pId];
return (
parentDefaultToFirstItem[pId] &&
parentMask?.filterState?.value === undefined
);
});
if (hasDefaultFirstParentPending) {
return;
}
let selectedParentFilterValueCounts = 0;
let isTimeRangeSelected = false;
transitiveParentIds.forEach(pId => {
@@ -307,6 +335,7 @@ const FilterValue: FC<FilterValueProps> = ({
dataMaskSelected,
setHasDepsFilterValue,
transitiveParentIds,
parentDefaultToFirstItem,
]);
useEffect(() => {

View File

@@ -32,7 +32,7 @@ import {
import { useSelector } from 'react-redux';
import cx from 'classnames';
import { t } from '@apache-superset/core/translation';
import { styled, useTheme } from '@apache-superset/core/theme';
import { css, 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';
@@ -141,6 +141,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
onPendingCustomizationDataMaskChange,
toggleFiltersBar,
width,
mobileMode,
}) => {
const theme = useTheme();
const [isScrolling, setIsScrolling] = useState(false);
@@ -260,33 +261,56 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
{...getFilterBarTestId()}
className={cx({ open: filtersOpen })}
width={width}
css={
mobileMode &&
css`
width: 100%;
&.open {
width: 100%;
}
`
}
>
<CollapsedBar
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
role="button"
tabIndex={0}
offset={offset}
{!mobileMode && (
<CollapsedBar
{...getFilterBarTestId('collapsable')}
className={cx({ open: !filtersOpen })}
onClick={openFiltersBar}
role="button"
tabIndex={0}
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;
`
}
>
<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} />
{!mobileMode && <Header toggleFiltersBar={toggleFiltersBar} />}
{!isInitialized ? (
<div
css={{

View File

@@ -469,7 +469,8 @@ const FilterBar: FC<FiltersBarProps> = ({
);
const pendingItems = (
Object.values(pendingChartCustomizations).filter(Boolean) as (
ChartCustomization | ChartCustomizationDivider
| ChartCustomization
| ChartCustomizationDivider
)[]
).filter(item => existingCustomizationIds.has(item.id));
@@ -680,6 +681,7 @@ const FilterBar: FC<FiltersBarProps> = ({
}
toggleFiltersBar={verticalConfig.toggleFiltersBar}
width={verticalConfig.width}
mobileMode={verticalConfig.mobileMode}
clearAllTriggers={clearAllTriggers}
onClearAllComplete={handleClearAllComplete}
/>

View File

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

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { forwardRef, useCallback, useState } from 'react';
import { t } from '@apache-superset/core/translation';
@@ -199,6 +200,7 @@ const FilterTitleContainer = forwardRef<HTMLDivElement, Props>(
e.preventDefault();
restoreFilter(id);
}}
onKeyDown={handleKeyboardActivation(() => restoreFilter(id))}
>
{t('Undo?')}
</span>

View File

@@ -16,6 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { handleKeyboardActivation } from '@superset-ui/core';
import { forwardRef, useState } from 'react';
import { t } from '@apache-superset/core/translation';
@@ -167,6 +168,7 @@ const ItemTitleContainer = forwardRef<HTMLDivElement, Props>(
e.preventDefault();
restoreItem(id);
}}
onKeyDown={handleKeyboardActivation(() => restoreItem(id))}
>
{t('Undo?')}
</span>

View File

@@ -367,6 +367,9 @@ export interface SliceEntitiesState {
export enum MenuKeys {
DownloadAsImage = 'download_as_image',
DownloadAsPngTransparent = 'download_as_png_transparent',
DownloadAsPngSolid = 'download_as_png_solid',
DownloadAsPdf = 'download_as_pdf',
ExploreChart = 'explore_chart',
ExportCsv = 'export_csv',
ExportPivotCsv = 'export_pivot_csv',

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