mirror of
https://github.com/apache/superset.git
synced 2026-07-17 04:05:37 +00:00
Compare commits
51 Commits
chore/drop
...
mobile-das
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a175324cff | ||
|
|
9da3908bec | ||
|
|
76c247513e | ||
|
|
f697a0c24d | ||
|
|
13d38a9cbd | ||
|
|
8603048518 | ||
|
|
635b18103d | ||
|
|
d57569c54a | ||
|
|
409605de70 | ||
|
|
8ce6d42942 | ||
|
|
2bbb7d0638 | ||
|
|
1b0c6aaed3 | ||
|
|
1f786f1949 | ||
|
|
c540f782a3 | ||
|
|
6fe4655ba2 | ||
|
|
689fc34ac2 | ||
|
|
e564389a01 | ||
|
|
da518d7a10 | ||
|
|
c6da740ce2 | ||
|
|
e28b259de0 | ||
|
|
beb9d53687 | ||
|
|
90f9238f8a | ||
|
|
f38fff2a19 | ||
|
|
cec9afb165 | ||
|
|
753113d169 | ||
|
|
5c8e14e9dc | ||
|
|
e66d58361a | ||
|
|
7bc1895050 | ||
|
|
071c431580 | ||
|
|
53c4603c8c | ||
|
|
f010affbc2 | ||
|
|
6c2b7aceb5 | ||
|
|
2af66b2c9b | ||
|
|
a540f56f5c | ||
|
|
aa85455a5c | ||
|
|
d80267d00b | ||
|
|
7953382d10 | ||
|
|
abafe195bf | ||
|
|
b4373f60b3 | ||
|
|
6dcd95e14a | ||
|
|
ad8f2cf268 | ||
|
|
9db88203e1 | ||
|
|
13121fcd58 | ||
|
|
be768efc0f | ||
|
|
d68e84e731 | ||
|
|
0a3c263606 | ||
|
|
bd9ba24266 | ||
|
|
4446967d0f | ||
|
|
c04a0295ef | ||
|
|
e3a0d0e41e | ||
|
|
256cc71c87 |
3
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
3
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -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:
|
||||
|
||||
1
.github/actions/file-changes-action
vendored
1
.github/actions/file-changes-action
vendored
Submodule .github/actions/file-changes-action deleted from a6ca26c142
10
.github/dependabot.yml
vendored
10
.github/dependabot.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/bump-python-package.yml
vendored
2
.github/workflows/bump-python-package.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/codeql-analysis.yml
vendored
4
.github/workflows/codeql-analysis.yml
vendored
@@ -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}}"
|
||||
|
||||
11
.github/workflows/docker.yml
vendored
11
.github/workflows/docker.yml
vendored
@@ -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
|
||||
|
||||
81
.github/workflows/pre-commit.yml
vendored
81
.github/workflows/pre-commit.yml
vendored
@@ -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=$?
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
2
.github/workflows/superset-frontend.yml
vendored
2
.github/workflows/superset-frontend.yml
vendored
@@ -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 \
|
||||
.
|
||||
|
||||
|
||||
2
.github/workflows/tag-release.yml
vendored
2
.github/workflows/tag-release.yml
vendored
@@ -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
3
.gitmodules
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
|
||||
---
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
6
docs/static/feature-flags.json
vendored
6
docs/static/feature-flags.json
vendored
@@ -69,6 +69,12 @@
|
||||
"lifecycle": "development",
|
||||
"description": "Enable Matrixify feature for matrix-style chart layouts"
|
||||
},
|
||||
{
|
||||
"name": "MOBILE_CONSUMPTION_MODE",
|
||||
"default": false,
|
||||
"lifecycle": "development",
|
||||
"description": "Serve a consumption-only mobile experience (dashboards, dashboard list, and home page) on small screens; other views show a \"not supported on mobile\" screen. Authoring features are hidden on mobile when enabled."
|
||||
},
|
||||
{
|
||||
"name": "OPTIMIZE_SQL",
|
||||
"default": false,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
|
||||
@@ -21,7 +21,7 @@ under the License.
|
||||
|
||||
[](https://badge.fury.io/py/apache-superset-core)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -21,7 +21,7 @@ under the License.
|
||||
|
||||
[](https://badge.fury.io/py/apache-superset-extensions-cli)
|
||||
[](https://opensource.org/licenses/Apache-2.0)
|
||||
[](https://www.python.org/downloads/)
|
||||
[](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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
16
superset-frontend/package-lock.json
generated
16
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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} />);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>"{formatNumber(formatString, v)}"</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>"{formatNumber(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
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>"{formatTime(formatString, v)}"</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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
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>"{formatTime(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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'}`
|
||||
|
||||
164
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal file
164
superset-frontend/spec/helpers/mobileTestUtils.ts
Normal 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),
|
||||
});
|
||||
@@ -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')}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
.
|
||||
|
||||
@@ -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;
|
||||
`}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}`
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' })),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
78
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal file
78
superset-frontend/src/components/MobileRouteGuard/index.tsx
Normal 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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}`
|
||||
: ''}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -121,5 +121,6 @@ export const useNativeFilters = () => {
|
||||
dashboardFiltersOpen,
|
||||
toggleDashboardFiltersOpen,
|
||||
nativeFiltersEnabled,
|
||||
hasFilters: filterValues.length > 0,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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(),
|
||||
}));
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -43,6 +43,10 @@ export interface HeaderDropdownProps {
|
||||
forceRefreshAllCharts: () => unknown;
|
||||
hasUnsavedChanges: boolean;
|
||||
isLoading: boolean;
|
||||
isMobile?: boolean;
|
||||
isStarred?: boolean;
|
||||
isPublished?: boolean;
|
||||
saveFaveStar?: (id: number, isStarred: boolean) => void;
|
||||
layout: Layout;
|
||||
onSave: (...args: unknown[]) => unknown;
|
||||
refreshFrequency: number;
|
||||
|
||||
@@ -37,6 +37,7 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { HeaderDropdownProps } from 'src/dashboard/components/Header/types';
|
||||
import { usePermissions } from 'src/hooks/usePermissions';
|
||||
import getUserName from 'src/utils/getUserName';
|
||||
|
||||
export const useHeaderActionsMenu = ({
|
||||
customCss,
|
||||
@@ -56,6 +57,10 @@ export const useHeaderActionsMenu = ({
|
||||
userCanCurate,
|
||||
userCanExport,
|
||||
isLoading,
|
||||
isMobile,
|
||||
isStarred,
|
||||
isPublished,
|
||||
saveFaveStar,
|
||||
lastModifiedTime,
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
@@ -117,6 +122,11 @@ export const useHeaderActionsMenu = ({
|
||||
case MenuKeys.ManageEmbedded:
|
||||
manageEmbedded();
|
||||
break;
|
||||
case 'toggle-favorite':
|
||||
if (saveFaveStar && isStarred !== undefined) {
|
||||
saveFaveStar(dashboardId, isStarred);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@@ -128,6 +138,9 @@ export const useHeaderActionsMenu = ({
|
||||
showPropertiesModal,
|
||||
showRefreshModal,
|
||||
manageEmbedded,
|
||||
saveFaveStar,
|
||||
dashboardId,
|
||||
isStarred,
|
||||
history,
|
||||
location,
|
||||
],
|
||||
@@ -205,6 +218,52 @@ export const useHeaderActionsMenu = ({
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// Mobile-only: show dashboard info items in menu
|
||||
if (isMobile && !editMode) {
|
||||
// Favorite toggle
|
||||
if (saveFaveStar) {
|
||||
menuItems.push({
|
||||
key: 'toggle-favorite',
|
||||
label: isStarred ? t('Remove from favorites') : t('Add to favorites'),
|
||||
});
|
||||
}
|
||||
|
||||
// Published status
|
||||
menuItems.push({
|
||||
key: 'status-info',
|
||||
label: isPublished ? t('Status: Published') : t('Status: Draft'),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Editor info
|
||||
const editorNames = dashboardInfo?.editors?.length
|
||||
? dashboardInfo.editors
|
||||
.map((editor: { label?: string }) => editor.label)
|
||||
.filter(Boolean)
|
||||
.join(', ')
|
||||
: t('None');
|
||||
menuItems.push({
|
||||
key: 'owner-info',
|
||||
label: t('Owner: %(names)s', { names: editorNames }),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
// Last modified
|
||||
const modifiedBy =
|
||||
getUserName(dashboardInfo?.changed_by) || t('Not available');
|
||||
const modifiedDate = dashboardInfo?.changed_on_delta_humanized || '';
|
||||
menuItems.push({
|
||||
key: 'modified-info',
|
||||
label: t('Modified %(date)s by %(user)s', {
|
||||
date: modifiedDate,
|
||||
user: modifiedBy,
|
||||
}),
|
||||
disabled: true,
|
||||
});
|
||||
|
||||
menuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
// Refresh dashboard
|
||||
if (!editMode) {
|
||||
menuItems.push({
|
||||
@@ -224,8 +283,8 @@ export const useHeaderActionsMenu = ({
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle fullscreen
|
||||
if (!editMode && !isEmbedded) {
|
||||
// Toggle fullscreen (hide on mobile)
|
||||
if (!editMode && !isEmbedded && !isMobile) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ToggleFullscreen,
|
||||
label: getUrlParam(URL_PARAMS.standalone)
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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} />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function URLShortLinkButton({
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
>
|
||||
<CopyToClipboard
|
||||
text={shortUrl}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
`}
|
||||
`}
|
||||
`;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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={{
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user