mirror of
https://github.com/apache/superset.git
synced 2026-06-15 04:29:18 +00:00
Compare commits
5 Commits
theme_uuid
...
examples
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0796aa1c6d | ||
|
|
14e6ec7d9f | ||
|
|
14ffa69e0b | ||
|
|
ef4cf2b430 | ||
|
|
48d8c91b19 |
@@ -1,20 +0,0 @@
|
||||
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
|
||||
FROM python:3.11.13-bookworm AS base
|
||||
|
||||
# Install system dependencies that Superset needs
|
||||
# This layer will be cached across Codespace sessions
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv for fast Python package management
|
||||
# This will also be cached in the image
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
|
||||
|
||||
# Set the cargo/bin directory in PATH for all users
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
@@ -3,14 +3,3 @@
|
||||
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
|
||||
|
||||
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**
|
||||
|
||||
## Pre-installed Development Environment
|
||||
|
||||
When you create a new Codespace from this repository, it automatically:
|
||||
|
||||
1. **Creates a Python virtual environment** using `uv venv`
|
||||
2. **Installs all development dependencies** via `uv pip install -r requirements/development.txt`
|
||||
3. **Sets up pre-commit hooks** with `pre-commit install`
|
||||
4. **Activates the virtual environment** automatically in all terminals
|
||||
|
||||
The virtual environment is located at `/workspaces/{repository-name}/.venv` and is automatically activated through environment variables set in the devcontainer configuration.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Superset Codespaces environment setup
|
||||
# This file is appended to ~/.bashrc during Codespace setup
|
||||
|
||||
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
|
||||
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||
|
||||
if [ -n "$WORKSPACE_DIR" ]; then
|
||||
# Check if virtual environment exists
|
||||
if [ -d "$WORKSPACE_DIR/.venv" ]; then
|
||||
# Activate the virtual environment
|
||||
source "$WORKSPACE_DIR/.venv/bin/activate"
|
||||
echo "✅ Python virtual environment activated"
|
||||
|
||||
# Verify pre-commit is installed and set up
|
||||
if command -v pre-commit &> /dev/null; then
|
||||
echo "✅ pre-commit is available ($(pre-commit --version))"
|
||||
# Install git hooks if not already installed
|
||||
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
|
||||
echo "🪝 Installing pre-commit hooks..."
|
||||
cd "$WORKSPACE_DIR" && pre-commit install
|
||||
fi
|
||||
else
|
||||
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
|
||||
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
|
||||
fi
|
||||
|
||||
# Always cd to the workspace directory for convenience
|
||||
cd "$WORKSPACE_DIR"
|
||||
fi
|
||||
|
||||
# Add helpful aliases for Superset development
|
||||
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
|
||||
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
|
||||
|
||||
# Show helpful message on login
|
||||
echo ""
|
||||
echo "🚀 Superset Codespaces Environment"
|
||||
echo "=================================="
|
||||
|
||||
# Check if Superset is running
|
||||
if docker ps 2>/dev/null | grep -q "superset"; then
|
||||
echo "✅ Superset is running!"
|
||||
echo " - Check the 'Ports' tab for your live Superset URL"
|
||||
echo " - Initial startup takes 10-20 minutes"
|
||||
echo " - Login: admin/admin"
|
||||
else
|
||||
echo "⚠️ Superset is not running. Use: start-superset"
|
||||
# Check if there's a startup log
|
||||
if [ -f "/tmp/superset-startup.log" ]; then
|
||||
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Quick commands:"
|
||||
echo " start-superset - Start Superset with Docker Compose"
|
||||
echo " setup-dev - Set up Python environment (if not already done)"
|
||||
echo " pre-commit run - Run pre-commit checks on staged files"
|
||||
echo ""
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to build and push the devcontainer image to GitHub Container Registry
|
||||
# This allows caching the image between Codespace sessions
|
||||
|
||||
# You'll need to run this with appropriate GitHub permissions
|
||||
# gh auth login --scopes write:packages
|
||||
|
||||
REGISTRY="ghcr.io"
|
||||
OWNER="apache"
|
||||
REPO="superset"
|
||||
TAG="devcontainer-base"
|
||||
|
||||
echo "Building devcontainer image..."
|
||||
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
|
||||
|
||||
echo "Pushing to GitHub Container Registry..."
|
||||
docker push $REGISTRY/$OWNER/$REPO:$TAG
|
||||
|
||||
echo "Done! Update .devcontainer/devcontainer.json to use:"
|
||||
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// Option 1: Use pre-built image directly
|
||||
// "image": "ghcr.io/apache/superset:devcontainer-base",
|
||||
|
||||
// Option 2: Build from Dockerfile with cache (current approach)
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
// Cache from the Apache registry image
|
||||
"cacheFrom": ["ghcr.io/apache/superset:devcontainer-base"]
|
||||
},
|
||||
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
|
||||
// Using the same base as Dockerfile, but non-slim for dev tools
|
||||
"image": "python:3.11.13-bookworm",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
@@ -39,17 +32,10 @@
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
|
||||
// Auto-start Superset after ensuring Docker is ready
|
||||
// Run in foreground to see any errors, but don't block on failures
|
||||
"postStartCommand": "bash -c 'echo \"Waiting 30s for services to initialize...\"; sleep 30; .devcontainer/start-superset.sh || echo \"⚠️ Auto-start failed - run start-superset manually\"'",
|
||||
|
||||
// Set environment variables
|
||||
"remoteEnv": {
|
||||
// Removed automatic venv activation to prevent startup issues
|
||||
// The setup script will handle this
|
||||
},
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.sh",
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
|
||||
@@ -3,76 +3,30 @@
|
||||
|
||||
echo "🔧 Setting up Superset development environment..."
|
||||
|
||||
# System dependencies and uv are now pre-installed in the Docker image
|
||||
# This speeds up Codespace creation significantly!
|
||||
# The universal image has most tools, just need Superset-specific libs
|
||||
echo "📦 Installing Superset-specific dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh
|
||||
|
||||
# Create virtual environment using uv
|
||||
echo "🐍 Creating Python virtual environment..."
|
||||
if ! uv venv; then
|
||||
echo "❌ Failed to create virtual environment"
|
||||
exit 1
|
||||
fi
|
||||
# Install uv for fast Python package management
|
||||
echo "📦 Installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Install Python dependencies
|
||||
echo "📦 Installing Python dependencies..."
|
||||
if ! uv pip install -r requirements/development.txt; then
|
||||
echo "❌ Failed to install Python dependencies"
|
||||
echo "💡 You may need to run this manually after the Codespace starts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install pre-commit hooks
|
||||
echo "🪝 Installing pre-commit hooks..."
|
||||
if source .venv/bin/activate && pre-commit install; then
|
||||
echo "✅ Pre-commit hooks installed"
|
||||
else
|
||||
echo "⚠️ Pre-commit hooks installation failed (non-critical)"
|
||||
fi
|
||||
# Add cargo/bin to PATH for uv
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
|
||||
|
||||
# Install Claude Code CLI via npm
|
||||
echo "🤖 Installing Claude Code..."
|
||||
if npm install -g @anthropic-ai/claude-code; then
|
||||
echo "✅ Claude Code installed"
|
||||
else
|
||||
echo "⚠️ Claude Code installation failed (non-critical)"
|
||||
fi
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Make the start script executable
|
||||
chmod +x .devcontainer/start-superset.sh
|
||||
|
||||
# Add bashrc additions for automatic venv activation
|
||||
echo "🔧 Setting up automatic environment activation..."
|
||||
if [ -f ~/.bashrc ]; then
|
||||
# Check if we've already added our additions
|
||||
if ! grep -q "Superset Codespaces environment setup" ~/.bashrc; then
|
||||
echo "" >> ~/.bashrc
|
||||
cat .devcontainer/bashrc-additions >> ~/.bashrc
|
||||
echo "✅ Added automatic venv activation to ~/.bashrc"
|
||||
else
|
||||
echo "✅ Bashrc additions already present"
|
||||
fi
|
||||
else
|
||||
# Create bashrc if it doesn't exist
|
||||
cat .devcontainer/bashrc-additions > ~/.bashrc
|
||||
echo "✅ Created ~/.bashrc with automatic venv activation"
|
||||
fi
|
||||
|
||||
# Also add to zshrc since that's the default shell
|
||||
if [ -f ~/.zshrc ] || [ -n "$ZSH_VERSION" ]; then
|
||||
if ! grep -q "Superset Codespaces environment setup" ~/.zshrc; then
|
||||
echo "" >> ~/.zshrc
|
||||
cat .devcontainer/bashrc-additions >> ~/.zshrc
|
||||
echo "✅ Added automatic venv activation to ~/.zshrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Development environment setup complete!"
|
||||
echo ""
|
||||
echo "📝 The virtual environment will be automatically activated in new terminals"
|
||||
echo ""
|
||||
echo "🔄 To activate in this terminal, run:"
|
||||
echo " source ~/.bashrc"
|
||||
echo ""
|
||||
echo "🚀 To start Superset:"
|
||||
echo " start-superset"
|
||||
echo ""
|
||||
echo "🚀 Run '.devcontainer/start-superset.sh' to start Superset"
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
#!/bin/bash
|
||||
# Startup script for Superset in Codespaces
|
||||
|
||||
# Log to a file for debugging
|
||||
LOG_FILE="/tmp/superset-startup.log"
|
||||
echo "[$(date)] Starting Superset startup script" >> "$LOG_FILE"
|
||||
echo "[$(date)] User: $(whoami), PWD: $(pwd)" >> "$LOG_FILE"
|
||||
|
||||
echo "🚀 Starting Superset in Codespaces..."
|
||||
echo "🌐 Frontend will be available at port 9001"
|
||||
|
||||
@@ -18,37 +13,10 @@ else
|
||||
echo "📁 Using current directory: $(pwd)"
|
||||
fi
|
||||
|
||||
# Wait for Docker to be available
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while ! docker info > /dev/null 2>&1; do
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "❌ Docker failed to start after $max_attempts attempts"
|
||||
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
|
||||
echo "🔄 Please restart the Codespace or run this script manually later"
|
||||
exit 1
|
||||
fi
|
||||
echo " Attempt $((attempt + 1))/$max_attempts..."
|
||||
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo "✅ Docker is ready!"
|
||||
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
|
||||
|
||||
# Check if Superset containers are already running
|
||||
if docker ps | grep -q "superset"; then
|
||||
echo "✅ Superset containers are already running!"
|
||||
echo ""
|
||||
echo "🌐 To access Superset:"
|
||||
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
|
||||
echo " 2. Find port 9001 and click the globe icon to open"
|
||||
echo " 3. Wait 10-20 minutes for initial startup"
|
||||
echo ""
|
||||
echo "📝 Login credentials: admin/admin"
|
||||
exit 0
|
||||
# Check if docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Clean up any existing containers
|
||||
@@ -56,33 +24,16 @@ echo "🧹 Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose-light.yml down
|
||||
|
||||
# Start services
|
||||
echo "🏗️ Starting Superset in background (daemon mode)..."
|
||||
echo "🏗️ Building and starting services..."
|
||||
echo ""
|
||||
echo "📝 Once started, login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin"
|
||||
echo ""
|
||||
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
|
||||
|
||||
# Start in detached mode
|
||||
docker-compose -f docker-compose-light.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Docker Compose started successfully!"
|
||||
echo ""
|
||||
echo "📋 Important information:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "⏱️ Initial startup takes 10-20 minutes"
|
||||
echo "🌐 Check the 'Ports' tab for your Superset URL (port 9001)"
|
||||
echo "👤 Login: admin / admin"
|
||||
echo ""
|
||||
echo "📊 Useful commands:"
|
||||
echo " docker-compose -f docker-compose-light.yml logs -f # Follow logs"
|
||||
echo " docker-compose -f docker-compose-light.yml ps # Check status"
|
||||
echo " docker-compose -f docker-compose-light.yml down # Stop services"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "💤 Keeping terminal open for 60 seconds to test persistence..."
|
||||
sleep 60
|
||||
echo "✅ Test complete - check if this terminal is still visible!"
|
||||
|
||||
# Show final status
|
||||
docker-compose -f docker-compose-light.yml ps
|
||||
# Run docker-compose and capture exit code
|
||||
docker-compose -f docker-compose-light.yml up
|
||||
EXIT_CODE=$?
|
||||
|
||||
# If it failed, provide helpful instructions
|
||||
|
||||
@@ -55,6 +55,7 @@ esm/*
|
||||
tsconfig.tsbuildinfo
|
||||
.*ipynb
|
||||
.*yml
|
||||
.*yaml
|
||||
.*iml
|
||||
.esprintrc
|
||||
.prettierignore
|
||||
|
||||
@@ -23,6 +23,8 @@ This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
- [34346](https://github.com/apache/superset/pull/34346) The examples system has been migrated from Python-based scripts to YAML configuration files. The CLI command `superset load-examples` has been deprecated in favor of `superset examples load`. The old command still works but will show a deprecation warning. Additional example management commands are available under `superset examples` including `clear-old` and `reload`. If you have old Python-based examples loaded, the new YAML-based examples will not load automatically to preserve your existing data. To migrate to the new examples, run `superset examples clear-old --confirm` followed by `superset examples load`.
|
||||
**Note**: This change affects Cypress tests that rely on specific chart names from the old examples (e.g., "Num Births Trend", "Daily Totals"). These charts may not exist in the new YAML examples, causing test failures. Consider updating your Cypress tests or creating test-specific fixtures.
|
||||
- [33084](https://github.com/apache/superset/pull/33084) The DISALLOWED_SQL_FUNCTIONS configuration now includes additional potentially sensitive database functions across PostgreSQL, MySQL, SQLite, MS SQL Server, and ClickHouse. Existing queries using these functions may now be blocked. Review your SQL Lab queries and dashboards if you encounter "disallowed function" errors after upgrading
|
||||
- [34235](https://github.com/apache/superset/pull/34235) CSV exports now use `utf-8-sig` encoding by default to include a UTF-8 BOM, improving compatibility with Excel.
|
||||
- [34258](https://github.com/apache/superset/pull/34258) changing the default in Dockerfile to INCLUDE_CHROMIUM="false" (from "true") in the past. This ensures the `lean` layer is lean by default, and people can opt-in to the `chromium` layer by setting the build arg `INCLUDE_CHROMIUM=true`. This is a breaking change for anyone using the `lean` layer, as it will no longer include Chromium by default.
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
# If you choose to use this type of deployment make sure to
|
||||
# create you own docker environment file (docker/.env) with your own
|
||||
# unique random secure passwords and SECRET_KEY.
|
||||
#
|
||||
# For verbose logging during development:
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
|
||||
# -----------------------------------------------------------------------
|
||||
x-superset-image: &superset-image apachesuperset.docker.scarf.sh/apache/superset:${TAG:-latest-dev}
|
||||
x-superset-volumes:
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
# If you choose to use this type of deployment make sure to
|
||||
# create you own docker environment file (docker/.env) with your own
|
||||
# unique random secure passwords and SECRET_KEY.
|
||||
#
|
||||
# For verbose logging during development:
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
|
||||
# -----------------------------------------------------------------------
|
||||
x-superset-volumes:
|
||||
&superset-volumes # /app/pythonpath_docker will be appended to the PYTHONPATH in the final container
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
# If you choose to use this type of deployment make sure to
|
||||
# create you own docker environment file (docker/.env) with your own
|
||||
# unique random secure passwords and SECRET_KEY.
|
||||
#
|
||||
# For verbose logging during development:
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
|
||||
# -----------------------------------------------------------------------
|
||||
x-superset-user: &superset-user root
|
||||
x-superset-volumes: &superset-volumes
|
||||
|
||||
@@ -53,12 +53,7 @@ PYTHONPATH=/app/pythonpath:/app/docker/pythonpath_dev
|
||||
REDIS_HOST=redis
|
||||
REDIS_PORT=6379
|
||||
|
||||
# Development and logging configuration
|
||||
# FLASK_DEBUG: Enables Flask dev features (auto-reload, better error pages) - keep 'true' for development
|
||||
FLASK_DEBUG=true
|
||||
# SUPERSET_LOG_LEVEL: Controls Superset application logging verbosity (debug, info, warning, error, critical)
|
||||
SUPERSET_LOG_LEVEL=info
|
||||
|
||||
SUPERSET_APP_ROOT="/"
|
||||
SUPERSET_ENV=development
|
||||
SUPERSET_LOAD_EXAMPLES=yes
|
||||
@@ -71,3 +66,4 @@ SUPERSET_SECRET_KEY=TEST_NON_DEV_SECRET
|
||||
ENABLE_PLAYWRIGHT=false
|
||||
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true
|
||||
BUILD_SUPERSET_FRONTEND_IN_DOCKER=true
|
||||
SUPERSET_LOG_LEVEL=info
|
||||
|
||||
@@ -23,57 +23,25 @@ MIN_MEM_FREE_GB=3
|
||||
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
|
||||
|
||||
echo_mem_warn() {
|
||||
# Check if running in Codespaces first
|
||||
if [[ -n "${CODESPACES}" ]]; then
|
||||
echo "Memory available: Codespaces managed"
|
||||
return
|
||||
fi
|
||||
MEM_FREE_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_FREE_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
|
||||
# Check platform and get memory accordingly
|
||||
if [[ -f /proc/meminfo ]]; then
|
||||
# Linux
|
||||
if grep -q MemAvailable /proc/meminfo; then
|
||||
MEM_AVAIL_KB=$(awk '/MemAvailable/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_AVAIL_GB=$(awk '/MemAvailable/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
else
|
||||
MEM_AVAIL_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_AVAIL_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
fi
|
||||
elif [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS - use vm_stat to get free memory
|
||||
# vm_stat reports in pages, typically 4096 bytes per page
|
||||
PAGE_SIZE=$(pagesize)
|
||||
FREE_PAGES=$(vm_stat | awk '/Pages free:/ {print $3}' | tr -d '.')
|
||||
INACTIVE_PAGES=$(vm_stat | awk '/Pages inactive:/ {print $3}' | tr -d '.')
|
||||
# Free + inactive pages give us available memory (similar to MemAvailable on Linux)
|
||||
AVAIL_PAGES=$((FREE_PAGES + INACTIVE_PAGES))
|
||||
MEM_AVAIL_KB=$((AVAIL_PAGES * PAGE_SIZE / 1024))
|
||||
MEM_AVAIL_GB=$(echo "scale=2; $MEM_AVAIL_KB / 1024 / 1024" | bc)
|
||||
else
|
||||
# Other platforms
|
||||
echo "Memory available: Unable to determine"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MEM_AVAIL_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||
cat <<EOF
|
||||
===============================================
|
||||
======== Memory Insufficient Warning =========
|
||||
===============================================
|
||||
|
||||
It looks like you only have ${MEM_AVAIL_GB}GB of
|
||||
memory ${MEM_TYPE}. Please increase your Docker
|
||||
It looks like you only have ${MEM_FREE_GB}GB of
|
||||
memory free. Please increase your Docker
|
||||
resources to at least ${MIN_MEM_FREE_GB}GB
|
||||
|
||||
Note: During builds, available memory may be
|
||||
temporarily low due to caching and compilation.
|
||||
|
||||
===============================================
|
||||
======== Memory Insufficient Warning =========
|
||||
===============================================
|
||||
EOF
|
||||
else
|
||||
echo "Memory available: ${MEM_AVAIL_GB} GB"
|
||||
echo "Memory check Ok [${MEM_FREE_GB}GB free]"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
1
docker/pythonpath_dev/.gitignore
vendored
1
docker/pythonpath_dev/.gitignore
vendored
@@ -20,5 +20,4 @@
|
||||
# DON'T ignore the .gitignore
|
||||
!.gitignore
|
||||
!superset_config.py
|
||||
!superset_config_docker_light.py
|
||||
!superset_config_local.example
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
# mypy: disable-error-code="assignment,misc"
|
||||
#
|
||||
# This file is included in the final Docker image and SHOULD be overridden when
|
||||
# deploying the image to prod. Settings configured here are intended for use in local
|
||||
|
||||
@@ -87,66 +87,8 @@ Restart Superset to apply changes.
|
||||
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
|
||||
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
Add font URLs to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
|
||||
CUSTOM_FONT_URLS = [
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||||
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
]
|
||||
|
||||
# Update CSP to allow font sources
|
||||
TALISMAN_CONFIG = {
|
||||
"content_security_policy": {
|
||||
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
||||
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Custom Fonts in Themes
|
||||
|
||||
Once configured, reference the fonts in your theme configuration:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
|
||||
# ... other theme tokens
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or in the CRUD interface theme JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Font Sources
|
||||
|
||||
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
|
||||
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
|
||||
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
|
||||
|
||||
This feature works with the stock Docker image - no custom build required!
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Superset includes built-in light and dark themes
|
||||
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
||||
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
||||
- **Custom Fonts**: Load external fonts via configuration without rebuilding
|
||||
|
||||
@@ -137,7 +137,7 @@ contributing to Apache Superset more accessible to developers worldwide.
|
||||
|
||||
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=codespaces&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
|
||||
|
||||
:::caution
|
||||
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||
@@ -348,7 +348,7 @@ superset init
|
||||
|
||||
# Load some data to play with.
|
||||
# Note: you MUST have previously created an admin user with the username `admin` for this command to work.
|
||||
superset load-examples
|
||||
superset examples load
|
||||
|
||||
# Start the Flask dev web server from inside your virtualenv.
|
||||
# Note that your page may not have CSS at this point.
|
||||
@@ -421,6 +421,14 @@ Then make sure you run your WSGI server using the right worker type:
|
||||
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
|
||||
```
|
||||
|
||||
You can log anything to the browser console, including objects:
|
||||
|
||||
```python
|
||||
from superset import app
|
||||
app.logger.error('An exception occurred!')
|
||||
app.logger.info(form_data)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
|
||||
|
||||
@@ -26,14 +26,11 @@ Superset locally is using Docker Compose on a Linux or Mac OSX
|
||||
computer. Superset does not have official support for Windows. It's also the easiest
|
||||
way to launch a fully functioning **development environment** quickly.
|
||||
|
||||
Note that there are 4 major ways we support to run `docker compose`:
|
||||
Note that there are 3 major ways we support to run `docker compose`:
|
||||
|
||||
1. **docker-compose.yml:** for interactive development, where we mount your local folder with the
|
||||
frontend/backend files that you can edit and experience the changes you
|
||||
make in the app in real time
|
||||
1. **docker-compose-light.yml:** a lightweight configuration with minimal services (database,
|
||||
Superset app, and frontend dev server) for development. Uses in-memory caching instead of Redis
|
||||
and is designed for running multiple instances simultaneously
|
||||
1. **docker-compose-non-dev.yml** where we just build a more immutable image based on the
|
||||
local branch and get all the required images running. Changes in the local branch
|
||||
at the time you fire this up will be reflected, but changes to the code
|
||||
@@ -47,7 +44,7 @@ Note that there are 4 major ways we support to run `docker compose`:
|
||||
The `dev` builds include the `psycopg2-binary` required to connect
|
||||
to the Postgres database launched as part of the `docker compose` builds.
|
||||
|
||||
More on these approaches after setting up the requirements for either.
|
||||
More on these two approaches after setting up the requirements for either.
|
||||
|
||||
## Requirements
|
||||
|
||||
@@ -106,36 +103,13 @@ and help you start fresh. In the context of `docker compose` setting
|
||||
from within docker. This will slow down the startup, but will fix various npm-related issues.
|
||||
:::
|
||||
|
||||
### Option #2 - lightweight development with multiple instances
|
||||
|
||||
For a lighter development setup that uses fewer resources and supports running multiple instances:
|
||||
|
||||
```bash
|
||||
# Single lightweight instance (default port 9001)
|
||||
docker compose -f docker-compose-light.yml up
|
||||
|
||||
# Multiple instances with different ports
|
||||
NODE_PORT=9001 docker compose -p superset-1 -f docker-compose-light.yml up
|
||||
NODE_PORT=9002 docker compose -p superset-2 -f docker-compose-light.yml up
|
||||
NODE_PORT=9003 docker compose -p superset-3 -f docker-compose-light.yml up
|
||||
```
|
||||
|
||||
This configuration includes:
|
||||
- PostgreSQL database (internal network only)
|
||||
- Superset application server
|
||||
- Frontend development server with webpack hot reloading
|
||||
- In-memory caching (no Redis)
|
||||
- Isolated volumes and networks per instance
|
||||
|
||||
Access each instance at `http://localhost:{NODE_PORT}` (e.g., `http://localhost:9001`).
|
||||
|
||||
### Option #3 - build a set of immutable images from the local branch
|
||||
### Option #2 - build a set of immutable images from the local branch
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose-non-dev.yml up
|
||||
```
|
||||
|
||||
### Option #4 - boot up an official release
|
||||
### Option #3 - boot up an official release
|
||||
|
||||
```bash
|
||||
# Set the version you want to run
|
||||
|
||||
@@ -151,7 +151,7 @@ Finish installing by running through the following commands:
|
||||
superset fab create-admin
|
||||
|
||||
# Load some data to play with
|
||||
superset load_examples
|
||||
superset examples load
|
||||
|
||||
# Create default roles and permissions
|
||||
superset init
|
||||
|
||||
@@ -94,12 +94,67 @@ describe('Charts list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list mode', () => {
|
||||
before(() => {
|
||||
visitChartList();
|
||||
setGridMode('list');
|
||||
});
|
||||
|
||||
it('should load rows in list mode', () => {
|
||||
cy.getBySel('listview-table').should('be.visible');
|
||||
cy.getBySel('sort-header').eq(1).contains('Name');
|
||||
cy.getBySel('sort-header').eq(2).contains('Type');
|
||||
cy.getBySel('sort-header').eq(3).contains('Dataset');
|
||||
cy.getBySel('sort-header').eq(4).contains('On dashboards');
|
||||
cy.getBySel('sort-header').eq(5).contains('Owners');
|
||||
cy.getBySel('sort-header').eq(6).contains('Last modified');
|
||||
cy.getBySel('sort-header').eq(7).contains('Actions');
|
||||
});
|
||||
|
||||
it('should bulk select in list mode', () => {
|
||||
toggleBulkSelect();
|
||||
cy.get('[aria-label="Select all"]').click();
|
||||
cy.get('input[type="checkbox"]:checked').should('have.length', 26);
|
||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
||||
cy.getBySel('bulk-select-action')
|
||||
.should('have.length', 2)
|
||||
.then($btns => {
|
||||
expect($btns).to.contain('Delete');
|
||||
expect($btns).to.contain('Export');
|
||||
});
|
||||
cy.getBySel('bulk-select-deselect-all').click();
|
||||
cy.get('input[type="checkbox"]:checked').should('have.length', 0);
|
||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
||||
cy.getBySel('bulk-select-action').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('card mode', () => {
|
||||
before(() => {
|
||||
visitChartList();
|
||||
setGridMode('card');
|
||||
});
|
||||
|
||||
it('should load rows in card mode', () => {
|
||||
cy.getBySel('listview-table').should('not.exist');
|
||||
cy.getBySel('styled-card').should('have.length', 25);
|
||||
});
|
||||
|
||||
it('should bulk select in card mode', () => {
|
||||
toggleBulkSelect();
|
||||
cy.getBySel('styled-card').click({ multiple: true });
|
||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
||||
cy.getBySel('bulk-select-action')
|
||||
.should('have.length', 2)
|
||||
.then($btns => {
|
||||
expect($btns).to.contain('Delete');
|
||||
expect($btns).to.contain('Export');
|
||||
});
|
||||
cy.getBySel('bulk-select-deselect-all').click();
|
||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
||||
cy.getBySel('bulk-select-action').should('not.exist');
|
||||
});
|
||||
|
||||
it('should preserve other filters when sorting', () => {
|
||||
cy.getBySel('styled-card').should('have.length', 25);
|
||||
setFilter('Type', 'Big Number');
|
||||
|
||||
@@ -65,16 +65,11 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
|
||||
)
|
||||
.should('be.visible')
|
||||
.find('[role="menuitem"]')
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.click();
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).should('not.exist');
|
||||
.then($el => {
|
||||
cy.wrap($el)
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
||||
});
|
||||
|
||||
if (isLegacy) {
|
||||
return cy.wait('@legacyData');
|
||||
@@ -245,7 +240,7 @@ describe('Drill by modal', () => {
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it.only('opens the modal from the context menu', () => {
|
||||
it('opens the modal from the context menu', () => {
|
||||
openTableContextMenu('boy');
|
||||
drillBy('state').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import {
|
||||
addParentFilterWithValue,
|
||||
applyNativeFilterValueWithIndex,
|
||||
@@ -161,74 +160,6 @@ describe('Native filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
// cannot use saveNativeFilterSettings because there is a bug which
|
||||
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
|
||||
// to be saved when using dependent filters so,
|
||||
// you reload the window.
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
cy.reload();
|
||||
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// Check that dependent filter auto-selects the first item
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||
.eq(1)
|
||||
.should('contain.text', 'Bermuda');
|
||||
});
|
||||
|
||||
it('User can create filter depend on 2 other filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
|
||||
@@ -68,13 +68,11 @@ function verifyDashboardSearch() {
|
||||
function verifyDashboardLink() {
|
||||
interceptDashboardGet();
|
||||
openDashboardsAddedTo();
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
|
||||
cy.get('.ant-dropdown-menu-submenu-popup a')
|
||||
.first()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click({ force: true });
|
||||
.click();
|
||||
cy.wait('@get');
|
||||
}
|
||||
|
||||
|
||||
1601
superset-frontend/package-lock.json
generated
1601
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jest": "^30.0.4",
|
||||
"jest": "^30.0.2",
|
||||
"yeoman-test": "^10.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -177,7 +177,6 @@ const granularity: SharedControlConfig<'SelectControl'> = {
|
||||
'can type and use simple natural language as in `10 seconds`, ' +
|
||||
'`1 day` or `56 weeks`',
|
||||
),
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
@@ -205,7 +204,6 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
choices: (datasource as Dataset)?.time_grain_sqla || [],
|
||||
}),
|
||||
visibility: displayTimeRelatedControls,
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_range: SharedControlConfig<'DateFilterControl'> = {
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"d3-format": "^1.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-scale": "^3.0.0",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dompurify": "^3.2.4",
|
||||
@@ -59,7 +59,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^5.1.1",
|
||||
"reselect": "^4.0.0",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
|
||||
@@ -17,8 +17,11 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/** Type checking is disabled for this file due to reselect only supporting
|
||||
* TS declarations for selectors with up to 12 arguments. */
|
||||
// @ts-nocheck
|
||||
import { RefObject } from 'react';
|
||||
import { createSelector, lruMemoize } from 'reselect';
|
||||
import { createSelector } from 'reselect';
|
||||
import {
|
||||
AppSection,
|
||||
Behavior,
|
||||
@@ -34,7 +37,7 @@ import {
|
||||
SetDataMaskHook,
|
||||
} from '../types/Base';
|
||||
import { QueryData, DataRecordFilters } from '..';
|
||||
import { supersetTheme, SupersetTheme } from '../../theme';
|
||||
import { SupersetTheme } from '../../theme';
|
||||
|
||||
// TODO: more specific typing for these fields of ChartProps
|
||||
type AnnotationData = PlainObject;
|
||||
@@ -106,8 +109,6 @@ export interface ChartPropsConfig {
|
||||
theme: SupersetTheme;
|
||||
/* legend index */
|
||||
legendIndex?: number;
|
||||
inContextMenu?: boolean;
|
||||
emitCrossFilters?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 800;
|
||||
@@ -160,11 +161,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
|
||||
|
||||
theme: SupersetTheme;
|
||||
|
||||
constructor(
|
||||
config: ChartPropsConfig & { formData?: FormData } = {
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) {
|
||||
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
|
||||
const {
|
||||
annotationData = {},
|
||||
datasource = {},
|
||||
@@ -279,16 +276,5 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
|
||||
emitCrossFilters,
|
||||
theme,
|
||||
}),
|
||||
// Below config is to retain usage of 1-sized `lruMemoize` object in Reselect v4
|
||||
// Reselect v5 introduces `weakMapMemoize` which is more performant but potentially memory-leaky
|
||||
// due to infinite cache size.
|
||||
// Source: https://github.com/reduxjs/reselect/releases/tag/v5.0.1
|
||||
{
|
||||
memoize: lruMemoize,
|
||||
argsMemoize: lruMemoize,
|
||||
memoizeOptions: {
|
||||
maxSize: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('ChartProps', () => {
|
||||
});
|
||||
expect(props1).not.toBe(props2);
|
||||
});
|
||||
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
|
||||
it('selector returns a new chartProps if some input fields change', () => {
|
||||
const props1 = selector({
|
||||
width: 800,
|
||||
height: 600,
|
||||
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
|
||||
theme: supersetTheme,
|
||||
});
|
||||
expect(props1).not.toBe(props2);
|
||||
expect(props1).toBe(props3);
|
||||
expect(props1).not.toBe(props3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
||||
@@ -69,42 +69,34 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
overlay={
|
||||
<Menu
|
||||
multiple
|
||||
onClick={handleOnClick}
|
||||
onBlur={handleOnBlur}
|
||||
selectedKeys={selectedComparisonColumns}
|
||||
>
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</InfoText>
|
||||
{comparisonColumns.map((column: ComparisonColumn) => (
|
||||
<Menu.Item key={column.key}>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</InfoText>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map((column: ComparisonColumn) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
</CheckIconWrapper>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -589,7 +589,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show Cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -617,7 +617,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -631,7 +631,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'comparison_color_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Basic conditional formatting'),
|
||||
label: t('basic conditional formatting'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!isEmpty(controls?.time_compare?.value),
|
||||
@@ -672,7 +672,7 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'ConditionalFormattingControl',
|
||||
renderTrigger: true,
|
||||
label: t('Custom conditional formatting'),
|
||||
label: t('Custom Conditional Formatting'),
|
||||
extraColorChoices: [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
|
||||
@@ -358,22 +358,7 @@ const config: ControlPanelConfig = {
|
||||
['x_axis_time_format'],
|
||||
[xAxisLabelRotation],
|
||||
[xAxisLabelInterval],
|
||||
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||
[
|
||||
{
|
||||
name: 'show_query_identifiers',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show query identifiers'),
|
||||
description: t(
|
||||
'Add Query A and Query B identifiers to tooltips to help differentiate series',
|
||||
),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
|
||||
...richTooltipSection,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -212,7 +212,6 @@ export default function transformProps(
|
||||
sortSeriesAscendingB,
|
||||
timeGrainSqla,
|
||||
percentageThreshold,
|
||||
showQueryIdentifiers = false,
|
||||
metrics = [],
|
||||
metricsB = [],
|
||||
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
@@ -396,17 +395,10 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
let displayName: string;
|
||||
let displayName = `${entryName} (Query A)`;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameA} (Query A)`
|
||||
: MetricDisplayNameA;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -461,17 +453,10 @@ export default function transformProps(
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName: string;
|
||||
let displayName = `${entryName} (Query B)`;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameB} (Query B)`
|
||||
: MetricDisplayNameB;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -711,13 +696,14 @@ export default function transformProps(
|
||||
zoomable,
|
||||
),
|
||||
// @ts-ignore
|
||||
data: series
|
||||
data: rawSeriesA
|
||||
.concat(rawSeriesB)
|
||||
.filter(
|
||||
entry =>
|
||||
extractForecastSeriesContext((entry.name || '') as string).type ===
|
||||
ForecastSeriesEnum.Observation,
|
||||
)
|
||||
.map(entry => entry.id || entry.name || '')
|
||||
.map(entry => entry.name || '')
|
||||
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
|
||||
@@ -60,7 +60,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval?: number | string;
|
||||
colorScheme?: string;
|
||||
@@ -134,7 +133,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
groupbyB: [],
|
||||
zoomable: TIMESERIES_DEFAULTS.zoomable,
|
||||
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
|
||||
showQueryIdentifiers: false,
|
||||
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
|
||||
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
|
||||
...DEFAULT_TITLE_FORM_DATA,
|
||||
|
||||
@@ -95,27 +95,27 @@ function getTotalValuePadding({
|
||||
top: donut ? 'middle' : '0',
|
||||
left: 'center',
|
||||
};
|
||||
const LEGEND_HEIGHT = 15;
|
||||
const LEGEND_WIDTH = 215;
|
||||
if (chartPadding.top) {
|
||||
padding.top = donut
|
||||
? `${50 + (chartPadding.top / height / 2) * 100}%`
|
||||
: `${(chartPadding.top / height) * 100}%`;
|
||||
? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
: `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`;
|
||||
}
|
||||
if (chartPadding.bottom) {
|
||||
padding.top = donut
|
||||
? `${50 - (chartPadding.bottom / height / 2) * 100}%`
|
||||
? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
: '0';
|
||||
}
|
||||
if (chartPadding.left) {
|
||||
// When legend is on the left, shift text right to center it in the available space
|
||||
const leftPaddingPercent = (chartPadding.left / width) * 100;
|
||||
const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
padding.left = `${
|
||||
50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
}
|
||||
if (chartPadding.right) {
|
||||
// When legend is on the right, shift text left to center it in the available space
|
||||
const rightPaddingPercent = (chartPadding.right / width) * 100;
|
||||
const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
padding.left = `${
|
||||
50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
}
|
||||
return padding;
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export default function transformProps(
|
||||
name: otherName,
|
||||
value: otherSum,
|
||||
itemStyle: {
|
||||
color: theme.colorText,
|
||||
color: theme.colors.grayscale.dark1,
|
||||
opacity:
|
||||
filterState.selectedValues &&
|
||||
!filterState.selectedValues.includes(otherName)
|
||||
@@ -368,7 +368,7 @@ export default function transformProps(
|
||||
const defaultLabel = {
|
||||
formatter,
|
||||
show: showLabels,
|
||||
color: theme.colorText,
|
||||
color: theme.colors.grayscale.dark2,
|
||||
};
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
@@ -403,7 +403,7 @@ export default function transformProps(
|
||||
label: {
|
||||
show: true,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
backgroundColor: theme.colors.grayscale.light5,
|
||||
},
|
||||
},
|
||||
data: transformedData,
|
||||
@@ -445,7 +445,6 @@ export default function transformProps(
|
||||
text: t('Total: %s', numberFormatter(totalValue)),
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: theme.colorText,
|
||||
},
|
||||
z: 10,
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
width,
|
||||
echartOptions,
|
||||
setDataMask,
|
||||
labelMap,
|
||||
selectedValues,
|
||||
formData,
|
||||
onContextMenu,
|
||||
@@ -51,47 +52,45 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(treePathInfo: TreePathInfo[]) => {
|
||||
const treePath = extractTreePathInfo(treePathInfo);
|
||||
const joinedTreePath = treePath.join(',');
|
||||
const value = treePath[treePath.length - 1];
|
||||
|
||||
const isCurrentValueSelected =
|
||||
Object.values(selectedValues).includes(joinedTreePath);
|
||||
|
||||
if (!columns?.length || isCurrentValueSelected) {
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
value: null,
|
||||
selectedValues: [],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
const name = treePath.join(',');
|
||||
const selected = Object.values(selectedValues);
|
||||
let values: string[];
|
||||
if (selected.includes(name)) {
|
||||
values = selected.filter(v => v !== name);
|
||||
} else {
|
||||
values = [name];
|
||||
}
|
||||
const labels = values.map(value => labelMap[value]);
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{
|
||||
col: columns[treePath.length - 1],
|
||||
op: '==' as const,
|
||||
val: value,
|
||||
},
|
||||
],
|
||||
filters:
|
||||
values.length === 0 || !columns
|
||||
? []
|
||||
: columns.slice(0, treePath.length).map((col, idx) => {
|
||||
const val = labels.map(v => v[idx]);
|
||||
if (val === null || val === undefined)
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IN' as const,
|
||||
val: val as (string | number | boolean)[],
|
||||
};
|
||||
}),
|
||||
},
|
||||
filterState: {
|
||||
value,
|
||||
selectedValues: [joinedTreePath],
|
||||
value: labels.length ? labels : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
isCurrentValueSelected: selected.includes(name),
|
||||
};
|
||||
},
|
||||
[columns, selectedValues],
|
||||
[columns, labelMap, selectedValues],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -102,7 +101,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
|
||||
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
||||
},
|
||||
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
|
||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
|
||||
@@ -71,7 +71,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
truncateXAxis: true,
|
||||
truncateYAxis: false,
|
||||
yAxisBounds: [null, null],
|
||||
|
||||
@@ -116,48 +116,49 @@ const chartPropsConfig = {
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: false,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
// Check that series IDs don't include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
expect(transformed).toEqual(
|
||||
expect.objectContaining({
|
||||
echartOptions: expect.objectContaining({
|
||||
series: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query A), boy',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query A), girl',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
// Query B — Bar series
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query B), boy',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query B), girl',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num, girl');
|
||||
expect(seriesIds).toContain('sum__num, boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), boy');
|
||||
});
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||
const chartPropsConfigWithIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
// Check that series IDs include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query B), boy');
|
||||
expect(seriesIds).not.toContain('sum__num, girl');
|
||||
expect(seriesIds).not.toContain('sum__num, boy');
|
||||
});
|
||||
|
||||
@@ -221,157 +221,6 @@ describe('Pie label string template', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Total value positioning with legends', () => {
|
||||
const getChartPropsWithLegend = (
|
||||
showTotal = true,
|
||||
showLegend = true,
|
||||
legendOrientation = 'right',
|
||||
donut = true,
|
||||
): EchartsPieChartProps => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['category'],
|
||||
viz_type: 'pie',
|
||||
show_total: showTotal,
|
||||
show_legend: showLegend,
|
||||
legend_orientation: legendOrientation,
|
||||
donut,
|
||||
};
|
||||
|
||||
return new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
|
||||
{ category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
}) as EchartsPieChartProps;
|
||||
};
|
||||
|
||||
it('should center total text when legend is on the right', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
style: expect.objectContaining({
|
||||
text: expect.stringContaining('Total:'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be less than 50% (shifted left)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeLessThan(50);
|
||||
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on the left', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be greater than 50% (shifted right)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeGreaterThan(50);
|
||||
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on top', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for top legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||
});
|
||||
|
||||
it('should center total text when legend is on bottom', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for bottom legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||
});
|
||||
|
||||
it('should use default positioning when no legend is shown', () => {
|
||||
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle regular pie chart (non-donut) positioning', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
top: '0', // Non-donut charts use '0' as default top position
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show total graphic when showTotal is false', () => {
|
||||
const props = getChartPropsWithLegend(false, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other category', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel';
|
||||
|
||||
describe('Bar Chart Control Panel', () => {
|
||||
describe('x_axis_time_format control', () => {
|
||||
it('should include x_axis_time_format control in the panel', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Look for x_axis_time_format control in all sections and rows
|
||||
let foundTimeFormatControl = false;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
foundTimeFormatControl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundTimeFormatControl).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct default value for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toBeDefined();
|
||||
expect(timeFormatControl.config.default).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have visibility function for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config.visibility).toBeDefined();
|
||||
expect(typeof timeFormatControl.config.visibility).toBe('function');
|
||||
|
||||
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||
// The important part is that the control has proper visibility configuration
|
||||
});
|
||||
|
||||
it('should have proper control configuration', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toMatchObject({
|
||||
default: 'smart_date',
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
});
|
||||
|
||||
// Should have a description that includes D3 time format docs
|
||||
expect(timeFormatControl.config.description).toContain('D3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control panel structure for bar charts', () => {
|
||||
it('should have Chart Orientation section', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const orientationSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Orientation',
|
||||
);
|
||||
|
||||
expect(orientationSection).toBeDefined();
|
||||
expect(orientationSection!.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it('should have Chart Options section with X Axis controls', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const chartOptionsSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Options',
|
||||
);
|
||||
|
||||
expect(chartOptionsSection).toBeDefined();
|
||||
expect(chartOptionsSection!.expanded).toBe(true);
|
||||
|
||||
// Should contain X Axis subsection header - this is sufficient proof
|
||||
expect(chartOptionsSection!.controlSetRows).toBeDefined();
|
||||
expect(chartOptionsSection!.controlSetRows!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper form data overrides', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
expect(config.formDataOverrides).toBeDefined();
|
||||
expect(typeof config.formDataOverrides).toBe('function');
|
||||
|
||||
// Test the form data override function
|
||||
const mockFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
metrics: ['test_metric'],
|
||||
groupby: ['test_column'],
|
||||
other_field: 'test',
|
||||
};
|
||||
|
||||
const result = config.formDataOverrides!(mockFormData);
|
||||
|
||||
expect(result).toHaveProperty('metrics');
|
||||
expect(result).toHaveProperty('groupby');
|
||||
expect(result).toHaveProperty('other_field', 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, SqlaFormData, supersetTheme } from '@superset-ui/core';
|
||||
import { EchartsTimeseriesChartProps } from '../../../src/types';
|
||||
import transformProps from '../../../src/Timeseries/transformProps';
|
||||
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||
import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
|
||||
|
||||
describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: SqlaFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: '__timestamp',
|
||||
metric: ['Sales', 'Marketing', 'Operations'],
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
orientation: 'vertical',
|
||||
};
|
||||
|
||||
const timeseriesData = [
|
||||
{
|
||||
data: [
|
||||
{ Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01
|
||||
{ Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01
|
||||
{ Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01
|
||||
],
|
||||
colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'],
|
||||
coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: timeseriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('Default xAxisTimeFormat', () => {
|
||||
it('should use smart_date as default xAxisTimeFormat', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Check that the x-axis has a formatter applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => {
|
||||
const formDataWithoutTimeFormat = {
|
||||
...baseFormData,
|
||||
};
|
||||
delete formDataWithoutTimeFormat.xAxisTimeFormat;
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithoutTimeFormat,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom xAxisTimeFormat', () => {
|
||||
it('should respect custom xAxisTimeFormat when explicitly set', () => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify the formatter function exists and is applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
|
||||
// The key test is that a formatter exists - the actual formatting is handled by d3-time-format
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle different time format options', () => {
|
||||
const timeFormats = [
|
||||
'%Y-%m-%d',
|
||||
'%Y/%m/%d',
|
||||
'%m/%d/%Y',
|
||||
'%b %d, %Y',
|
||||
'smart_date',
|
||||
];
|
||||
|
||||
timeFormats.forEach(timeFormat => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: timeFormat,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Orientation-specific behavior', () => {
|
||||
it('should apply time formatting to x-axis in vertical bar charts', () => {
|
||||
const verticalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'vertical',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: verticalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In vertical orientation, time should be on x-axis
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply time formatting to y-axis in horizontal bar charts', () => {
|
||||
const horizontalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'horizontal',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: horizontalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In horizontal orientation, axes are swapped, so time should be on y-axis
|
||||
const yAxis = transformedProps.echartOptions.yAxis as any;
|
||||
expect(yAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof yAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with existing features', () => {
|
||||
it('should work with axis bounds', () => {
|
||||
const formDataWithBounds = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
truncateXAxis: true,
|
||||
xAxisBounds: [null, null] as [number | null, number | null],
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithBounds,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
// The xAxis should be configured with the time formatting
|
||||
expect(transformedProps.echartOptions.xAxis).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with label rotation', () => {
|
||||
const formDataWithRotation = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
xAxisLabelRotation: 45,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithRotation,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(xAxis.axisLabel).toHaveProperty('rotate', 45);
|
||||
});
|
||||
|
||||
it('should maintain time formatting consistency with tooltip', () => {
|
||||
const formDataWithTooltip = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
tooltipTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithTooltip,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both axis and tooltip should have formatters
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(transformedProps.xValueFormatter).toBeDefined();
|
||||
expect(typeof transformedProps.xValueFormatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression test for Issue #30373', () => {
|
||||
it('should not be stuck on adaptive formatting', () => {
|
||||
// Test the exact scenario described in the issue
|
||||
const issueFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: issueFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
|
||||
// The important part is that the xAxisTimeFormat is being used from formData
|
||||
// The actual formatting is handled by the underlying time formatter
|
||||
});
|
||||
|
||||
it('should allow changing from smart_date to other formats', () => {
|
||||
// First create with smart_date (default)
|
||||
const smartDateFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
};
|
||||
|
||||
const smartDateChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: smartDateFormData,
|
||||
});
|
||||
|
||||
const smartDateProps = transformProps(
|
||||
smartDateChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Then change to a different format
|
||||
const customFormatFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%b %Y',
|
||||
};
|
||||
|
||||
const customFormatChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormatFormData,
|
||||
});
|
||||
|
||||
const customFormatProps = transformProps(
|
||||
customFormatChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both should have formatters - the key is that they're not undefined
|
||||
const smartDateXAxis = smartDateProps.echartOptions.xAxis as any;
|
||||
const customFormatXAxis = customFormatProps.echartOptions.xAxis as any;
|
||||
|
||||
expect(smartDateXAxis.axisLabel.formatter).toBeDefined();
|
||||
expect(customFormatXAxis.axisLabel.formatter).toBeDefined();
|
||||
|
||||
// Both should be functions that can format time
|
||||
expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function');
|
||||
expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should have xAxisTimeFormat in formData by default', () => {
|
||||
// This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBeDefined();
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
|
||||
|
||||
describe('Timeseries constants', () => {
|
||||
describe('DEFAULT_FORM_DATA', () => {
|
||||
it('should include xAxisTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('xAxisTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should include tooltipTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('tooltipTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.tooltipTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have consistent time format defaults', () => {
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe(
|
||||
DEFAULT_FORM_DATA.tooltipTimeFormat,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have vertical orientation as default', () => {
|
||||
expect(DEFAULT_FORM_DATA.orientation).toBe('vertical');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Space,
|
||||
RawAntdSelect as Select,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
@@ -563,62 +564,52 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<div
|
||||
overlay={
|
||||
<Menu
|
||||
multiple
|
||||
onClick={handleOnClick}
|
||||
onBlur={handleOnBlur}
|
||||
selectedKeys={selectedComparisonColumns}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
max-width: 242px;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</div>
|
||||
{comparisonColumns.map(column => (
|
||||
<Menu.Item key={column.key}>
|
||||
<span
|
||||
css={css`
|
||||
max-width: 242px;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorText};
|
||||
`}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
<span
|
||||
css={css`
|
||||
float: right;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map(
|
||||
(column: { key: string; label: string }) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<span
|
||||
css={css`
|
||||
color: ${theme.colorText};
|
||||
`}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
<span
|
||||
css={css`
|
||||
float: right;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -646,7 +646,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show Cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -674,7 +674,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -688,7 +688,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'comparison_color_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Basic conditional formatting'),
|
||||
label: t('basic conditional formatting'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!isEmpty(controls?.time_compare?.value),
|
||||
@@ -729,7 +729,7 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'ConditionalFormattingControl',
|
||||
renderTrigger: true,
|
||||
label: t('Custom conditional formatting'),
|
||||
label: t('Custom Conditional Formatting'),
|
||||
extraColorChoices: [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
|
||||
@@ -63,7 +63,7 @@ export enum ContextMenuItem {
|
||||
export interface ChartContextMenuProps {
|
||||
id: number;
|
||||
formData: QueryFormData;
|
||||
onSelection: (args?: any) => void;
|
||||
onSelection: () => void;
|
||||
onClose: () => void;
|
||||
additionalConfig?: {
|
||||
crossFilter?: Record<string, any>;
|
||||
@@ -123,12 +123,6 @@ const ChartContextMenu = (
|
||||
const [dataset, setDataset] = useState<Dataset>();
|
||||
const verboseMap = useVerboseMap(dataset);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
|
||||
setDrillByColumn(column);
|
||||
setDataset(dataset); // Save dataset when drilling
|
||||
@@ -270,7 +264,6 @@ const ChartContextMenu = (
|
||||
<DrillByMenuItems
|
||||
drillByConfig={filters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
contextMenuY={clientY}
|
||||
submenuIndex={submenuIndex}
|
||||
@@ -318,7 +311,6 @@ const ChartContextMenu = (
|
||||
onOpenChange={setOpenKeys}
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -166,12 +166,8 @@ test('render menu item with submenu without searchbox', async () => {
|
||||
renderMenu({});
|
||||
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Check that each column appears in the drill-by submenu
|
||||
slicedColumns.forEach(column => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0]; // Use the first submenu
|
||||
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -190,19 +186,15 @@ test('render menu item with submenu and searchbox', async () => {
|
||||
// Wait for all columns to be visible
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const searchbox = await waitFor(
|
||||
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||
() => screen.getAllByPlaceholderText('Search columns')[1],
|
||||
);
|
||||
expect(searchbox).toBeInTheDocument();
|
||||
|
||||
@@ -212,26 +204,19 @@ test('render menu item with submenu and searchbox', async () => {
|
||||
|
||||
// Wait for filtered results
|
||||
await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
|
||||
defaultColumns
|
||||
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
||||
.forEach(col => {
|
||||
expect(
|
||||
within(submenu).queryByText(col.column_name),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,23 +238,17 @@ test('Do not display excluded column in the menu', async () => {
|
||||
// Wait for menu items to be loaded
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
excludedColNames.forEach(colName => {
|
||||
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,11 +269,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for col1 to be visible before clicking
|
||||
const col1Element = await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
return within(submenu).getByText('col1');
|
||||
});
|
||||
const col1Element = await waitFor(() => screen.getByText('col1'));
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
@@ -68,7 +68,6 @@ export interface DrillByMenuItemsProps {
|
||||
submenuIndex?: number;
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
open: boolean;
|
||||
@@ -101,7 +100,6 @@ export const DrillByMenuItems = ({
|
||||
submenuIndex = 0,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
excludedColumns,
|
||||
openNewModal = true,
|
||||
open,
|
||||
@@ -126,7 +124,6 @@ export const DrillByMenuItems = ({
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
onCloseMenu();
|
||||
},
|
||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||
);
|
||||
@@ -267,14 +264,15 @@ export const DrillByMenuItems = ({
|
||||
const { columns, ...rest } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
<MenuItemWithTruncation
|
||||
menuKey={`drill-by-item-${column.column_name}`}
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
</MenuItemWithTruncation>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
*/
|
||||
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
Behavior,
|
||||
BinaryQueryObjectFilterClause,
|
||||
css,
|
||||
extractQueryFields,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
removeHTMLTags,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
NO_FILTERS: t(
|
||||
'Right-click on a dimension value to drill to detail by that value.',
|
||||
),
|
||||
NOT_SUPPORTED: t(
|
||||
'Drill to detail by value is not yet supported for this chart type.',
|
||||
),
|
||||
};
|
||||
|
||||
function getDisabledMenuItem(
|
||||
children: ReactNode,
|
||||
menuKey: string,
|
||||
...rest: unknown[]
|
||||
): MenuItem {
|
||||
return {
|
||||
disabled: true,
|
||||
key: menuKey,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
stripHTML: boolean;
|
||||
}) => {
|
||||
const content =
|
||||
stripHTML && typeof children === 'string'
|
||||
? removeHTMLTags(children)
|
||||
: children;
|
||||
return <span>{content}</span>;
|
||||
};
|
||||
|
||||
const StyledFilter = styled(Filter)`
|
||||
${({ theme }) => `
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorPrimary};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsArgs = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
isContextMenu?: boolean;
|
||||
contextMenuY?: number;
|
||||
onSelection?: () => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
submenuIndex?: number;
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
};
|
||||
|
||||
export const useDrillDetailMenuItems = ({
|
||||
formData,
|
||||
filters = [],
|
||||
isContextMenu = false,
|
||||
contextMenuY = 0,
|
||||
onSelection = () => null,
|
||||
onClick = () => null,
|
||||
submenuIndex = 0,
|
||||
setFilters,
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsArgs) => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters, event) => {
|
||||
onClick(event);
|
||||
onSelection();
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
// event for dimensions. If it doesn't, tell the user that drill to detail by
|
||||
// dimension is not supported. If it does, and the `contextmenu` handler didn't
|
||||
// pass any filters, tell the user that they didn't select a dimension.
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
// Check metrics to see if chart's current configuration lacks
|
||||
// aggregations, in which case Drill to Detail should be disabled.
|
||||
const noAggregations = useMemo(() => {
|
||||
const { metrics } = extractQueryFields(formData);
|
||||
return isEmpty(metrics);
|
||||
}, [formData]);
|
||||
|
||||
// Ensure submenu doesn't appear offscreen
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filters.length > 1 ? filters.length + 1 : filters.length,
|
||||
submenuIndex,
|
||||
),
|
||||
[contextMenuY, filters.length, submenuIndex],
|
||||
);
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
if (noAggregations) {
|
||||
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
} else if (!filters?.length) {
|
||||
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
|
||||
}
|
||||
} else {
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem: MenuItem = drillDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: 'drill-to-detail',
|
||||
label: DRILL_TO_DETAIL,
|
||||
onClick: openModal.bind(null, []),
|
||||
...props,
|
||||
};
|
||||
|
||||
const getMenuItemWithTruncation = useMenuItemWithTruncation();
|
||||
|
||||
const drillToDetailByMenuItem: MenuItem = drillByDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-by-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
key: `drill-detail-filter-${i}`,
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
})),
|
||||
filters.length > 1 && {
|
||||
key: 'drill-detail-filter-all',
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
|
||||
onClick: openModal.bind(null, filters),
|
||||
key: 'drill-detail-filter-all',
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
},
|
||||
].filter(Boolean) as MenuItem[],
|
||||
onClick: openModal.bind(null, filters),
|
||||
forceSubmenuRender: true,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
...props,
|
||||
};
|
||||
if (isContextMenu) {
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
drillToDetailByMenuItem,
|
||||
};
|
||||
}
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
};
|
||||
};
|
||||
@@ -18,14 +18,9 @@
|
||||
*/
|
||||
|
||||
import { ReactNode, CSSProperties, useCallback } from 'react';
|
||||
import {
|
||||
css,
|
||||
truncationCSS,
|
||||
useCSSTextTruncation,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
|
||||
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Flex, Tooltip } from '@superset-ui/core/components';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
import { MenuItemProps } from 'antd';
|
||||
|
||||
export type MenuItemWithTruncationProps = {
|
||||
@@ -118,12 +113,7 @@ export const MenuItemWithTruncation = ({
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<Tooltip
|
||||
title={itemIsTruncated ? tooltipText : null}
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||
<div
|
||||
ref={itemRef}
|
||||
css={css`
|
||||
@@ -137,50 +127,3 @@ export const MenuItemWithTruncation = ({
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export const VirtualizedMenuItem = ({
|
||||
tooltipText,
|
||||
children,
|
||||
onClick,
|
||||
style,
|
||||
}: {
|
||||
tooltipText: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
align="center"
|
||||
style={style}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
padding-left: ${theme.paddingXS}px;
|
||||
&:hover {
|
||||
background-color: ${theme.colorBgTextHover};
|
||||
}
|
||||
&:active {
|
||||
background-color: ${theme.colorBgTextActive};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||
<div
|
||||
ref={itemRef}
|
||||
css={css`
|
||||
max-width: 100%;
|
||||
${truncationCSS};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,29 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
cleanup,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import { store } from 'src/views/store';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { FacePile } from '.';
|
||||
import { getRandomColor } from './utils';
|
||||
|
||||
// Mock the feature flag
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||
typeof isFeatureEnabled
|
||||
>;
|
||||
|
||||
const users = [...new Array(10)].map((_, i) => ({
|
||||
first_name: 'user',
|
||||
last_name: `${i}`,
|
||||
@@ -47,99 +29,37 @@ const users = [...new Array(10)].map((_, i) => ({
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Default to Slack avatars disabled
|
||||
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('FacePile', () => {
|
||||
it('renders empty state with no users', () => {
|
||||
const { container } = render(<FacePile users={[]} />, { store });
|
||||
let container: HTMLElement;
|
||||
|
||||
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
|
||||
beforeEach(() => {
|
||||
({ container } = render(<FacePile users={users} />, { store }));
|
||||
});
|
||||
|
||||
it('renders single user without truncation', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
|
||||
store,
|
||||
});
|
||||
it('is a valid element', () => {
|
||||
const exposedFaces = screen.getAllByText(/U/);
|
||||
expect(exposedFaces).toHaveLength(4);
|
||||
const overflownFaces = screen.getByText('+6');
|
||||
expect(overflownFaces).toBeVisible();
|
||||
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(1);
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple users no truncation', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 4)} />, {
|
||||
store,
|
||||
});
|
||||
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(4);
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple users with truncation', () => {
|
||||
const { container } = render(<FacePile users={users} />, { store });
|
||||
|
||||
// Should show 4 avatars + 1 overflow indicator = 5 total elements
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(5);
|
||||
|
||||
// Should show first 4 users
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||
|
||||
// Should show overflow count (+6 because 10 total - 4 shown)
|
||||
expect(within(container).getByText('+6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user tooltip on hover', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 2)} />, {
|
||||
store,
|
||||
});
|
||||
|
||||
const firstAvatar = within(container).getByText('U0');
|
||||
fireEvent.mouseEnter(firstAvatar);
|
||||
// Display user info when hovering over one of exposed face in the pile.
|
||||
fireEvent.mouseEnter(exposedFaces[0]);
|
||||
act(() => jest.runAllTimers());
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
|
||||
});
|
||||
|
||||
it('displays avatar images when Slack avatars are enabled', () => {
|
||||
// Enable Slack avatars feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'SLACK_ENABLE_AVATARS',
|
||||
);
|
||||
it('renders an Avatar', () => {
|
||||
expect(container.querySelector('.ant-avatar')).toBeVisible();
|
||||
});
|
||||
|
||||
const { container: testContainer } = render(
|
||||
<FacePile users={users.slice(0, 2)} />,
|
||||
{
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
const avatars = testContainer.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(2);
|
||||
|
||||
// Should have img elements with correct src attributes
|
||||
const imgs = testContainer.querySelectorAll('.ant-avatar img');
|
||||
expect(imgs).toHaveLength(2);
|
||||
expect(imgs[0]).toHaveAttribute('src', '/api/v1/user/0/avatar.png');
|
||||
expect(imgs[1]).toHaveAttribute('src', '/api/v1/user/1/avatar.png');
|
||||
it('hides overflow', () => {
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
|
||||
import { tagToSelectOption } from 'src/components/Tag/utils';
|
||||
|
||||
describe('tagToSelectOption', () => {
|
||||
test('converts a Tag object with table_name to a SelectTagsValue', () => {
|
||||
@@ -37,166 +35,3 @@ describe('tagToSelectOption', () => {
|
||||
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTags', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('constructs correct API query with custom tag filter', async () => {
|
||||
const mockTags = [
|
||||
{ id: 1, name: 'analytics', type: 1 },
|
||||
{ id: 2, name: 'finance', type: 1 },
|
||||
];
|
||||
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: mockTags,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await loadTags('analytics', 0, 25);
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
const calls = fetchMock.calls();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url).toContain('/api/v1/tag/?q=');
|
||||
|
||||
// Extract and decode the query parameter
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Verify the query structure
|
||||
expect(decodedQuery).toEqual({
|
||||
filters: [
|
||||
{ col: 'name', opr: 'ct', value: 'analytics' },
|
||||
{ col: 'type', opr: 'custom_tag', value: true },
|
||||
],
|
||||
page: 0,
|
||||
page_size: 25,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns correctly transformed data', async () => {
|
||||
const mockTags = [
|
||||
{ id: 1, name: 'analytics', type: 1 },
|
||||
{ id: 2, name: 'finance', type: 1 },
|
||||
];
|
||||
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: mockTags,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const result = await loadTags('', 0, 25);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{ value: 1, label: 'analytics', key: 1 },
|
||||
{ value: 2, label: 'finance', key: 2 },
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles search parameter correctly', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('financial-data', 0, 25);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Should include the search term in the name filter
|
||||
expect(decodedQuery.filters[0]).toEqual({
|
||||
col: 'name',
|
||||
opr: 'ct',
|
||||
value: 'financial-data',
|
||||
});
|
||||
});
|
||||
|
||||
test('handles pagination parameters correctly', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('', 2, 10);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
expect(decodedQuery.page).toBe(2);
|
||||
expect(decodedQuery.page_size).toBe(10);
|
||||
});
|
||||
|
||||
test('always includes custom tag filter regardless of other parameters', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
// Test with different combinations of parameters
|
||||
await loadTags('', 0, 25);
|
||||
await loadTags('search-term', 1, 50);
|
||||
await loadTags('another-search', 5, 100);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
|
||||
// Verify all calls include the custom tag filter
|
||||
calls.forEach(call => {
|
||||
const [url] = call;
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Every call should have the custom tag filter
|
||||
expect(decodedQuery.filters).toContainEqual({
|
||||
col: 'type',
|
||||
opr: 'custom_tag',
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('maintains correct order specification', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('test', 0, 25);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Should always order by name ascending
|
||||
expect(decodedQuery.order_column).toBe('name');
|
||||
expect(decodedQuery.order_direction).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,129 +78,3 @@ test('should render 3 elements when maxTags is set to 3', async () => {
|
||||
expect(tagsListItems).toHaveLength(3);
|
||||
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||
});
|
||||
|
||||
describe('Tag type filtering', () => {
|
||||
test('should render only custom type tags (type: 1)', async () => {
|
||||
const mixedTypeTags = [
|
||||
{ name: 'custom-tag', type: 1, id: 1 }, // Custom - should show
|
||||
{ name: 'type:chart', type: 2, id: 2 }, // Type - should be filtered out
|
||||
{ name: 'owner:admin', type: 3, id: 3 }, // Owner - should be filtered out
|
||||
{ name: 'another-custom', type: 1, id: 4 }, // Custom - should show
|
||||
];
|
||||
|
||||
// Filter tags like ChartList does - only custom types
|
||||
const filteredTags = mixedTypeTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should only show 2 custom tags, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('another-custom');
|
||||
expect(tagsListItems[1]).toHaveTextContent('custom-tag');
|
||||
});
|
||||
|
||||
test('should show tags when type is undefined (fallback case)', async () => {
|
||||
const undefinedTypeTags = [
|
||||
{ name: 'legacy-tag', id: 1 }, // No type property - should show due to fallback
|
||||
{ name: 'custom-tag', type: 1, id: 2 }, // Custom - should show
|
||||
{ name: 'system-tag', type: 2, id: 3 }, // System - should be filtered out
|
||||
];
|
||||
|
||||
// Apply ChartList filtering logic - undefined type defaults to true
|
||||
const filteredTags = undefinedTypeTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show both tags, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('custom-tag');
|
||||
expect(tagsListItems[1]).toHaveTextContent('legacy-tag');
|
||||
});
|
||||
|
||||
test('should handle legacy TagTypes.custom string format', async () => {
|
||||
const legacyFormatTags = [
|
||||
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 1 }, // Legacy string format - should show
|
||||
{ name: 'modern-custom', type: 1, id: 2 }, // Modern enum - should show
|
||||
{ name: 'other-type', type: 'TagTypes.other', id: 3 }, // Other legacy type - should be filtered out
|
||||
];
|
||||
|
||||
// Apply ChartList filtering logic - supports both numeric and legacy string
|
||||
const filteredTags = legacyFormatTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show both custom formats, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('legacy-custom');
|
||||
expect(tagsListItems[1]).toHaveTextContent('modern-custom');
|
||||
});
|
||||
|
||||
test('should show empty list when all tags are filtered out', async () => {
|
||||
const nonCustomTags = [
|
||||
{ name: 'type:chart', type: 2, id: 1 }, // Type tag
|
||||
{ name: 'owner:admin', type: 3, id: 2 }, // Owner tag
|
||||
{ name: 'favoritedBy:user', type: 4, id: 3 }, // FavoritedBy tag
|
||||
];
|
||||
|
||||
// Apply ChartList filtering - all should be filtered out
|
||||
const filteredTags = nonCustomTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
|
||||
// Should render container but with no tags
|
||||
const container = document.querySelector('.tag-list');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// No tags should be rendered
|
||||
const tagsListItems = document.querySelectorAll('.ant-tag');
|
||||
expect(tagsListItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle mixed scenarios with truncation', async () => {
|
||||
const largeMixedTagSet = [
|
||||
{ name: 'custom-1', type: 1, id: 1 }, // Custom - should show
|
||||
{ name: 'system-1', type: 2, id: 2 }, // System - filtered out
|
||||
{ name: 'custom-2', type: 1, id: 3 }, // Custom - should show
|
||||
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 4 }, // Legacy custom - should show
|
||||
{ name: 'custom-3', type: 1, id: 5 }, // Custom - should show
|
||||
{ name: 'owner-tag', type: 3, id: 6 }, // Owner - filtered out
|
||||
{ name: 'custom-4', type: 1, id: 7 }, // Custom - should show (but truncated)
|
||||
];
|
||||
|
||||
// Apply ChartList filtering - should get 5 custom tags
|
||||
const filteredTags = largeMixedTagSet.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
// Set maxTags to 3 to test truncation of filtered results
|
||||
setup({ tags: filteredTags, maxTags: 3 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show 3 tags: 2 custom tags (alphabetically sorted) + 1 "+3..." truncation indicator
|
||||
expect(tagsListItems).toHaveLength(3);
|
||||
expect(tagsListItems[0]).toHaveTextContent('custom-1');
|
||||
expect(tagsListItems[1]).toHaveTextContent('custom-2');
|
||||
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,16 +18,16 @@
|
||||
*/
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import { useDownloadMenuItems } from 'src/dashboard/components/menu/DownloadMenuItems';
|
||||
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
|
||||
import CssEditor from 'src/dashboard/components/CssEditor';
|
||||
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
|
||||
import SaveModal from 'src/dashboard/components/SaveModal';
|
||||
import HeaderReportDropdown from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
|
||||
import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
|
||||
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
|
||||
@@ -74,6 +74,9 @@ export const useHeaderActionsMenu = ({
|
||||
}: HeaderDropdownProps) => {
|
||||
const dispatch = useDispatch();
|
||||
const [css, setCss] = useState(customCss || '');
|
||||
const [showReportSubMenu, setShowReportSubMenu] = useState<boolean | null>(
|
||||
null,
|
||||
);
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const directPathToChild = useSelector(
|
||||
(state: RootState) => state.dashboardState.directPathToChild,
|
||||
@@ -169,220 +172,163 @@ export const useHeaderActionsMenu = ({
|
||||
[directPathToChild],
|
||||
);
|
||||
|
||||
const shareMenuItems = useShareMenuItems({
|
||||
title: t('Share'),
|
||||
disabled: isLoading,
|
||||
url,
|
||||
dashboardId,
|
||||
dashboardComponentId,
|
||||
copyMenuItemTitle: t('Copy permalink to clipboard'),
|
||||
emailMenuItemTitle: t('Share permalink by email'),
|
||||
emailSubject,
|
||||
emailBody: t('Check out this dashboard: '),
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
});
|
||||
|
||||
const downloadMenuItem = useDownloadMenuItems({
|
||||
pdfMenuItemTitle: t('Export to PDF'),
|
||||
imageMenuItemTitle: t('Download as Image'),
|
||||
dashboardTitle,
|
||||
dashboardId,
|
||||
title: t('Download'),
|
||||
disabled: isLoading,
|
||||
logEvent,
|
||||
});
|
||||
|
||||
const reportMenuItem = useHeaderReportMenuItems({
|
||||
dashboardId: dashboardInfo?.id,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
});
|
||||
|
||||
// Helper function to create menu items for components with triggerNode
|
||||
const createModalMenuItem = (
|
||||
key: string,
|
||||
modalComponent: React.ReactElement,
|
||||
): MenuItem => ({
|
||||
key,
|
||||
label: modalComponent,
|
||||
});
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const isEmbedded = !dashboardInfo?.userId;
|
||||
const refreshIntervalOptions =
|
||||
dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
|
||||
|
||||
const menuItems: MenuItem[] = [];
|
||||
|
||||
// Refresh dashboard
|
||||
if (!editMode) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.RefreshDashboard,
|
||||
label: t('Refresh dashboard'),
|
||||
disabled: isLoading,
|
||||
});
|
||||
}
|
||||
|
||||
// Toggle fullscreen
|
||||
if (!editMode && !isEmbedded) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ToggleFullscreen,
|
||||
label: getUrlParam(URL_PARAMS.standalone)
|
||||
? t('Exit fullscreen')
|
||||
: t('Enter fullscreen'),
|
||||
});
|
||||
}
|
||||
|
||||
// Edit properties
|
||||
if (editMode) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.EditProperties,
|
||||
label: t('Edit properties'),
|
||||
});
|
||||
}
|
||||
|
||||
// Edit CSS
|
||||
if (editMode) {
|
||||
menuItems.push(
|
||||
createModalMenuItem(
|
||||
MenuKeys.EditCss,
|
||||
<CssEditor
|
||||
triggerNode={<div>{t('Theme & CSS')}</div>}
|
||||
initialCss={css}
|
||||
onChange={changeCss}
|
||||
addDangerToast={addDangerToast}
|
||||
currentThemeId={dashboardInfo.theme?.id || null}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Save as
|
||||
if (userCanSave) {
|
||||
menuItems.push(
|
||||
createModalMenuItem(
|
||||
MenuKeys.SaveModal,
|
||||
<SaveModal
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
dashboardId={dashboardId}
|
||||
dashboardTitle={dashboardTitle}
|
||||
dashboardInfo={dashboardInfo}
|
||||
saveType={SAVE_TYPE_NEWDASHBOARD}
|
||||
layout={layout}
|
||||
expandedSlices={expandedSlices}
|
||||
refreshFrequency={refreshFrequency}
|
||||
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
|
||||
lastModifiedTime={lastModifiedTime}
|
||||
customCss={customCss}
|
||||
colorNamespace={colorNamespace}
|
||||
colorScheme={colorScheme}
|
||||
onSave={onSave}
|
||||
triggerNode={
|
||||
<div data-test="save-as-menu-item">{t('Save as')}</div>
|
||||
}
|
||||
canOverwrite={userCanEdit}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Download submenu
|
||||
menuItems.push(downloadMenuItem);
|
||||
|
||||
// Share submenu
|
||||
if (userCanShare) {
|
||||
menuItems.push(shareMenuItems);
|
||||
}
|
||||
|
||||
// Embed dashboard
|
||||
if (!editMode && userCanCurate) {
|
||||
menuItems.push({
|
||||
key: MenuKeys.ManageEmbedded,
|
||||
label: t('Embed dashboard'),
|
||||
});
|
||||
}
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Report dropdown
|
||||
if (!editMode && reportMenuItem) {
|
||||
menuItems.push(reportMenuItem);
|
||||
}
|
||||
|
||||
// Set filter mapping
|
||||
if (editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes)) {
|
||||
menuItems.push(
|
||||
createModalMenuItem(
|
||||
MenuKeys.SetFilterMapping,
|
||||
<FilterScopeModal
|
||||
triggerNode={<div>{t('Set filter mapping')}</div>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Auto-refresh interval
|
||||
menuItems.push(
|
||||
createModalMenuItem(
|
||||
MenuKeys.AutorefreshModal,
|
||||
<RefreshIntervalModal
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshFrequency={refreshFrequency}
|
||||
refreshLimit={refreshLimit}
|
||||
refreshWarning={refreshWarning}
|
||||
onChange={changeRefreshInterval}
|
||||
editMode={editMode}
|
||||
refreshIntervalOptions={refreshIntervalOptions}
|
||||
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
|
||||
return (
|
||||
<Menu
|
||||
selectable={false}
|
||||
data-test="header-actions-menu"
|
||||
onClick={handleMenuClick}
|
||||
items={menuItems}
|
||||
/>
|
||||
>
|
||||
{!editMode && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.RefreshDashboard}
|
||||
data-test="refresh-dashboard-menu-item"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t('Refresh dashboard')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{!editMode && !isEmbedded && (
|
||||
<Menu.Item key={MenuKeys.ToggleFullscreen}>
|
||||
{getUrlParam(URL_PARAMS.standalone)
|
||||
? t('Exit fullscreen')
|
||||
: t('Enter fullscreen')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{editMode && (
|
||||
<Menu.Item key={MenuKeys.EditProperties}>
|
||||
{t('Edit properties')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
{editMode && (
|
||||
<Menu.Item key={MenuKeys.EditCss}>
|
||||
<CssEditor
|
||||
triggerNode={<div>{t('Theme & CSS')}</div>}
|
||||
initialCss={css}
|
||||
onChange={changeCss}
|
||||
addDangerToast={addDangerToast}
|
||||
currentThemeId={dashboardInfo.theme?.id || null}
|
||||
onThemeChange={handleThemeChange}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{userCanSave && (
|
||||
<Menu.Item key={MenuKeys.SaveModal}>
|
||||
<SaveModal
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
dashboardId={dashboardId}
|
||||
dashboardTitle={dashboardTitle}
|
||||
dashboardInfo={dashboardInfo}
|
||||
saveType={SAVE_TYPE_NEWDASHBOARD}
|
||||
layout={layout}
|
||||
expandedSlices={expandedSlices}
|
||||
refreshFrequency={refreshFrequency}
|
||||
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
|
||||
lastModifiedTime={lastModifiedTime}
|
||||
customCss={customCss}
|
||||
colorNamespace={colorNamespace}
|
||||
colorScheme={colorScheme}
|
||||
onSave={onSave}
|
||||
triggerNode={
|
||||
<div data-test="save-as-menu-item">{t('Save as')}</div>
|
||||
}
|
||||
canOverwrite={userCanEdit}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<DownloadMenuItems
|
||||
submenuKey={MenuKeys.Download}
|
||||
disabled={isLoading}
|
||||
title={t('Download')}
|
||||
pdfMenuItemTitle={t('Export to PDF')}
|
||||
imageMenuItemTitle={t('Download as Image')}
|
||||
dashboardTitle={dashboardTitle}
|
||||
dashboardId={dashboardId}
|
||||
logEvent={logEvent}
|
||||
/>
|
||||
{userCanShare && (
|
||||
<ShareMenuItems
|
||||
disabled={isLoading}
|
||||
data-test="share-dashboard-menu-item"
|
||||
title={t('Share')}
|
||||
url={url}
|
||||
copyMenuItemTitle={t('Copy permalink to clipboard')}
|
||||
emailMenuItemTitle={t('Share permalink by email')}
|
||||
emailSubject={emailSubject}
|
||||
emailBody={t('Check out this dashboard: ')}
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
dashboardId={dashboardId}
|
||||
dashboardComponentId={dashboardComponentId}
|
||||
/>
|
||||
)}
|
||||
{!editMode && userCanCurate && (
|
||||
<Menu.Item key={MenuKeys.ManageEmbedded}>
|
||||
{t('Embed dashboard')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Divider />
|
||||
{!editMode ? (
|
||||
showReportSubMenu ? (
|
||||
<>
|
||||
<HeaderReportDropdown
|
||||
submenuTitle={t('Manage email report')}
|
||||
dashboardId={dashboardInfo.id}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
showReportSubMenu={showReportSubMenu}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
) : (
|
||||
<HeaderReportDropdown
|
||||
dashboardId={dashboardInfo.id}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
{editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && (
|
||||
<Menu.Item key={MenuKeys.SetFilterMapping}>
|
||||
<FilterScopeModal
|
||||
triggerNode={<div>{t('Set filter mapping')}</div>}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item key={MenuKeys.AutorefreshModal}>
|
||||
<RefreshIntervalModal
|
||||
addSuccessToast={addSuccessToast}
|
||||
refreshFrequency={refreshFrequency}
|
||||
refreshLimit={refreshLimit}
|
||||
refreshWarning={refreshWarning}
|
||||
onChange={changeRefreshInterval}
|
||||
editMode={editMode}
|
||||
refreshIntervalOptions={refreshIntervalOptions}
|
||||
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
|
||||
/>
|
||||
</Menu.Item>
|
||||
</Menu>
|
||||
);
|
||||
}, [
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
changeRefreshInterval,
|
||||
changeCss,
|
||||
colorNamespace,
|
||||
colorScheme,
|
||||
css,
|
||||
customCss,
|
||||
dashboardId,
|
||||
dashboardInfo,
|
||||
dashboardTitle,
|
||||
downloadMenuItem,
|
||||
editMode,
|
||||
expandedSlices,
|
||||
showReportSubMenu,
|
||||
isDropdownVisible,
|
||||
directPathToChild,
|
||||
handleMenuClick,
|
||||
isLoading,
|
||||
lastModifiedTime,
|
||||
layout,
|
||||
onSave,
|
||||
refreshFrequency,
|
||||
refreshLimit,
|
||||
refreshWarning,
|
||||
reportMenuItem,
|
||||
shareMenuItems,
|
||||
shouldPersistRefreshFrequency,
|
||||
userCanCurate,
|
||||
userCanEdit,
|
||||
userCanSave,
|
||||
userCanShare,
|
||||
changeCss,
|
||||
changeRefreshInterval,
|
||||
emailSubject,
|
||||
url,
|
||||
dashboardComponentId,
|
||||
]);
|
||||
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||
|
||||
@@ -438,9 +438,10 @@ describe('PropertiesModal', () => {
|
||||
const props = createProps();
|
||||
const propsWithDashboardInfo = { ...props, dashboardInfo };
|
||||
|
||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||
const getSelect = () =>
|
||||
screen.getByRole('combobox', { name: SupersetCore.t('Owners') });
|
||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||
|
||||
const getElementsByClassName = (className: string) =>
|
||||
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
|
||||
|
||||
|
||||
@@ -41,20 +41,20 @@ import {
|
||||
QueryFormData,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
NoAnimationDropdown,
|
||||
Tooltip,
|
||||
Button,
|
||||
ModalTrigger,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
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/DrillDetail';
|
||||
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||
@@ -334,199 +334,183 @@ const SliceHeaderControls = (
|
||||
animationDuration: '0s',
|
||||
};
|
||||
|
||||
const newMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: MenuKeys.ForceRefresh,
|
||||
label: (
|
||||
<>
|
||||
{t('Force refresh')}
|
||||
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
|
||||
{refreshTooltip}
|
||||
</RefreshTooltip>
|
||||
</>
|
||||
),
|
||||
disabled: props.chartStatus === 'loading',
|
||||
style: { height: 'auto', lineHeight: 'initial' },
|
||||
...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type
|
||||
},
|
||||
{
|
||||
key: MenuKeys.Fullscreen,
|
||||
label: fullscreenLabel,
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
];
|
||||
const menu = (
|
||||
<Menu
|
||||
onClick={handleMenuClick}
|
||||
data-test={`slice_${slice.slice_id}-menu`}
|
||||
id={`slice_${slice.slice_id}-menu`}
|
||||
selectable={false}
|
||||
>
|
||||
<Menu.Item
|
||||
key={MenuKeys.ForceRefresh}
|
||||
disabled={props.chartStatus === 'loading'}
|
||||
style={{ height: 'auto', lineHeight: 'initial' }}
|
||||
data-test="refresh-chart-menu-item"
|
||||
>
|
||||
{t('Force refresh')}
|
||||
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
|
||||
{refreshTooltip}
|
||||
</RefreshTooltip>
|
||||
</Menu.Item>
|
||||
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
label: props.isDescriptionExpanded
|
||||
? t('Hide chart description')
|
||||
: t('Show chart description'),
|
||||
});
|
||||
}
|
||||
<Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item>
|
||||
|
||||
if (canExplore) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ExploreChart,
|
||||
label: (
|
||||
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
|
||||
{t('Edit chart')}
|
||||
</Tooltip>
|
||||
),
|
||||
...{ 'data-test-edit-chart-name': slice.slice_name },
|
||||
});
|
||||
}
|
||||
<Menu.Divider />
|
||||
|
||||
if (canEditCrossFilters) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.CrossFilterScoping,
|
||||
label: t('Cross-filtering scoping'),
|
||||
});
|
||||
}
|
||||
{slice.description && (
|
||||
<Menu.Item key={MenuKeys.ToggleChartDescription}>
|
||||
{props.isDescriptionExpanded
|
||||
? t('Hide chart description')
|
||||
: t('Show chart description')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
if (canExplore || canEditCrossFilters) {
|
||||
newMenuItems.push({ type: 'divider' });
|
||||
}
|
||||
{canExplore && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExploreChart}
|
||||
data-test-edit-chart-name={slice.slice_name}
|
||||
>
|
||||
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
|
||||
{t('Edit chart')}
|
||||
</Tooltip>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
if (canExplore || canViewQuery) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ViewQuery,
|
||||
label: (
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
ref={queryMenuRef}
|
||||
{canEditCrossFilters && (
|
||||
<Menu.Item key={MenuKeys.CrossFilterScoping}>
|
||||
{t('Cross-filtering scoping')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{(canExplore || canEditCrossFilters) && <Menu.Divider />}
|
||||
|
||||
{(canExplore || canViewQuery) && (
|
||||
<Menu.Item key={MenuKeys.ViewQuery}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
ref={queryMenuRef}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{(canExplore || canViewTable) && (
|
||||
<Menu.Item key={MenuKeys.ViewResults}>
|
||||
<ViewResultsModalTrigger
|
||||
canExplore={props.supersetCanExplore}
|
||||
exploreUrl={props.exploreUrl}
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View as table')}</div>
|
||||
}
|
||||
modalRef={resultsMenuRef}
|
||||
modalTitle={t('Chart Data: %s', slice.slice_name)}
|
||||
modalBody={
|
||||
<ResultsPaneOnDashboard
|
||||
queryFormData={props.formData}
|
||||
queryForce={false}
|
||||
dataSize={20}
|
||||
isRequest
|
||||
isVisible
|
||||
canDownload={!!props.supersetCanCSV}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
{isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && (
|
||||
<DrillDetailMenuItems
|
||||
setFilters={setFilters}
|
||||
filters={modalFilters}
|
||||
formData={props.formData}
|
||||
key={MenuKeys.DrillToDetail}
|
||||
setShowModal={setDrillModalIsOpen}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
)}
|
||||
|
||||
if (canExplore || canViewTable) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ViewResults,
|
||||
label: (
|
||||
<ViewResultsModalTrigger
|
||||
canExplore={props.supersetCanExplore}
|
||||
exploreUrl={props.exploreUrl}
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View as table')}</div>
|
||||
}
|
||||
modalRef={resultsMenuRef}
|
||||
modalTitle={t('Chart Data: %s', slice.slice_name)}
|
||||
modalBody={
|
||||
<ResultsPaneOnDashboard
|
||||
queryFormData={props.formData}
|
||||
queryForce={false}
|
||||
dataSize={20}
|
||||
isRequest
|
||||
isVisible
|
||||
canDownload={!!props.supersetCanCSV}
|
||||
/>
|
||||
}
|
||||
{(slice.description || canExplore) && <Menu.Divider />}
|
||||
|
||||
{supersetCanShare && (
|
||||
<ShareMenuItems
|
||||
dashboardId={dashboardId}
|
||||
dashboardComponentId={componentId}
|
||||
copyMenuItemTitle={t('Copy permalink to clipboard')}
|
||||
emailMenuItemTitle={t('Share chart by email')}
|
||||
emailSubject={t('Superset chart')}
|
||||
emailBody={t('Check out this chart: ')}
|
||||
addSuccessToast={addSuccessToast}
|
||||
addDangerToast={addDangerToast}
|
||||
title={t('Share')}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}
|
||||
)}
|
||||
|
||||
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
||||
useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
{props.supersetCanCSV && (
|
||||
<Menu.SubMenu title={t('Download')} key={MenuKeys.Download}>
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportCsv}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to .CSV')}
|
||||
</Menu.Item>
|
||||
{isPivotTable && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportPivotCsv}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to Pivoted .CSV')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportXlsx}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to Excel')}
|
||||
</Menu.Item>
|
||||
|
||||
const shareMenuItems = useShareMenuItems({
|
||||
dashboardId,
|
||||
dashboardComponentId: componentId,
|
||||
copyMenuItemTitle: t('Copy permalink to clipboard'),
|
||||
emailMenuItemTitle: t('Share chart by email'),
|
||||
emailSubject: t('Superset chart'),
|
||||
emailBody: t('Check out this chart: '),
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
title: t('Share'),
|
||||
});
|
||||
{isPivotTable && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportPivotXlsx}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to Pivoted Excel')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
||||
newMenuItems.push(drillToDetailMenuItem);
|
||||
if (drillToDetailByMenuItem) {
|
||||
newMenuItems.push(drillToDetailByMenuItem);
|
||||
}
|
||||
}
|
||||
|
||||
if (slice.description || canExplore) {
|
||||
newMenuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
if (supersetCanShare) {
|
||||
newMenuItems.push(shareMenuItems);
|
||||
}
|
||||
|
||||
if (props.supersetCanCSV) {
|
||||
newMenuItems.push({
|
||||
type: 'submenu',
|
||||
key: MenuKeys.Download,
|
||||
label: t('Download'),
|
||||
children: [
|
||||
{
|
||||
key: MenuKeys.ExportCsv,
|
||||
label: t('Export to .CSV'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
...(isPivotTable
|
||||
? [
|
||||
{
|
||||
key: MenuKeys.ExportPivotCsv,
|
||||
label: t('Export to Pivoted .CSV'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
{
|
||||
key: MenuKeys.ExportPivotXlsx,
|
||||
label: t('Export to Pivoted Excel'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: MenuKeys.ExportXlsx,
|
||||
label: t('Export to Excel'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
||||
props.supersetCanCSV &&
|
||||
isTable
|
||||
? [
|
||||
{
|
||||
key: MenuKeys.ExportFullCsv,
|
||||
label: t('Export to full .CSV'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
{
|
||||
key: MenuKeys.ExportFullXlsx,
|
||||
label: t('Export to full Excel'),
|
||||
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: MenuKeys.DownloadAsImage,
|
||||
label: t('Download as image'),
|
||||
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
||||
props.supersetCanCSV &&
|
||||
isTable && (
|
||||
<>
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportFullCsv}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to full .CSV')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportFullXlsx}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to full Excel')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
key={MenuKeys.DownloadAsImage}
|
||||
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Download as image')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isFullSize && (
|
||||
@@ -538,15 +522,7 @@ const SliceHeaderControls = (
|
||||
/>
|
||||
)}
|
||||
<NoAnimationDropdown
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
onClick={handleMenuClick}
|
||||
data-test={`slice_${slice.slice_id}-menu`}
|
||||
id={`slice_${slice.slice_id}-menu`}
|
||||
selectable={false}
|
||||
items={newMenuItems}
|
||||
/>
|
||||
)}
|
||||
popupRender={() => menu}
|
||||
overlayStyle={dropdownOverlayStyle}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { useDownloadMenuItems } from '.';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import DownloadMenuItems from '.';
|
||||
|
||||
const createProps = () => ({
|
||||
pdfMenuItemTitle: 'Export to PDF',
|
||||
@@ -30,17 +30,19 @@ const createProps = () => ({
|
||||
submenuKey: 'download',
|
||||
});
|
||||
|
||||
const MenuWrapper = () => {
|
||||
const downloadMenuItem = useDownloadMenuItems(createProps());
|
||||
const menuItems: MenuItem[] = [downloadMenuItem];
|
||||
return <Menu forceSubMenuRender items={menuItems} />;
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<Menu forceSubMenuRender>
|
||||
<DownloadMenuItems {...createProps()} />
|
||||
</Menu>,
|
||||
{
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
test('Should render menu items', () => {
|
||||
render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
renderComponent();
|
||||
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
|
||||
expect(screen.getByText('Download as Image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,21 +16,16 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||
import { MenuKeys } from 'src/dashboard/types';
|
||||
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import {
|
||||
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
|
||||
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
|
||||
} from 'src/logger/LogUtils';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { ComponentProps } from 'react';
|
||||
import { DownloadScreenshotFormat } from './types';
|
||||
import DownloadAsPdf from './DownloadAsPdf';
|
||||
import DownloadAsImage from './DownloadAsImage';
|
||||
|
||||
export interface UseDownloadMenuItemsProps {
|
||||
export interface DownloadMenuItemProps
|
||||
extends ComponentProps<typeof Menu.SubMenu> {
|
||||
pdfMenuItemTitle: string;
|
||||
imageMenuItemTitle: string;
|
||||
dashboardTitle: string;
|
||||
@@ -38,81 +33,56 @@ export interface UseDownloadMenuItemsProps {
|
||||
dashboardId: number;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
submenuKey: string;
|
||||
}
|
||||
|
||||
export const useDownloadMenuItems = (
|
||||
props: UseDownloadMenuItemsProps,
|
||||
): MenuItem => {
|
||||
const DownloadMenuItems = (props: DownloadMenuItemProps) => {
|
||||
const {
|
||||
pdfMenuItemTitle,
|
||||
imageMenuItemTitle,
|
||||
logEvent,
|
||||
dashboardId,
|
||||
dashboardTitle,
|
||||
submenuKey,
|
||||
disabled,
|
||||
title,
|
||||
...rest
|
||||
} = props;
|
||||
|
||||
const { addDangerToast } = useToasts();
|
||||
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
|
||||
|
||||
const isWebDriverScreenshotEnabled =
|
||||
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
|
||||
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
|
||||
|
||||
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
|
||||
|
||||
const onDownloadPdf = async (e: SyntheticEvent) => {
|
||||
try {
|
||||
downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
|
||||
} catch (error) {
|
||||
logging.error(error);
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
}
|
||||
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF);
|
||||
};
|
||||
|
||||
const onDownloadImage = async (e: SyntheticEvent) => {
|
||||
try {
|
||||
downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
|
||||
} catch (error) {
|
||||
logging.error(error);
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
}
|
||||
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE);
|
||||
};
|
||||
|
||||
const children: MenuItem[] = isWebDriverScreenshotEnabled
|
||||
? [
|
||||
{
|
||||
key: DownloadScreenshotFormat.PDF,
|
||||
label: pdfMenuItemTitle,
|
||||
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PDF),
|
||||
},
|
||||
{
|
||||
key: DownloadScreenshotFormat.PNG,
|
||||
label: imageMenuItemTitle,
|
||||
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PNG),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
key: 'download-pdf',
|
||||
label: pdfMenuItemTitle,
|
||||
onClick: (e: any) => onDownloadPdf(e.domEvent),
|
||||
},
|
||||
{
|
||||
key: 'download-image',
|
||||
label: imageMenuItemTitle,
|
||||
onClick: (e: any) => onDownloadImage(e.domEvent),
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
key: MenuKeys.Download,
|
||||
type: 'submenu',
|
||||
label: title,
|
||||
disabled,
|
||||
children,
|
||||
};
|
||||
return isWebDriverScreenshotEnabled ? (
|
||||
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
|
||||
<Menu.Item
|
||||
key={DownloadScreenshotFormat.PDF}
|
||||
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PDF)}
|
||||
>
|
||||
{pdfMenuItemTitle}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={DownloadScreenshotFormat.PNG}
|
||||
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PNG)}
|
||||
>
|
||||
{imageMenuItemTitle}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
) : (
|
||||
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
|
||||
<DownloadAsPdf
|
||||
text={pdfMenuItemTitle}
|
||||
dashboardTitle={dashboardTitle}
|
||||
logEvent={logEvent}
|
||||
/>
|
||||
<DownloadAsImage
|
||||
text={imageMenuItemTitle}
|
||||
dashboardTitle={dashboardTitle}
|
||||
logEvent={logEvent}
|
||||
/>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export default DownloadMenuItems;
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -26,8 +26,7 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
import * as copyTextToClipboard from 'src/utils/copy';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useShareMenuItems, ShareMenuItemProps } from '.';
|
||||
import ShareMenuItems from '.';
|
||||
|
||||
const spy = jest.spyOn(copyTextToClipboard, 'default');
|
||||
|
||||
@@ -70,23 +69,17 @@ afterAll((): void => {
|
||||
window.location = location;
|
||||
});
|
||||
|
||||
const MenuWrapper = (
|
||||
props: ComponentProps<typeof Menu> & { shareProps: ShareMenuItemProps },
|
||||
) => {
|
||||
const shareMenuItems = useShareMenuItems(props.shareProps);
|
||||
const menuItems: MenuItem[] = [shareMenuItems];
|
||||
return <Menu {...props} items={menuItems} />;
|
||||
};
|
||||
|
||||
test('Should render menu items', () => {
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
<Menu
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
shareProps={createProps()}
|
||||
/>,
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
|
||||
@@ -97,13 +90,14 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
spy.mockResolvedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
<Menu
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
shareProps={props}
|
||||
/>,
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -129,13 +123,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
spy.mockRejectedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
<Menu
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
shareProps={props}
|
||||
/>,
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -162,13 +157,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
test('Click on "Share dashboard by email" and succeed', async () => {
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
<Menu
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
shareProps={props}
|
||||
/>,
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -195,13 +191,14 @@ test('Click on "Share dashboard by email" and fail', async () => {
|
||||
);
|
||||
const props = createProps();
|
||||
render(
|
||||
<MenuWrapper
|
||||
<Menu
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
shareProps={props}
|
||||
/>,
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -19,13 +19,12 @@
|
||||
import { ComponentProps, RefObject } from 'react';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { t, logging } from '@superset-ui/core';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { getDashboardPermalink } from 'src/utils/urlUtils';
|
||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
|
||||
export interface ShareMenuItemProps
|
||||
extends ComponentProps<typeof Menu.SubMenu> {
|
||||
interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
|
||||
url?: string;
|
||||
copyMenuItemTitle: string;
|
||||
emailMenuItemTitle: string;
|
||||
@@ -41,10 +40,9 @@ export interface ShareMenuItemProps
|
||||
setOpenKeys?: Function;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||
const ShareMenuItems = (props: ShareMenuItemProps) => {
|
||||
const {
|
||||
copyMenuItemTitle,
|
||||
emailMenuItemTitle,
|
||||
@@ -98,23 +96,20 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...rest,
|
||||
type: 'submenu',
|
||||
label: title,
|
||||
key: MenuKeys.Share,
|
||||
disabled,
|
||||
children: [
|
||||
{
|
||||
key: MenuKeys.CopyLink,
|
||||
label: copyMenuItemTitle,
|
||||
onClick: onCopyLink,
|
||||
},
|
||||
{
|
||||
key: MenuKeys.ShareByEmail,
|
||||
label: emailMenuItemTitle,
|
||||
onClick: onShareByEmail,
|
||||
},
|
||||
],
|
||||
};
|
||||
return (
|
||||
<Menu.SubMenu
|
||||
title={title}
|
||||
key={MenuKeys.Share}
|
||||
disabled={disabled}
|
||||
{...rest}
|
||||
>
|
||||
<Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}>
|
||||
{copyMenuItemTitle}
|
||||
</Menu.Item>
|
||||
<Menu.Item key={MenuKeys.ShareByEmail} onClick={() => onShareByEmail()}>
|
||||
{emailMenuItemTitle}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
};
|
||||
export default ShareMenuItems;
|
||||
|
||||
@@ -155,34 +155,6 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
dashboardId,
|
||||
});
|
||||
const filterOwnState = filter.dataMask?.ownState || {};
|
||||
if (filter?.cascadeParentIds?.length) {
|
||||
// Prevent unnecessary backend requests by validating parent filter selections first
|
||||
|
||||
let selectedParentFilterValueCounts = 0;
|
||||
|
||||
filter?.cascadeParentIds?.forEach(pId => {
|
||||
const extraFormData = dataMaskSelected?.[pId]?.extraFormData;
|
||||
if (extraFormData?.filters?.length) {
|
||||
selectedParentFilterValueCounts += extraFormData.filters.length;
|
||||
} else if (extraFormData?.time_range) {
|
||||
selectedParentFilterValueCounts += 1;
|
||||
}
|
||||
});
|
||||
|
||||
// check if all parent filters with defaults have a value selected
|
||||
|
||||
let depsCount = dependencies.filters?.length ?? 0;
|
||||
|
||||
if (dependencies?.time_range) {
|
||||
depsCount += 1;
|
||||
}
|
||||
if (selectedParentFilterValueCounts !== depsCount) {
|
||||
// child filter should not request backend until it
|
||||
// has all the required information from parent filters
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: We should try to improve our useEffect hooks to depend more on
|
||||
// granular information instead of big objects that require deep comparison.
|
||||
const customizer = (
|
||||
@@ -254,7 +226,6 @@ const FilterValue: FC<FilterControlProps> = ({
|
||||
hasDataSource,
|
||||
isRefreshing,
|
||||
shouldRefresh,
|
||||
dataMaskSelected,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -96,14 +96,14 @@ test('remove filter', async () => {
|
||||
test('add filter', async () => {
|
||||
defaultRender();
|
||||
// First trash icon
|
||||
const addFilterButton = await screen.findByText('Add filter');
|
||||
const addFilterButton = await screen.findByText('Add Filter');
|
||||
userEvent.click(addFilterButton);
|
||||
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
|
||||
});
|
||||
|
||||
test('add divider', async () => {
|
||||
defaultRender();
|
||||
const addFilterButton = await screen.findByText('Add divider');
|
||||
const addFilterButton = await screen.findByText('Add Divider');
|
||||
userEvent.click(addFilterButton);
|
||||
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
|
||||
});
|
||||
@@ -128,7 +128,7 @@ test('filter container should scroll to bottom when adding items', async () => {
|
||||
|
||||
defaultRender(state, props);
|
||||
|
||||
const addFilterButton = await screen.findByText('Add filter');
|
||||
const addFilterButton = await screen.findByText('Add Filter');
|
||||
|
||||
userEvent.click(addFilterButton);
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ const FilterTitlePane: FC<Props> = ({
|
||||
data-test="add-new-filter-button"
|
||||
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
|
||||
>
|
||||
{t('Add filter')}
|
||||
{t('Add Filter')}
|
||||
</Button>
|
||||
<Button
|
||||
buttonSize="default"
|
||||
@@ -125,7 +125,7 @@ const FilterTitlePane: FC<Props> = ({
|
||||
data-test="add-new-divider-button"
|
||||
onClick={() => handleOnAdd(NativeFilterType.Divider)}
|
||||
>
|
||||
{t('Add divider')}
|
||||
{t('Add Divider')}
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContainer>
|
||||
|
||||
@@ -53,6 +53,7 @@ import {
|
||||
sections,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { rgba } from 'emotion-rgba';
|
||||
import { kebabCase, isEqual } from 'lodash';
|
||||
|
||||
import {
|
||||
@@ -117,11 +118,16 @@ const iconStyles = css`
|
||||
|
||||
const actionButtonsContainerStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: ${theme.sizeUnit * 4}px;
|
||||
background: ${theme.colorBgContainer};
|
||||
flex-shrink: 0;
|
||||
z-index: 999;
|
||||
background: linear-gradient(
|
||||
${rgba(theme.colorBgBase, 0)},
|
||||
${theme.colorBgBase} 35%
|
||||
);
|
||||
|
||||
& > button {
|
||||
min-width: 156px;
|
||||
@@ -132,18 +138,15 @@ const Styles = styled.div`
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
// Resizable add overflow-y: auto as a style to this div
|
||||
// To override it, we need to use !important
|
||||
overflow: visible !important;
|
||||
|
||||
#controlSections {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
padding-bottom: ${({ theme }) => theme.sizeUnit * 10}px;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
overflow: auto;
|
||||
flex: 1 1 100%;
|
||||
|
||||
@@ -30,6 +30,10 @@ export type DateLabelProps = {
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
};
|
||||
|
||||
// This is the color that antd components (such as Select or Input) use on hover
|
||||
// TODO: use theme.colorPrimary here and in antd components
|
||||
const ACTIVE_BORDER_COLOR = '#45BED6';
|
||||
|
||||
const LabelContainer = styled.div<{
|
||||
isActive?: boolean;
|
||||
isPlaceholder?: boolean;
|
||||
@@ -43,9 +47,10 @@ const LabelContainer = styled.div<{
|
||||
|
||||
padding: 0 ${theme.sizeUnit * 3}px;
|
||||
|
||||
background-color: ${theme.colorBgContainer};
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
|
||||
border: 1px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
|
||||
border: 1px solid
|
||||
${isActive ? ACTIVE_BORDER_COLOR : theme.colors.grayscale.light2};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
|
||||
cursor: pointer;
|
||||
@@ -53,11 +58,11 @@ const LabelContainer = styled.div<{
|
||||
transition: border-color 0.3s cubic-bezier(0.65, 0.05, 0.36, 1);
|
||||
:hover,
|
||||
:focus {
|
||||
border-color: ${theme.colorPrimary};
|
||||
border-color: ${ACTIVE_BORDER_COLOR};
|
||||
}
|
||||
|
||||
.date-label-content {
|
||||
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
|
||||
color: ${isPlaceholder ? theme.colors.grayscale.light1 : theme.colorText};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
@@ -66,7 +71,6 @@ const LabelContainer = styled.div<{
|
||||
}
|
||||
|
||||
span[role='img'] {
|
||||
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
|
||||
margin-left: auto;
|
||||
padding-left: ${theme.sizeUnit}px;
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import { getChartPermalink } from 'src/utils/urlUtils';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
|
||||
@@ -123,18 +123,12 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const theme = useTheme();
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const dispatch = useDispatch();
|
||||
const [showReportSubMenu, setShowReportSubMenu] = useState(null);
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const chart = useSelector(
|
||||
state => state.charts?.[getChartKey(state.explore)],
|
||||
);
|
||||
|
||||
// Use the updated report menu items hook
|
||||
const reportMenuItem = useHeaderReportMenuItems({
|
||||
chart,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
});
|
||||
|
||||
const { datasource } = latestQueryFormData;
|
||||
|
||||
const shareByEmail = useCallback(async () => {
|
||||
@@ -209,106 +203,14 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const menuItems = [];
|
||||
|
||||
// Edit chart properties
|
||||
if (slice) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||
label: t('Edit chart properties'),
|
||||
onClick: () => {
|
||||
const handleMenuClick = useCallback(
|
||||
({ key, domEvent }) => {
|
||||
switch (key) {
|
||||
case MENU_KEYS.EDIT_PROPERTIES:
|
||||
onOpenPropertiesModal();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// On dashboards submenu
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
|
||||
type: 'submenu',
|
||||
label: t('On dashboards'),
|
||||
children: [
|
||||
{
|
||||
key: 'dashboards-content',
|
||||
label: (
|
||||
<DashboardsSubMenu
|
||||
chartId={slice?.slice_id}
|
||||
dashboards={dashboards}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Download submenu
|
||||
const downloadChildren = [];
|
||||
|
||||
if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) {
|
||||
downloadChildren.push(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||
label: t('Export to original .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportCSV();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
|
||||
label: t('Export to pivoted .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportCSVPivoted();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
|
||||
label: t('Export to Pivoted Excel'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportPivotExcel(
|
||||
'.pvtTable',
|
||||
slice?.slice_name ?? t('pivoted_xlsx'),
|
||||
);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
downloadChildren.push({
|
||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||
label: t('Export to .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_CSV:
|
||||
exportCSV();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -317,17 +219,18 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
downloadChildren.push(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_JSON,
|
||||
label: t('Export to .JSON'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_CSV_PIVOTED:
|
||||
exportCSVPivoted();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_JSON:
|
||||
exportJson();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -336,33 +239,8 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.DOWNLOAD_AS_IMAGE,
|
||||
label: t('Download as image'),
|
||||
icon: <Icons.FileImageOutlined />,
|
||||
onClick: e => {
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
true,
|
||||
)(e.domEvent);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_XLSX,
|
||||
label: t('Export to Excel'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_XLSX:
|
||||
exportExcel();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -371,128 +249,225 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DOWNLOAD_SUBMENU,
|
||||
type: 'submenu',
|
||||
label: t('Download'),
|
||||
children: downloadChildren,
|
||||
});
|
||||
|
||||
// Share submenu
|
||||
const shareChildren = [
|
||||
{
|
||||
key: MENU_KEYS.COPY_PERMALINK,
|
||||
label: t('Copy permalink to clipboard'),
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
|
||||
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
// eslint-disable-next-line camelcase
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
true,
|
||||
)(domEvent);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.COPY_PERMALINK:
|
||||
copyLink();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.SHARE_BY_EMAIL,
|
||||
label: t('Share chart by email'),
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EMBED_CODE:
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.SHARE_BY_EMAIL:
|
||||
shareByEmail();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
break;
|
||||
case MENU_KEYS.VIEW_QUERY:
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.RUN_IN_SQL_LAB:
|
||||
onOpenInEditor(latestQueryFormData, domEvent.metaKey);
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
copyLink,
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
shareByEmail,
|
||||
slice?.slice_name,
|
||||
],
|
||||
);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) {
|
||||
shareChildren.push({
|
||||
key: MENU_KEYS.EMBED_CODE,
|
||||
label: (
|
||||
const menu = useMemo(
|
||||
() => (
|
||||
<Menu onClick={handleMenuClick} selectable={false} {...rest}>
|
||||
<>
|
||||
{slice && (
|
||||
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
|
||||
{t('Edit chart properties')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.SubMenu
|
||||
title={t('On dashboards')}
|
||||
key={MENU_KEYS.DASHBOARDS_ADDED_TO}
|
||||
>
|
||||
<DashboardsSubMenu
|
||||
chartId={slice?.slice_id}
|
||||
dashboards={dashboards}
|
||||
/>
|
||||
</Menu.SubMenu>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
<Menu.SubMenu title={t('Download')} key={MENU_KEYS.DOWNLOAD_SUBMENU}>
|
||||
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to original .CSV')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to pivoted .CSV')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to .CSV')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_JSON}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to .JSON')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
|
||||
icon={<Icons.FileImageOutlined />}
|
||||
>
|
||||
{t('Download as image')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_XLSX}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to Excel')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to Pivoted Excel')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
|
||||
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
|
||||
{t('Copy permalink to clipboard')}
|
||||
</Menu.Item>
|
||||
<Menu.Item key={MENU_KEYS.SHARE_BY_EMAIL}>
|
||||
{t('Share chart by email')}
|
||||
</Menu.Item>
|
||||
{isFeatureEnabled(FeatureFlag.EmbeddableCharts) ? (
|
||||
<Menu.Item key={MENU_KEYS.EMBED_CODE}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="embed-code-button">{t('Embed code')}</div>
|
||||
}
|
||||
modalTitle={t('Embed code')}
|
||||
modalBody={
|
||||
<EmbedCodeContent
|
||||
formData={latestQueryFormData}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
}
|
||||
maxWidth={`${theme.sizeUnit * 100}px`}
|
||||
destroyOnHidden
|
||||
responsive
|
||||
/>
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.SubMenu>
|
||||
<Menu.Divider />
|
||||
{showReportSubMenu ? (
|
||||
<>
|
||||
<HeaderReportDropDown
|
||||
submenuTitle={t('Manage email report')}
|
||||
chart={chart}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportSubMenu={showReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
) : (
|
||||
<HeaderReportDropDown
|
||||
chart={chart}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
)}
|
||||
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="embed-code-button">{t('Embed code')}</div>
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('Embed code')}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={
|
||||
<EmbedCodeContent
|
||||
formData={latestQueryFormData}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
}
|
||||
maxWidth={`${theme.sizeUnit * 100}px`}
|
||||
destroyOnHidden
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
/>
|
||||
),
|
||||
onClick: () => setIsDropdownVisible(false),
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.SHARE_SUBMENU,
|
||||
type: 'submenu',
|
||||
label: t('Share'),
|
||||
children: shareChildren,
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Report menu item
|
||||
if (reportMenuItem) {
|
||||
menuItems.push(reportMenuItem);
|
||||
}
|
||||
|
||||
// View query
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.VIEW_QUERY,
|
||||
label: (
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
/>
|
||||
),
|
||||
onClick: () => setIsDropdownVisible(false),
|
||||
});
|
||||
|
||||
// Run in SQL Lab
|
||||
if (datasource) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.RUN_IN_SQL_LAB,
|
||||
label: t('Run in SQL Lab'),
|
||||
onClick: e => {
|
||||
onOpenInEditor(latestQueryFormData, e.domEvent.metaKey);
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return <Menu selectable={false} items={menuItems} {...rest} />;
|
||||
}, [
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
copyLink,
|
||||
dashboards,
|
||||
datasource,
|
||||
dispatch,
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportExcel,
|
||||
exportJson,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
reportMenuItem,
|
||||
shareByEmail,
|
||||
slice,
|
||||
theme.sizeUnit,
|
||||
]);
|
||||
|
||||
</Menu.Item>
|
||||
{datasource && (
|
||||
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
|
||||
{t('Run in SQL Lab')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
),
|
||||
[
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
chart,
|
||||
dashboards,
|
||||
handleMenuClick,
|
||||
isDropdownVisible,
|
||||
latestQueryFormData,
|
||||
showReportSubMenu,
|
||||
slice,
|
||||
theme.sizeUnit,
|
||||
],
|
||||
);
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||
};
|
||||
|
||||
@@ -1087,27 +1087,18 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
onChange={setDatabaseModel}
|
||||
placeholder={t('Choose a database...')}
|
||||
options={[
|
||||
...(availableDbs?.databases || []).map(
|
||||
(database: DatabaseForm, index: number) => ({
|
||||
...(availableDbs?.databases || [])
|
||||
.sort((a: DatabaseForm, b: DatabaseForm) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((database: DatabaseForm, index: number) => ({
|
||||
value: database.name,
|
||||
label: database.name,
|
||||
key: `database-${index}`,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
{ value: 'Other', label: t('Other'), key: 'Other' },
|
||||
]}
|
||||
showSearch
|
||||
sortComparator={(a, b) => {
|
||||
// Always put "Other" at the end
|
||||
if (a.value === 'Other') return 1;
|
||||
if (b.value === 'Other') return -1;
|
||||
// For all other options, sort alphabetically
|
||||
return String(a.label).localeCompare(String(b.label));
|
||||
}}
|
||||
getPopupContainer={triggerNode =>
|
||||
triggerNode.parentElement || document.body
|
||||
}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
/>
|
||||
<Alert
|
||||
showIcon
|
||||
|
||||
@@ -43,7 +43,6 @@ interface MenuProps {
|
||||
const StyledHeader = styled.header`
|
||||
${({ theme }) => `
|
||||
background-color: ${theme.colorBgContainer};
|
||||
border-bottom: 1px solid ${theme.colorBorderSecondary};
|
||||
z-index: 10;
|
||||
|
||||
&:nth-last-of-type(2) nav {
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
*/
|
||||
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { useHeaderReportMenuItems, HeaderReportProps } from './index';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import HeaderReportDropdown, { HeaderReportProps } from '.';
|
||||
|
||||
const createProps = () => ({
|
||||
dashboardId: 1,
|
||||
useTextMenu: false,
|
||||
setShowReportSubMenu: jest.fn,
|
||||
showReportModal: jest.fn,
|
||||
setCurrentReportDeleting: jest.fn,
|
||||
@@ -114,14 +115,13 @@ const stateWithUserAndReport = {
|
||||
},
|
||||
};
|
||||
|
||||
const MenuWrapper = (props: HeaderReportProps) => {
|
||||
const reportMenuItems = useHeaderReportMenuItems(props);
|
||||
const menuItems: MenuItem[] = [reportMenuItems];
|
||||
return <Menu items={menuItems} forceSubMenuRender />;
|
||||
};
|
||||
|
||||
function setup(props: HeaderReportProps, initialState = {}) {
|
||||
render(<MenuWrapper {...props} />, { useRedux: true, initialState });
|
||||
render(
|
||||
<Menu>
|
||||
<HeaderReportDropdown {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
}
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -147,7 +147,7 @@ describe('Header Report Dropdown', () => {
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
expect(screen.getAllByRole('menuitem')[0]).toBeInTheDocument();
|
||||
expect(screen.getByRole('menuitem')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the dropdown correctly', async () => {
|
||||
@@ -155,6 +155,8 @@ describe('Header Report Dropdown', () => {
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
const emailReportModalButton = screen.getByRole('menuitem');
|
||||
userEvent.hover(emailReportModalButton);
|
||||
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit email report')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete email report')).toBeInTheDocument();
|
||||
@@ -166,6 +168,8 @@ describe('Header Report Dropdown', () => {
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
const emailReportModalButton = screen.getByRole('menuitem');
|
||||
userEvent.click(emailReportModalButton);
|
||||
const editModal = await screen.findByText('Edit email report');
|
||||
userEvent.click(editModal);
|
||||
expect(mockedProps.showReportModal).toHaveBeenCalled();
|
||||
@@ -177,34 +181,49 @@ describe('Header Report Dropdown', () => {
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
const emailReportModalButton = screen.getByRole('menuitem');
|
||||
userEvent.click(emailReportModalButton);
|
||||
const deleteModal = await screen.findByText('Delete email report');
|
||||
userEvent.click(deleteModal);
|
||||
expect(mockedProps.setCurrentReportDeleting).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders Manage Email Reports Menu if there is a report', async () => {
|
||||
const mockedProps = createProps();
|
||||
it('renders Manage Email Reports Menu if textMenu is set to true and there is a report', async () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
userEvent.click(screen.getByRole('menuitem'));
|
||||
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
|
||||
expect(screen.getByText('Edit email report')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete email report')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Schedule Email Reports if there is a report', async () => {
|
||||
const mockedProps = createProps();
|
||||
|
||||
it('renders Schedule Email Reports if textMenu is set to true and there is a report', async () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithOnlyUser);
|
||||
});
|
||||
userEvent.click(screen.getByRole('menuitem'));
|
||||
expect(
|
||||
await screen.findByText('Set up an email report'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Schedule Email Reports as long as user has permission through any role', async () => {
|
||||
const mockedProps = createProps();
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithNonAdminUser);
|
||||
});
|
||||
@@ -215,8 +234,11 @@ describe('Header Report Dropdown', () => {
|
||||
});
|
||||
|
||||
it('do not render Schedule Email Reports if user no permission', () => {
|
||||
const mockedProps = createProps();
|
||||
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithNonMenuAccessOnManage);
|
||||
});
|
||||
|
||||
@@ -16,20 +16,24 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
t,
|
||||
SupersetTheme,
|
||||
css,
|
||||
styled,
|
||||
FeatureFlag,
|
||||
isFeatureEnabled,
|
||||
getExtensionsRegistry,
|
||||
usePrevious,
|
||||
css,
|
||||
} from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Checkbox } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Switch } from '@superset-ui/core/components/Switch';
|
||||
import { AlertObject } from 'src/features/alerts/types';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { Checkbox } from '@superset-ui/core/components';
|
||||
import { noOp } from 'src/utils/common';
|
||||
import { ChartState } from 'src/explore/types';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
@@ -37,14 +41,35 @@ import {
|
||||
fetchUISpecificReport,
|
||||
toggleActive,
|
||||
} from 'src/features/reports/ReportModal/actions';
|
||||
import { reportSelector } from 'src/views/CRUD/hooks';
|
||||
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
|
||||
export enum CreationMethod {
|
||||
Charts = 'charts',
|
||||
Dashboards = 'dashboards',
|
||||
}
|
||||
const deleteColor = (theme: SupersetTheme) => css`
|
||||
color: ${theme.colorError};
|
||||
`;
|
||||
|
||||
const onMenuHover = (theme: SupersetTheme) => css`
|
||||
& .ant-menu-item {
|
||||
padding: 5px 12px;
|
||||
margin-top: 0px;
|
||||
margin-bottom: 4px;
|
||||
:hover {
|
||||
color: ${theme.colorText};
|
||||
}
|
||||
}
|
||||
:hover {
|
||||
background-color: ${theme.colorPrimaryBg};
|
||||
}
|
||||
`;
|
||||
|
||||
const onMenuItemHover = (theme: SupersetTheme) => css`
|
||||
&:hover {
|
||||
color: ${theme.colorText};
|
||||
background-color: ${theme.colorPrimaryBg};
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledDropdownItemWithIcon = styled.div`
|
||||
display: flex;
|
||||
@@ -60,59 +85,63 @@ const DropdownItemExtension = extensionsRegistry.get(
|
||||
'report-modal.dropdown.item.icon',
|
||||
);
|
||||
|
||||
export enum CreationMethod {
|
||||
Charts = 'charts',
|
||||
Dashboards = 'dashboards',
|
||||
}
|
||||
export interface HeaderReportProps {
|
||||
dashboardId?: number;
|
||||
chart?: ChartState;
|
||||
useTextMenu?: boolean;
|
||||
setShowReportSubMenu?: (show: boolean) => void;
|
||||
showReportSubMenu?: boolean;
|
||||
submenuTitle?: string;
|
||||
showReportModal: () => void;
|
||||
setCurrentReportDeleting: (report: AlertObject | null) => void;
|
||||
}
|
||||
|
||||
export const useHeaderReportMenuItems = ({
|
||||
// Same instance to be used in useEffects
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
export default function HeaderReportDropDown({
|
||||
dashboardId,
|
||||
chart,
|
||||
useTextMenu = false,
|
||||
setShowReportSubMenu,
|
||||
submenuTitle,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
}: HeaderReportProps): MenuItem | null => {
|
||||
}: HeaderReportProps) {
|
||||
const dispatch = useDispatch();
|
||||
const resourceId = dashboardId || chart?.id;
|
||||
const resourceType = dashboardId
|
||||
? CreationMethod.Dashboards
|
||||
: CreationMethod.Charts;
|
||||
|
||||
// Select the reports state and specific report with proper reactivity
|
||||
const report = useSelector<any, AlertObject | null>(state => {
|
||||
if (!resourceId) return null;
|
||||
// Select directly from the reports state to ensure reactivity
|
||||
const reportsState = state.reports || {};
|
||||
const resourceTypeReports = reportsState[resourceType] || {};
|
||||
const reportData = resourceTypeReports[resourceId];
|
||||
|
||||
// Debug logging to understand what's happening
|
||||
console.log('Report selector called:', {
|
||||
resourceId,
|
||||
resourceType,
|
||||
reportsState: Object.keys(reportsState),
|
||||
resourceTypeReports: Object.keys(resourceTypeReports),
|
||||
reportData: reportData
|
||||
? { id: reportData.id, name: reportData.name }
|
||||
: null,
|
||||
});
|
||||
|
||||
return reportData || null;
|
||||
const report = useSelector<any, AlertObject>(state => {
|
||||
const resourceType = dashboardId
|
||||
? CreationMethod.Dashboards
|
||||
: CreationMethod.Charts;
|
||||
return (
|
||||
reportSelector(state, resourceType, dashboardId || chart?.id) ||
|
||||
EMPTY_OBJECT
|
||||
);
|
||||
});
|
||||
|
||||
const isReportActive: boolean = report?.active || false;
|
||||
const user: UserWithPermissionsAndRoles = useSelector<
|
||||
any,
|
||||
UserWithPermissionsAndRoles
|
||||
>(state => state.user);
|
||||
|
||||
const prevDashboard = usePrevious(dashboardId);
|
||||
|
||||
// Check if user can add reports
|
||||
const canAddReports = () => {
|
||||
if (!isFeatureEnabled(FeatureFlag.AlertReports)) return false;
|
||||
if (!user?.userId) return false;
|
||||
if (!resourceId) return false;
|
||||
if (!isFeatureEnabled(FeatureFlag.AlertReports)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!user?.userId) {
|
||||
// this is in the case that there is an anonymous user.
|
||||
return false;
|
||||
}
|
||||
|
||||
// Cannot add reports if the resource is not saved
|
||||
if (!(dashboardId || chart?.id)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const roles = Object.keys(user.roles || []);
|
||||
const permissions = roles.map(key =>
|
||||
@@ -123,11 +152,17 @@ export const useHeaderReportMenuItems = ({
|
||||
return permissions.some(permission => permission.length > 0);
|
||||
};
|
||||
|
||||
const prevDashboard = usePrevious(dashboardId);
|
||||
const toggleActiveKey = async (data: AlertObject, checked: boolean) => {
|
||||
if (data?.id) {
|
||||
dispatch(toggleActive(data, checked));
|
||||
}
|
||||
};
|
||||
|
||||
const shouldFetch =
|
||||
canAddReports() &&
|
||||
!!((dashboardId && prevDashboard !== dashboardId) || chart?.id);
|
||||
|
||||
// Fetch report data when needed
|
||||
useEffect(() => {
|
||||
if (shouldFetch) {
|
||||
dispatch(
|
||||
@@ -135,82 +170,113 @@ export const useHeaderReportMenuItems = ({
|
||||
userId: user.userId,
|
||||
filterField: dashboardId ? 'dashboard_id' : 'chart_id',
|
||||
creationMethod: dashboardId ? 'dashboards' : 'charts',
|
||||
resourceId,
|
||||
resourceId: dashboardId || chart?.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, [dispatch, shouldFetch, user?.userId, dashboardId, resourceId]);
|
||||
}, []);
|
||||
|
||||
// Don't show anything if user can't add reports
|
||||
if (!canAddReports()) {
|
||||
return null;
|
||||
}
|
||||
const showReportSubMenu = report && setShowReportSubMenu && canAddReports();
|
||||
|
||||
// Handler functions
|
||||
const handleShowModal = () => showReportModal();
|
||||
const handleDeleteReport = () => setCurrentReportDeleting(report);
|
||||
const handleToggleActive = () => {
|
||||
if (report?.id) {
|
||||
dispatch(toggleActive(report, !report.active));
|
||||
useEffect(() => {
|
||||
if (showReportSubMenu) {
|
||||
setShowReportSubMenu(true);
|
||||
} else if (!report && setShowReportSubMenu) {
|
||||
setShowReportSubMenu(false);
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const handleShowMenu = () => {
|
||||
showReportModal();
|
||||
};
|
||||
|
||||
// If no report exists, show "Set up email report" option
|
||||
if (!report || !report.id) {
|
||||
return {
|
||||
key: 'email-report-setup',
|
||||
type: 'submenu',
|
||||
label: t('Manage email report'),
|
||||
children: [
|
||||
{
|
||||
key: 'set-up-report',
|
||||
label: DropdownItemExtension ? (
|
||||
const handleDeleteMenuClick = () => {
|
||||
setCurrentReportDeleting(report);
|
||||
};
|
||||
|
||||
const textMenu = () =>
|
||||
isEmpty(report) ? (
|
||||
<Menu.SubMenu title={submenuTitle} css={onMenuHover}>
|
||||
<Menu.Item onClick={handleShowMenu}>
|
||||
{DropdownItemExtension ? (
|
||||
<StyledDropdownItemWithIcon>
|
||||
<div>{t('Set up an email report')}</div>
|
||||
<DropdownItemExtension />
|
||||
</StyledDropdownItemWithIcon>
|
||||
) : (
|
||||
t('Set up an email report')
|
||||
),
|
||||
onClick: handleShowModal,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// If report exists, show management options
|
||||
return {
|
||||
key: 'email-report-manage',
|
||||
type: 'submenu',
|
||||
label: t('Manage email report'),
|
||||
children: [
|
||||
{
|
||||
key: 'toggle-active',
|
||||
label: (
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</Menu.SubMenu>
|
||||
) : (
|
||||
<Menu.SubMenu
|
||||
title={submenuTitle}
|
||||
css={css`
|
||||
border: none;
|
||||
`}
|
||||
>
|
||||
<Menu.Item
|
||||
css={onMenuItemHover}
|
||||
onClick={() => toggleActiveKey(report, !isReportActive)}
|
||||
>
|
||||
<MenuItemWithCheckboxContainer>
|
||||
<Checkbox
|
||||
checked={report.active || false}
|
||||
onChange={noOp}
|
||||
css={theme => css`
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`}
|
||||
/>
|
||||
<Checkbox checked={isReportActive} onChange={noOp} />
|
||||
{t('Email reports active')}
|
||||
</MenuItemWithCheckboxContainer>
|
||||
),
|
||||
onClick: handleToggleActive,
|
||||
},
|
||||
{
|
||||
key: 'edit-report',
|
||||
label: t('Edit email report'),
|
||||
onClick: handleShowModal,
|
||||
},
|
||||
{
|
||||
key: 'delete-report',
|
||||
label: t('Delete email report'),
|
||||
onClick: handleDeleteReport,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
</Menu.Item>
|
||||
<Menu.Item css={onMenuItemHover} onClick={handleShowMenu}>
|
||||
{t('Edit email report')}
|
||||
</Menu.Item>
|
||||
<Menu.Item css={onMenuItemHover} onClick={handleDeleteMenuClick}>
|
||||
{t('Delete email report')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
const menu = (title: ReactNode) => (
|
||||
<Menu.SubMenu
|
||||
title={title}
|
||||
css={css`
|
||||
width: 200px;
|
||||
`}
|
||||
>
|
||||
<Menu.Item>
|
||||
{t('Email reports active')}
|
||||
<Switch
|
||||
data-test="toggle-active"
|
||||
checked={isReportActive}
|
||||
onClick={(checked: boolean) => toggleActiveKey(report, checked)}
|
||||
size="small"
|
||||
css={theme => css`
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
</Menu.Item>
|
||||
<Menu.Item onClick={() => showReportModal()}>
|
||||
{t('Edit email report')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => setCurrentReportDeleting(report)}
|
||||
css={deleteColor}
|
||||
>
|
||||
{t('Delete email report')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
);
|
||||
|
||||
const iconMenu = () =>
|
||||
isEmpty(report) ? (
|
||||
<span
|
||||
role="button"
|
||||
title={t('Schedule email report')}
|
||||
tabIndex={0}
|
||||
className="action-button action-schedule-report"
|
||||
onClick={() => showReportModal()}
|
||||
>
|
||||
<Icons.CalendarOutlined />
|
||||
</span>
|
||||
) : (
|
||||
menu(<Icons.CalendarOutlined />)
|
||||
);
|
||||
return <>{canAddReports() && (useTextMenu ? textMenu() : iconMenu())}</>;
|
||||
}
|
||||
|
||||
@@ -32,13 +32,11 @@ import {
|
||||
Form,
|
||||
Tooltip,
|
||||
Alert,
|
||||
Label,
|
||||
} from '@superset-ui/core/components';
|
||||
import { useJsonValidation } from '@superset-ui/core/components/AsyncAceEditor';
|
||||
import { Typography } from '@superset-ui/core/components/Typography';
|
||||
|
||||
import { OnlyKeyWithType } from 'src/utils/types';
|
||||
import { CopyToClipboard } from 'src/components/CopyToClipboard';
|
||||
import { ThemeObject } from './types';
|
||||
|
||||
interface ThemeModalProps {
|
||||
@@ -342,27 +340,6 @@ const ThemeModal: FunctionComponent<ThemeModalProps> = ({
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{currentTheme?.uuid && (
|
||||
<Form.Item label={t('UUID')}>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${supersetTheme.sizeUnit * 2}px;
|
||||
`}
|
||||
>
|
||||
<Label monospace>{currentTheme.uuid}</Label>
|
||||
<CopyToClipboard
|
||||
text={currentTheme.uuid}
|
||||
shouldShowText={false}
|
||||
wrapped={false}
|
||||
copyNode={<Icons.CopyOutlined iconSize="m" />}
|
||||
tooltipText={t('Copy UUID to clipboard')}
|
||||
/>
|
||||
</div>
|
||||
</Form.Item>
|
||||
)}
|
||||
|
||||
<Form.Item label={t('JSON Configuration')} required={!isReadOnly}>
|
||||
<Alert
|
||||
type="info"
|
||||
|
||||
@@ -151,8 +151,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
const [col] = groupby;
|
||||
const [initialColtypeMap] = useState(coltypeMap);
|
||||
const [search, setSearch] = useState('');
|
||||
const isChangedByUser = useRef(false);
|
||||
const prevDataRef = useRef(data);
|
||||
const [dataMask, dispatchDataMask] = useImmerReducer(reducer, {
|
||||
extraFormData: {},
|
||||
filterState,
|
||||
@@ -273,8 +271,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
} else {
|
||||
updateDataMask(values);
|
||||
}
|
||||
|
||||
isChangedByUser.current = true;
|
||||
},
|
||||
[updateDataMask, formData.nativeFilterId, clearAllTrigger],
|
||||
);
|
||||
@@ -372,61 +368,6 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
inverseSelection,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const prev = prevDataRef.current;
|
||||
const curr = data;
|
||||
|
||||
const hasDataChanged =
|
||||
prev?.length !== curr?.length ||
|
||||
prev?.some((row, i) => {
|
||||
const prevVal = row[col];
|
||||
const currVal = curr[i][col];
|
||||
return typeof prevVal === 'bigint' || typeof currVal === 'bigint'
|
||||
? prevVal?.toString() !== currVal?.toString()
|
||||
: prevVal !== currVal;
|
||||
});
|
||||
|
||||
// If data actually changed (e.g., due to parent filter), reset flag
|
||||
if (hasDataChanged) {
|
||||
isChangedByUser.current = false;
|
||||
prevDataRef.current = data;
|
||||
}
|
||||
}, [data, col]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
isChangedByUser.current &&
|
||||
filterState.value &&
|
||||
filterState.value.every((value?: any) =>
|
||||
data.some(row => row[col] === value),
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
const firstItem: SelectValue = data[0]
|
||||
? (groupby.map(col => data[0][col]) as string[])
|
||||
: null;
|
||||
|
||||
if (
|
||||
defaultToFirstItem &&
|
||||
Object.keys(formData?.extraFormData || {}).length &&
|
||||
filterState.value !== undefined &&
|
||||
firstItem !== null &&
|
||||
filterState.value !== firstItem
|
||||
) {
|
||||
if (firstItem?.[0] !== undefined) {
|
||||
updateDataMask(firstItem);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
defaultToFirstItem,
|
||||
updateDataMask,
|
||||
formData,
|
||||
data,
|
||||
JSON.stringify(filterState.value),
|
||||
isChangedByUser.current,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
setDataMask(dataMask);
|
||||
}, [JSON.stringify(dataMask)]);
|
||||
|
||||
@@ -134,7 +134,6 @@ export default function PluginFilterTimegrain(
|
||||
ref={inputRef}
|
||||
options={options}
|
||||
onOpenChange={setFilterActive}
|
||||
sortComparator={() => 0} // Disable frontend sorting to preserve backend order
|
||||
/>
|
||||
</FormItem>
|
||||
</FilterPluginStyle>
|
||||
|
||||
@@ -1,588 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { fireEvent, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
mockCharts,
|
||||
mockHandleResourceExport,
|
||||
renderChartList,
|
||||
setupMocks,
|
||||
} from './ChartList.testHelpers';
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock the feature flag
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the export utility
|
||||
jest.mock('src/utils/export', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
['can_export', 'Chart'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ChartList Card View Tests', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
|
||||
// Enable card view as default
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders ChartList in card view', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for chart list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify we're in card view by default (no table should be present)
|
||||
expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument();
|
||||
|
||||
// Verify basic card view elements are present
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
|
||||
// Verify card view toggle is active (appstore icon should have active class)
|
||||
const cardViewToggle = screen.getByRole('img', { name: 'appstore' });
|
||||
const cardViewButton = cardViewToggle.closest('[role="button"]');
|
||||
expect(cardViewButton).toHaveClass('active');
|
||||
|
||||
// Verify list view toggle is not active
|
||||
const listViewToggle = screen.getByRole('img', { name: 'unordered-list' });
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
expect(listViewButton).not.toHaveClass('active');
|
||||
});
|
||||
|
||||
it('switches from card view to list view', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify starting in card view
|
||||
expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument();
|
||||
|
||||
// Switch to list view
|
||||
const listViewToggle = screen.getByRole('img', { name: 'unordered-list' });
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
expect(listViewButton).not.toBeNull();
|
||||
fireEvent.click(listViewButton!);
|
||||
|
||||
// Verify table is now rendered (indicating list view)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders ChartList in card view with thumbnails enabled', async () => {
|
||||
// Enable thumbnails feature flag
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) =>
|
||||
feature === 'LISTVIEWS_DEFAULT_CARD_VIEW' || feature === 'THUMBNAILS',
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for chart list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Wait for chart metadata section to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should show images (thumbnails) in card view when feature is enabled
|
||||
const allImages = await screen.findAllByTestId('image-loader');
|
||||
expect(allImages).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('displays chart data correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for chart list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Wait for cards to render
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const testChart = mockCharts[0];
|
||||
|
||||
// 1. Verify chart name appears
|
||||
expect(screen.getByText(testChart.slice_name)).toBeInTheDocument();
|
||||
|
||||
// 2. Verify favorite stars exist (one per chart)
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
|
||||
// 3. Verify last modified date appears (rendered with "Modified" prefix)
|
||||
const modifiedText = `Modified ${testChart.changed_on_delta_humanized}`;
|
||||
expect(screen.getByText(modifiedText)).toBeInTheDocument();
|
||||
|
||||
// 4. Verify action menu exists (more button for each card)
|
||||
const moreButtons = screen.getAllByLabelText('more');
|
||||
expect(moreButtons).toHaveLength(mockCharts.length);
|
||||
|
||||
// 5. Verify menu items appear on click
|
||||
fireEvent.click(moreButtons[0]);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit')).toBeInTheDocument();
|
||||
expect(screen.getByText('Export')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('export chart api called when export button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the more actions button on the first card
|
||||
const moreButtons = screen.getAllByLabelText('more');
|
||||
fireEvent.click(moreButtons[0]);
|
||||
|
||||
// Wait for dropdown menu and click export
|
||||
const exportOption = await screen.findByText('Export');
|
||||
fireEvent.click(exportOption);
|
||||
|
||||
// Verify export was called with correct chart ID
|
||||
expect(mockHandleResourceExport).toHaveBeenCalledWith(
|
||||
'chart',
|
||||
[mockCharts[0].id],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('opens edit properties modal when edit button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the more actions button on the first card
|
||||
const moreButtons = screen.getAllByLabelText('more');
|
||||
fireEvent.click(moreButtons[0]);
|
||||
|
||||
// Wait for dropdown menu and click edit
|
||||
const editOption = await screen.findByText('Edit');
|
||||
fireEvent.click(editOption);
|
||||
|
||||
// Verify edit modal appears (look for edit form elements)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Edit Chart Properties')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('opens delete confirmation when delete button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find and click the more actions button on the first card
|
||||
const moreButtons = screen.getAllByLabelText('more');
|
||||
fireEvent.click(moreButtons[0]);
|
||||
|
||||
// Wait for dropdown menu and click delete
|
||||
const deleteOption = await screen.findByText('Delete');
|
||||
fireEvent.click(deleteOption);
|
||||
|
||||
// Verify delete confirmation modal appears
|
||||
await waitFor(() => {
|
||||
const deleteModal = screen.getByRole('dialog');
|
||||
expect(deleteModal).toBeInTheDocument();
|
||||
expect(deleteModal).toHaveTextContent(/delete/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays certified badge only for certified charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test certified charts (mockCharts[1] and mockCharts[3] have certified_by)
|
||||
const certifiedBadges = screen.getAllByLabelText('certified');
|
||||
|
||||
// Should have exactly 2 certified badges (for charts 1 and 3)
|
||||
expect(certifiedBadges).toHaveLength(2);
|
||||
|
||||
// Verify specific certified charts show badges
|
||||
// mockCharts[1] is certified by 'Data Team'
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
|
||||
// mockCharts[3] is certified by 'QA Team'
|
||||
expect(screen.getByText(mockCharts[3].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can bulk deselect all charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// In card view, click on individual cards to select them (not checkboxes)
|
||||
// Find the first chart name and click on it to select the card
|
||||
const firstChartName = screen.getByText(mockCharts[0].slice_name);
|
||||
fireEvent.click(firstChartName);
|
||||
|
||||
// Verify first chart is selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'1 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
// Click on second chart to add to selection
|
||||
const secondChartName = screen.getByText(mockCharts[1].slice_name);
|
||||
fireEvent.click(secondChartName);
|
||||
|
||||
// Verify both charts are selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'2 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
// Click deselect all
|
||||
const deselectAllButton = screen.getByTestId('bulk-select-deselect-all');
|
||||
fireEvent.click(deselectAllButton);
|
||||
|
||||
// Verify all charts are deselected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'0 Selected',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('can bulk export selected charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select charts by clicking on each card (no "Select all" in card view)
|
||||
for (let i = 0; i < mockCharts.length; i += 1) {
|
||||
const chartName = screen.getByText(mockCharts[i].slice_name);
|
||||
fireEvent.click(chartName);
|
||||
}
|
||||
|
||||
// Wait for all charts to be selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Click bulk export button (find by text since there are multiple bulk-select-action buttons)
|
||||
const bulkExportButton = screen.getByText('Export');
|
||||
fireEvent.click(bulkExportButton);
|
||||
|
||||
// Verify export was called with all chart IDs
|
||||
expect(mockHandleResourceExport).toHaveBeenCalledWith(
|
||||
'chart',
|
||||
mockCharts.map(chart => chart.id),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('can bulk delete selected charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select charts by clicking on each card (no "Select all" in card view)
|
||||
for (let i = 0; i < mockCharts.length; i += 1) {
|
||||
const chartName = screen.getByText(mockCharts[i].slice_name);
|
||||
fireEvent.click(chartName);
|
||||
}
|
||||
|
||||
// Wait for all charts to be selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Click bulk delete button (find by text since there are multiple bulk-select-action buttons)
|
||||
const bulkDeleteButton = screen.getByText('Delete');
|
||||
fireEvent.click(bulkDeleteButton);
|
||||
|
||||
// Verify delete confirmation appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Please confirm')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can bulk add tags to selected charts', async () => {
|
||||
// Enable tagging system for this test
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) =>
|
||||
feature === 'LISTVIEWS_DEFAULT_CARD_VIEW' ||
|
||||
feature === 'TAGGING_SYSTEM',
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select charts by clicking on each card (no "Select all" in card view)
|
||||
for (let i = 0; i < mockCharts.length; i += 1) {
|
||||
const chartName = screen.getByText(mockCharts[i].slice_name);
|
||||
fireEvent.click(chartName);
|
||||
}
|
||||
|
||||
// Wait for all charts to be selected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Since TAGGING_SYSTEM is enabled, the tag button should be present
|
||||
const bulkTagButton = screen.getByTestId('bulk-select-tag-btn');
|
||||
expect(bulkTagButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(bulkTagButton);
|
||||
|
||||
// Verify tag modal appears
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Add Tag')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('exit bulk select by hitting x on bulk select bar', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the X button to close bulk select (look for close icon in bulk select bar)
|
||||
const closeButton = document.querySelector(
|
||||
'.ant-alert-close-icon',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
// Verify bulk select controls are gone
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-controls'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('exit bulk select by clicking bulk select button again', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click bulk select button again to exit
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Verify bulk select controls are gone
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-controls'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('card click behavior changes in bulk select mode', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for cards to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// In normal mode, clicking card should navigate (but we can't test navigation in this setup)
|
||||
// Instead, verify bulk select is not active initially
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-controls'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Enable bulk select mode
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
// Wait for bulk select controls
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-controls')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Now clicking on cards should select them instead of navigating
|
||||
const firstChartName = screen.getByText(mockCharts[0].slice_name);
|
||||
fireEvent.click(firstChartName);
|
||||
|
||||
// Verify chart was selected (not navigated)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'1 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
// Clicking the same card again should deselect it
|
||||
fireEvent.click(firstChartName);
|
||||
|
||||
// Verify chart was deselected
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'0 Selected',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders sort dropdown in card view', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Wait for the component to switch to card view (due to feature flag)
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify basic card view elements are present
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
|
||||
// Find Sort dropdown using its data-test attribute (CardSortSelect component)
|
||||
const sortFilter = screen.getByTestId('card-sort-select');
|
||||
|
||||
expect(sortFilter).toBeInTheDocument();
|
||||
expect(sortFilter).toBeVisible();
|
||||
expect(sortFilter).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -1,883 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import {
|
||||
screen,
|
||||
waitFor,
|
||||
fireEvent,
|
||||
within,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
mockCharts,
|
||||
mockHandleResourceExport,
|
||||
setupMocks,
|
||||
renderChartList,
|
||||
} from './ChartList.testHelpers';
|
||||
|
||||
// Increase default timeout for all tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
// Mock the feature flag
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the export utility
|
||||
jest.mock('src/utils/export', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||
typeof isFeatureEnabled
|
||||
>;
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
['can_export', 'Chart'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
describe('ChartList - List View Tests', () => {
|
||||
beforeEach(() => {
|
||||
mockHandleResourceExport.mockClear();
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
it('renders ChartList in list view', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Wait for component to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for table to be rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify cards are not rendered in list view
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('styled-card')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('switches from list view to card view', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Switch to card view
|
||||
const cardViewToggle = screen.getByRole('img', { name: 'appstore' });
|
||||
fireEvent.click(cardViewToggle);
|
||||
|
||||
// Verify table is no longer rendered
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('listview-table')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify cards are rendered
|
||||
const cards = screen.getAllByTestId('styled-card');
|
||||
expect(cards).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('renders all required column headers', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const columnHeaders = table.querySelectorAll('[role="columnheader"]');
|
||||
|
||||
// All the table headers with default feature flags on
|
||||
const expectedHeaders = [
|
||||
'Name',
|
||||
'Type',
|
||||
'Dataset',
|
||||
'On dashboards',
|
||||
'Owners',
|
||||
'Last modified',
|
||||
'Actions',
|
||||
];
|
||||
|
||||
// Add one extra column header for favorite stars
|
||||
expect(columnHeaders).toHaveLength(expectedHeaders.length + 1);
|
||||
|
||||
// Verify all expected headers are present
|
||||
expectedHeaders.forEach(headerText => {
|
||||
expect(within(table).getByText(headerText)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('sorts table when clicking column headers', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const sortableHeaders = table.querySelectorAll('.ant-table-column-sorters');
|
||||
|
||||
expect(sortableHeaders).toHaveLength(3);
|
||||
|
||||
const nameHeader = within(table).getByText('Name');
|
||||
fireEvent.click(nameHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const sortCalls = fetchMock
|
||||
.calls(/chart\/\?q/)
|
||||
.filter(
|
||||
call =>
|
||||
call[0].includes('order_column') && call[0].includes('slice_name'),
|
||||
);
|
||||
expect(sortCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const typeHeader = within(table).getByText('Type');
|
||||
fireEvent.click(typeHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const typeSortCalls = fetchMock
|
||||
.calls(/chart\/\?q/)
|
||||
.filter(
|
||||
call =>
|
||||
call[0].includes('order_column') && call[0].includes('viz_type'),
|
||||
);
|
||||
expect(typeSortCalls).toHaveLength(1);
|
||||
});
|
||||
|
||||
const lastModifiedHeader = within(table).getByText('Last modified');
|
||||
fireEvent.click(lastModifiedHeader);
|
||||
|
||||
await waitFor(() => {
|
||||
const lastModifiedSortCalls = fetchMock
|
||||
.calls(/chart\/\?q/)
|
||||
.filter(
|
||||
call =>
|
||||
call[0].includes('order_column') &&
|
||||
call[0].includes('last_saved_at'),
|
||||
);
|
||||
expect(lastModifiedSortCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays chart data correctly', async () => {
|
||||
/**
|
||||
* @todo Implement test logic for tagging.
|
||||
* If TAGGING_SYSTEM is ever deprecated to always be on,
|
||||
* will need to combine this with the tagging column test.
|
||||
*/
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const testChart = mockCharts[0];
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(table).getByText(testChart.slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the specific row for our test chart
|
||||
const chartNameElement = within(table).getByText(testChart.slice_name);
|
||||
const chartRow = chartNameElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
expect(chartRow).toBeInTheDocument();
|
||||
|
||||
// Check for favorite star column within the specific row
|
||||
const favoriteButton = within(chartRow).getByTestId('fave-unfave-icon');
|
||||
expect(favoriteButton).toBeInTheDocument();
|
||||
expect(favoriteButton).toHaveAttribute('role', 'button');
|
||||
|
||||
// Check chart name link within the specific row
|
||||
const chartLink = within(chartRow).getByTestId(
|
||||
`${testChart.slice_name}-list-chart-title`,
|
||||
);
|
||||
expect(chartLink).toBeInTheDocument();
|
||||
expect(chartLink).toHaveAttribute('href', testChart.url);
|
||||
|
||||
// Check viz type within the specific row
|
||||
expect(within(chartRow).getByText(testChart.viz_type)).toBeInTheDocument();
|
||||
|
||||
// Check dataset name and link within the specific row
|
||||
const datasetName = testChart.datasource_name_text?.split('.').pop() || '';
|
||||
expect(within(chartRow).getByText(datasetName)).toBeInTheDocument();
|
||||
|
||||
const datasetLink = within(chartRow).getByTestId('internal-link');
|
||||
expect(datasetLink).toBeInTheDocument();
|
||||
expect(datasetLink).toHaveAttribute('href', testChart.datasource_url);
|
||||
|
||||
// Check dashboard display within the specific row
|
||||
expect(
|
||||
within(chartRow).getByText(testChart.dashboards[0].dashboard_title),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check owners display - find avatar group within the row
|
||||
const avatarGroup = chartRow.querySelector(
|
||||
'.ant-avatar-group',
|
||||
) as HTMLElement;
|
||||
expect(avatarGroup).toBeInTheDocument();
|
||||
|
||||
// Test owner initials for mockCharts[0] (we know it has owners)
|
||||
const ownerInitials = `${testChart.owners[0].first_name[0]}${testChart.owners[0].last_name[0]}`;
|
||||
expect(within(avatarGroup).getByText(ownerInitials)).toBeInTheDocument();
|
||||
|
||||
// Check last modified time within the specific row
|
||||
expect(
|
||||
within(chartRow).getByText(testChart.changed_on_delta_humanized),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Check actions column within the specific row
|
||||
const actionsContainer = chartRow.querySelector('.actions');
|
||||
expect(actionsContainer).toBeInTheDocument();
|
||||
|
||||
// Verify action buttons exist within the specific row
|
||||
expect(within(chartRow).getByTestId('delete')).toBeInTheDocument();
|
||||
expect(within(chartRow).getByTestId('upload')).toBeInTheDocument();
|
||||
expect(within(chartRow).getByTestId('edit-alt')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('export chart api called when export button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click first export button
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const exportButtons = within(table).getAllByTestId('upload');
|
||||
fireEvent.click(exportButtons[0]);
|
||||
|
||||
// Verify export functionality is triggered - check if handleResourceExport was called
|
||||
await waitFor(() => {
|
||||
expect(mockHandleResourceExport).toHaveBeenCalledWith(
|
||||
'chart',
|
||||
[mockCharts[0].id],
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens edit properties modal when edit button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const editButtons = within(table).getAllByTestId('edit-alt');
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
// Verify edit modal opens
|
||||
await waitFor(() => {
|
||||
const editModal = screen.getByRole('dialog');
|
||||
expect(editModal).toBeInTheDocument();
|
||||
expect(editModal).toHaveTextContent(/properties/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('opens delete confirmation when delete button is clicked', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const deleteButtons = within(table).getAllByTestId('delete');
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
// Verify delete confirmation modal opens
|
||||
await waitFor(() => {
|
||||
const deleteModal = screen.getByRole('dialog');
|
||||
expect(deleteModal).toBeInTheDocument();
|
||||
expect(deleteModal).toHaveTextContent(/delete/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('displays certified badge only for certified charts', async () => {
|
||||
// Test certified chart (mockCharts[1] has certification)
|
||||
const certifiedChart = mockCharts[1];
|
||||
// Test uncertified chart (mockCharts[0] has no certification)
|
||||
const uncertifiedChart = mockCharts[0];
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
|
||||
const certifiedChartElement = within(table).getByText(
|
||||
certifiedChart.slice_name,
|
||||
);
|
||||
const certifiedChartRow = certifiedChartElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
const certifiedBadge =
|
||||
within(certifiedChartRow).getByLabelText('certified');
|
||||
expect(certifiedBadge).toBeInTheDocument();
|
||||
|
||||
const uncertifiedChartElement = within(table).getByText(
|
||||
uncertifiedChart.slice_name,
|
||||
);
|
||||
const uncertifiedChartRow = uncertifiedChartElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(uncertifiedChartRow).queryByLabelText('certified'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays info icon only for charts with descriptions', async () => {
|
||||
// Test chart with description (mockCharts[0] has description)
|
||||
const chartWithDesc = mockCharts[0];
|
||||
// Test chart without description (mockCharts[2] has description: null)
|
||||
const chartNoDesc = mockCharts[2];
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[2].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
|
||||
const chartWithDescElement = within(table).getByText(
|
||||
chartWithDesc.slice_name,
|
||||
);
|
||||
const chartWithDescRow = chartWithDescElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
const infoTooltip =
|
||||
within(chartWithDescRow).getByLabelText('Show info tooltip');
|
||||
expect(infoTooltip).toBeInTheDocument();
|
||||
|
||||
const chartNoDescElement = within(table).getByText(chartNoDesc.slice_name);
|
||||
const chartNoDescRow = chartNoDescElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
expect(
|
||||
within(chartNoDescRow).queryByLabelText('Show info tooltip'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays chart with empty dataset column', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[2].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const chartNameElement = within(table).getByText(mockCharts[2].slice_name);
|
||||
const chartRow = chartNameElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
|
||||
// Chart name should be visible
|
||||
expect(
|
||||
within(chartRow).getByText(mockCharts[2].slice_name),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Find dataset column index by header
|
||||
const headers = within(table).getAllByRole('columnheader');
|
||||
const datasetHeaderIndex = headers.findIndex(header =>
|
||||
header.textContent?.includes('Dataset'),
|
||||
);
|
||||
expect(datasetHeaderIndex).toBeGreaterThan(-1); // Ensure column exists
|
||||
|
||||
// Since mockCharts[2] has datasource_name_text: null, verify dataset cell is empty
|
||||
const datasetCell =
|
||||
within(chartRow).getAllByRole('cell')[datasetHeaderIndex];
|
||||
expect(datasetCell).toBeInTheDocument();
|
||||
|
||||
// Verify dataset cell is empty for charts with no dataset
|
||||
expect(datasetCell).toHaveTextContent('');
|
||||
// There's a link element but with empty href
|
||||
const datasetLink = within(datasetCell).getByRole('link');
|
||||
expect(datasetLink).toHaveAttribute('href', '');
|
||||
});
|
||||
|
||||
it('displays chart with empty on dashboards column', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[2].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test mockCharts[2] which has dashboards: []
|
||||
const table = screen.getByTestId('listview-table');
|
||||
const chartNameElement = within(table).getByText(mockCharts[2].slice_name);
|
||||
const chartRow = chartNameElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
|
||||
// Chart should still render - chart name should be visible
|
||||
expect(
|
||||
within(chartRow).getByText(mockCharts[2].slice_name),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Find dashboard column index by header
|
||||
const headers = within(table).getAllByRole('columnheader');
|
||||
const dashboardHeaderIndex = headers.findIndex(header =>
|
||||
header.textContent?.includes('On dashboards'),
|
||||
);
|
||||
expect(dashboardHeaderIndex).toBeGreaterThan(-1); // Ensure column exists
|
||||
|
||||
// Since mockCharts[2] has dashboards: [], verify dashboard cell is empty
|
||||
const dashboardCell =
|
||||
within(chartRow).getAllByRole('cell')[dashboardHeaderIndex];
|
||||
expect(dashboardCell).toBeInTheDocument();
|
||||
|
||||
// Verify no dashboard links are present in this cell
|
||||
expect(within(dashboardCell).queryByRole('link')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows tag info when TAGGING_SYSTEM is enabled', async () => {
|
||||
// Enable tagging system feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'TAGGING_SYSTEM',
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const testChart = mockCharts[0];
|
||||
const table = screen.getByTestId('listview-table');
|
||||
expect(within(table).getByText('Tags')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(within(table).getByText(testChart.slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const chartNameElement = within(table).getByText(testChart.slice_name);
|
||||
const chartRow = chartNameElement.closest(
|
||||
'[data-test="table-row"]',
|
||||
) as HTMLElement;
|
||||
expect(chartRow).toBeInTheDocument();
|
||||
|
||||
const tagList = chartRow.querySelector('.tag-list') as HTMLElement;
|
||||
expect(tagList).toBeInTheDocument();
|
||||
|
||||
// Find the tag in the row
|
||||
const tag = within(tagList).getByTestId('tag');
|
||||
expect(tag).toBeInTheDocument();
|
||||
expect(tag).toHaveTextContent('basic');
|
||||
|
||||
// Tag should be a link to all_entities page
|
||||
const tagLink = within(tag).getByRole('link');
|
||||
expect(tagLink).toHaveAttribute('href', '/superset/all_entities/?id=1');
|
||||
expect(tagLink).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
it('can bulk select and deselect all charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
// Use the header checkbox to select all
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all');
|
||||
expect(selectAllCheckbox).not.toBeChecked();
|
||||
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
// All checkboxes should be checked
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
// Should show all charts selected
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Use the deselect all link to deselect all
|
||||
const deselectAllButton = screen.getByTestId('bulk-select-deselect-all');
|
||||
fireEvent.click(deselectAllButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// All checkboxes should be unchecked
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
// Should show 0 selected
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'0 Selected',
|
||||
);
|
||||
|
||||
// Bulk action buttons should disappear
|
||||
expect(
|
||||
screen.queryByTestId('bulk-select-action'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('can bulk export selected charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
// Use select all to select multiple charts
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all');
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Click bulk export button
|
||||
const bulkActions = screen.getAllByTestId('bulk-select-action');
|
||||
const exportButton = bulkActions.find(btn => btn.textContent === 'Export');
|
||||
expect(exportButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(exportButton!);
|
||||
|
||||
// Verify export function was called with all chart IDs
|
||||
await waitFor(() => {
|
||||
expect(mockHandleResourceExport).toHaveBeenCalledWith(
|
||||
'chart',
|
||||
mockCharts.map(chart => chart.id),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('can bulk delete selected charts', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
// Use select all to select multiple charts
|
||||
const selectAllCheckbox = screen.getByLabelText('Select all');
|
||||
fireEvent.click(selectAllCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
`${mockCharts.length} Selected`,
|
||||
);
|
||||
});
|
||||
|
||||
// Click bulk delete button
|
||||
const bulkActions = screen.getAllByTestId('bulk-select-action');
|
||||
const deleteButton = bulkActions.find(btn => btn.textContent === 'Delete');
|
||||
expect(deleteButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(deleteButton!);
|
||||
|
||||
// Should open delete confirmation modal
|
||||
await waitFor(() => {
|
||||
const deleteModal = screen.getByRole('dialog');
|
||||
expect(deleteModal).toBeInTheDocument();
|
||||
expect(deleteModal).toHaveTextContent(/delete/i);
|
||||
expect(deleteModal).toHaveTextContent(/selected charts/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('can bulk add tags to selected charts', async () => {
|
||||
// Enable tagging system feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'TAGGING_SYSTEM',
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for chart data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Activate bulk select and select charts
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
// Select first chart
|
||||
const table = screen.getByTestId('listview-table');
|
||||
// Target first data row specifically (not header row)
|
||||
const dataRows = within(table).getAllByTestId('table-row');
|
||||
const firstRowCheckbox = within(dataRows[0]).getByRole('checkbox');
|
||||
fireEvent.click(firstRowCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'1 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
const addTagButton = screen.queryByText('Add Tag') as HTMLButtonElement;
|
||||
expect(addTagButton).toBeInTheDocument();
|
||||
fireEvent.click(addTagButton);
|
||||
|
||||
await waitFor(() => {
|
||||
const tagModal = screen.getByRole('dialog');
|
||||
expect(tagModal).toBeInTheDocument();
|
||||
expect(tagModal).toHaveTextContent(/tag/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('exit bulk select by hitting x on bulk select bar', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
// Target first data row specifically (not header row)
|
||||
const dataRows = within(table).getAllByTestId('table-row');
|
||||
const firstRowCheckbox = within(dataRows[0]).getByRole('checkbox');
|
||||
fireEvent.click(firstRowCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'1 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
// Find and click the close button (x) on the bulk select bar
|
||||
const closeIcon = document.querySelector(
|
||||
'.ant-alert-close-icon',
|
||||
) as HTMLButtonElement;
|
||||
fireEvent.click(closeIcon);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
expect(screen.queryByTestId('bulk-select-copy')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('exit bulk select by clicking bulk select button again', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockCharts[1].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const bulkSelectButton = screen.getByTestId('bulk-select');
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// Expect header checkbox + one checkbox per chart
|
||||
expect(screen.getAllByRole('checkbox')).toHaveLength(
|
||||
mockCharts.length + 1,
|
||||
);
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
// Target first data row specifically (not header row)
|
||||
const dataRows = within(table).getAllByTestId('table-row');
|
||||
const firstRowCheckbox = within(dataRows[0]).getByRole('checkbox');
|
||||
fireEvent.click(firstRowCheckbox);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('bulk-select-copy')).toHaveTextContent(
|
||||
'1 Selected',
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(bulkSelectButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
|
||||
expect(screen.queryByTestId('bulk-select-copy')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('displays dataset name without schema prefix correctly', async () => {
|
||||
// Test just name case - should display the full name when no schema prefix
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const table = screen.getByTestId('listview-table');
|
||||
|
||||
// Wait for chart with simple dataset name to load
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(table).getByText(mockCharts[1].slice_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test mockCharts[1] which has 'sales_data' (no schema prefix)
|
||||
const chart1Row = within(table)
|
||||
.getByText(mockCharts[1].slice_name)
|
||||
.closest('[data-test="table-row"]') as HTMLElement;
|
||||
const chart1DatasetLink = within(chart1Row).getByTestId('internal-link');
|
||||
|
||||
// Should display the full name when there's no schema prefix
|
||||
expect(chart1DatasetLink).toHaveTextContent('sales_data');
|
||||
expect(chart1DatasetLink).toHaveAttribute(
|
||||
'href',
|
||||
mockCharts[1].datasource_url,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,486 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render, screen, waitFor } from 'spec/helpers/testing-library';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import ChartList from 'src/pages/ChartList';
|
||||
import { API_ENDPOINTS, mockCharts, setupMocks } from './ChartList.testHelpers';
|
||||
|
||||
// Increase default timeout for all tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Permission configurations
|
||||
const PERMISSIONS = {
|
||||
ADMIN: [
|
||||
['can_write', 'Chart'],
|
||||
['can_export', 'Chart'],
|
||||
['can_read', 'Tag'],
|
||||
],
|
||||
READ_ONLY: [], // No permissions - should hide most UI elements
|
||||
EXPORT_ONLY: [['can_export', 'Chart']], // Only export permission
|
||||
WRITE_ONLY: [['can_write', 'Chart']], // Only write permission (covers edit/delete)
|
||||
MIXED: [
|
||||
['can_export', 'Chart'],
|
||||
['can_read', 'Tag'],
|
||||
],
|
||||
NONE: [],
|
||||
};
|
||||
|
||||
const createMockUser = (overrides = {}) => ({
|
||||
userId: 1,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockStore = (initialState: any = {}) =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
user: (state = initialState.user || {}, action: any) => state,
|
||||
common: (state = initialState.common || {}, action: any) => state,
|
||||
charts: (state = initialState.charts || {}, action: any) => state,
|
||||
},
|
||||
preloadedState: initialState,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
immutableCheck: false,
|
||||
}),
|
||||
});
|
||||
|
||||
const createStoreStateWithPermissions = (
|
||||
permissions = PERMISSIONS.ADMIN,
|
||||
userId: number | undefined = 1,
|
||||
) => ({
|
||||
user: userId
|
||||
? {
|
||||
...createMockUser({ userId }),
|
||||
roles: { TestRole: permissions },
|
||||
}
|
||||
: {},
|
||||
common: {
|
||||
conf: {
|
||||
SUPERSET_WEBSERVER_TIMEOUT: 60000,
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
chartList: mockCharts,
|
||||
},
|
||||
});
|
||||
|
||||
const renderChartList = (
|
||||
props = {},
|
||||
storeState = {},
|
||||
user = createMockUser(),
|
||||
) => {
|
||||
const storeStateWithUser = {
|
||||
...createStoreStateWithPermissions(),
|
||||
user,
|
||||
...storeState,
|
||||
};
|
||||
|
||||
const store = createMockStore(storeStateWithUser);
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<ChartList user={user} {...props} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
// Setup API permissions mock
|
||||
const setupApiPermissions = (permissions: string[]) => {
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS_INFO,
|
||||
{
|
||||
permissions,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
};
|
||||
|
||||
// Render with permissions and wait for load
|
||||
const renderWithPermissions = async (
|
||||
permissions = PERMISSIONS.ADMIN,
|
||||
userId: number | undefined = 1,
|
||||
featureFlags: { tagging?: boolean; cardView?: boolean } = {},
|
||||
) => {
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation((feature: string) => {
|
||||
if (feature === 'TAGGING_SYSTEM') return featureFlags.tagging === true;
|
||||
if (feature === 'LISTVIEWS_DEFAULT_CARD_VIEW')
|
||||
return featureFlags.cardView === true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Convert role permissions to API permissions
|
||||
const apiPermissions = permissions.map(perm => perm[0]);
|
||||
setupApiPermissions(apiPermissions);
|
||||
|
||||
const storeState = createStoreStateWithPermissions(permissions, userId);
|
||||
|
||||
// Pass appropriate user prop based on userId
|
||||
const userProps = userId
|
||||
? {
|
||||
user: {
|
||||
...createMockUser({ userId }),
|
||||
roles: { TestRole: permissions },
|
||||
},
|
||||
}
|
||||
: { user: { userId: undefined } }; // Explicitly set userId to undefined for logged-out state
|
||||
|
||||
const result = renderChartList(userProps, storeState);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
});
|
||||
return result;
|
||||
};
|
||||
|
||||
describe('ChartList - Permission-based UI Tests', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
fetchMock.restore();
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
});
|
||||
|
||||
it('shows all UI elements for admin users with full permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN);
|
||||
|
||||
// Wait for component to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify all admin controls are visible
|
||||
expect(screen.getByRole('button', { name: /chart/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('import-button')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('bulk-select')).toBeInTheDocument();
|
||||
|
||||
// Verify Actions column is visible
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
|
||||
// Verify favorite stars are rendered for each chart
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('renders basic UI for anonymous users without permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.NONE, undefined);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify basic structure renders
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charts')).toBeInTheDocument();
|
||||
|
||||
// Verify view toggles are available (not permission-gated)
|
||||
expect(screen.getByRole('img', { name: 'appstore' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'unordered-list' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Verify permission-gated elements are hidden
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /chart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Actions column for users with admin permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
|
||||
// Wait for table to load with charts data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check for action buttons using test-ids (delete, upload, edit-alt)
|
||||
const deleteButtons = screen.getAllByTestId('delete');
|
||||
expect(deleteButtons).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('hides Actions column for users with read-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.READ_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByLabelText('more')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('hides Actions column for users with export-only permissions', async () => {
|
||||
// Known issue: Actions column requires can_write permission
|
||||
await renderWithPermissions(PERMISSIONS.EXPORT_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
expect(screen.queryAllByLabelText('more')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows Actions column for users with write-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.WRITE_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
|
||||
// Wait for table to load with charts data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Check for action buttons using test-ids (delete, upload, edit-alt)
|
||||
const deleteButtons = screen.getAllByTestId('delete');
|
||||
expect(deleteButtons).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('shows favorite stars for logged-in users', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN, 1);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('shows favorite stars even for users without userId', async () => {
|
||||
// Current behavior: Component renders favorites regardless of userId
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN, undefined);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
});
|
||||
|
||||
it('shows Tags column when TAGGING_SYSTEM feature flag is enabled', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN, 1, { tagging: true });
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Tags column when TAGGING_SYSTEM feature flag is disabled', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN, 1, { tagging: false });
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Tags column based on feature flag regardless of user permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.READ_ONLY, 1, { tagging: true });
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bulk select button for users with admin permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByTestId('bulk-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bulk select button for users with export-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.EXPORT_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByTestId('bulk-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows bulk select button for users with write-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.WRITE_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByTestId('bulk-select')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides bulk select button for users with read-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.READ_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.queryByTestId('bulk-select')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Create and Import buttons for users with write permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.WRITE_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByRole('button', { name: /chart/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('import-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Create and Import buttons for users with admin permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(screen.getByRole('button', { name: /chart/i })).toBeInTheDocument();
|
||||
expect(screen.getByTestId('import-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Create and Import buttons for users with read-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.READ_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /chart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides Create and Import buttons for users with export-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.EXPORT_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /chart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows individual action buttons when user has admin permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.ADMIN);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Actions column should be visible
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
|
||||
// Wait for table to load with charts data
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Action dropdown buttons should exist - try different selectors
|
||||
const actionButtons =
|
||||
screen.queryAllByRole('button', { name: /actions/i }) ||
|
||||
screen.queryAllByLabelText(/more/i) ||
|
||||
screen.queryAllByLabelText(/actions/i);
|
||||
|
||||
// If we still can't find the action buttons, that's okay for now
|
||||
// The important thing is that the Actions column is visible
|
||||
expect(actionButtons.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('hides individual action buttons when user has read-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.READ_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Actions column should not be visible
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
|
||||
// No action buttons should exist
|
||||
const actionButtons = screen.queryAllByLabelText(/more/i);
|
||||
expect(actionButtons).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('shows individual action buttons when user has write-only permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.WRITE_ONLY);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Actions column should be visible (requires can_write)
|
||||
expect(screen.getByText('Actions')).toBeInTheDocument();
|
||||
|
||||
// Wait for table to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Action buttons should exist - verify the column is there even if we can't find the exact buttons
|
||||
// The important verification is that Actions column is visible for write permissions
|
||||
});
|
||||
|
||||
it('shows correct UI elements for users with mixed permissions (export + tag read)', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.MIXED, 1, { tagging: true });
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Actions column should be hidden (requires can_write, not can_export)
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
|
||||
// Favorites should be visible (user has userId)
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
|
||||
// Tags column should be visible (feature flag enabled)
|
||||
expect(screen.getByText('Tags')).toBeInTheDocument();
|
||||
|
||||
// Bulk select should be visible (user has can_export)
|
||||
expect(screen.getByTestId('bulk-select')).toBeInTheDocument();
|
||||
|
||||
// Export buttons not visible because Actions column is hidden
|
||||
expect(screen.queryAllByLabelText(/export/i)).toHaveLength(0);
|
||||
|
||||
// Create and Import should be hidden (no can_write)
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /chart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows minimal UI for users with no permissions', async () => {
|
||||
await renderWithPermissions(PERMISSIONS.NONE, undefined);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// All permission-based elements should be hidden
|
||||
expect(screen.queryByText('Actions')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Tags')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('bulk-select')).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /chart/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('import-button')).not.toBeInTheDocument();
|
||||
|
||||
// Favorites still render (component behavior)
|
||||
const favoriteStars = screen.getAllByTestId('fave-unfave-icon');
|
||||
expect(favoriteStars).toHaveLength(mockCharts.length);
|
||||
|
||||
// Basic table structure should still be visible
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /name/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /type/i }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('columnheader', { name: /dataset/i }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
433
superset-frontend/src/pages/ChartList/ChartList.test.jsx
Normal file
433
superset-frontend/src/pages/ChartList/ChartList.test.jsx
Normal file
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* 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 thunk from 'redux-thunk';
|
||||
import configureStore from 'redux-mock-store';
|
||||
import * as reactRedux from 'react-redux';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { VizType, isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
|
||||
import ChartList from 'src/pages/ChartList';
|
||||
|
||||
// Increase default timeout for all tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockCharts = [...new Array(3)].map((_, i) => ({
|
||||
changed_on: new Date().toISOString(),
|
||||
creator: 'super user',
|
||||
id: i,
|
||||
slice_name: `cool chart ${i}`,
|
||||
url: 'url',
|
||||
viz_type: VizType.Bar,
|
||||
datasource_name: `ds${i}`,
|
||||
datasource_name_text: `schema.ds${i}`,
|
||||
datasource_url: `/dataset/${i}`,
|
||||
thumbnail_url: '/thumbnail',
|
||||
}));
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
};
|
||||
|
||||
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
|
||||
const chartsOwnersEndpoint = 'glob:*/api/v1/chart/related/owners*';
|
||||
const chartsCreatedByEndpoint = 'glob:*/api/v1/chart/related/created_by*';
|
||||
const chartsEndpoint = 'glob:*/api/v1/chart/*';
|
||||
const chartsVizTypesEndpoint = 'glob:*/api/v1/chart/viz_types';
|
||||
const chartsDatasourcesEndpoint = 'glob:*/api/v1/chart/datasources';
|
||||
const chartFavoriteStatusEndpoint = 'glob:*/api/v1/chart/favorite_status*';
|
||||
const datasetEndpoint = 'glob:*/api/v1/dataset/*';
|
||||
|
||||
fetchMock.get(chartsInfoEndpoint, {
|
||||
permissions: ['can_read', 'can_write'],
|
||||
});
|
||||
fetchMock.get(chartsOwnersEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
fetchMock.get(chartsCreatedByEndpoint, {
|
||||
result: [],
|
||||
});
|
||||
fetchMock.get(chartFavoriteStatusEndpoint, {
|
||||
result: mockCharts.map(chart => ({ id: chart.id, value: true })),
|
||||
});
|
||||
fetchMock.get(chartsEndpoint, {
|
||||
result: mockCharts,
|
||||
chart_count: 3,
|
||||
});
|
||||
fetchMock.get(chartsVizTypesEndpoint, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
fetchMock.get(chartsDatasourcesEndpoint, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
fetchMock.get(datasetEndpoint, {});
|
||||
|
||||
global.URL.createObjectURL = jest.fn();
|
||||
fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
|
||||
|
||||
const user = {
|
||||
createdOn: '2021-04-27T18:12:38.952304',
|
||||
email: 'admin',
|
||||
firstName: 'admin',
|
||||
isActive: true,
|
||||
lastName: 'admin',
|
||||
permissions: {},
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
],
|
||||
},
|
||||
userId: 1,
|
||||
username: 'admin',
|
||||
};
|
||||
|
||||
const mockStore = configureStore([thunk]);
|
||||
const store = mockStore({ user });
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
|
||||
const renderChartList = (props = {}) =>
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<ChartList {...props} user={mockUser} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>,
|
||||
{
|
||||
useRedux: true,
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
describe('ChartList', () => {
|
||||
beforeEach(() => {
|
||||
isFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
);
|
||||
fetchMock.resetHistory();
|
||||
useSelectorMock.mockClear();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
isFeatureEnabled.mockRestore();
|
||||
});
|
||||
|
||||
it('renders', async () => {
|
||||
renderChartList();
|
||||
expect(await screen.findByText('Charts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a ListView', async () => {
|
||||
renderChartList();
|
||||
expect(await screen.findByTestId('chart-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fetches info', async () => {
|
||||
renderChartList();
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.calls(/chart\/_info/);
|
||||
expect(calls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches data', async () => {
|
||||
renderChartList();
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.calls(/chart\/\?q/);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0][0]).toContain(
|
||||
'order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('switches between card and table view', async () => {
|
||||
renderChartList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Find and click list view toggle
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active
|
||||
await waitFor(() => {
|
||||
const listViewToggle = screen.getByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
expect(listViewToggle.closest('[role="button"]')).toHaveClass('active');
|
||||
});
|
||||
|
||||
// Find and click card view toggle
|
||||
const cardViewToggle = screen.getByRole('img', {
|
||||
name: 'appstore',
|
||||
});
|
||||
const cardViewButton = cardViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(cardViewButton);
|
||||
|
||||
// Wait for card view to be active
|
||||
await waitFor(() => {
|
||||
const cardViewToggle = screen.getByRole('img', {
|
||||
name: 'appstore',
|
||||
});
|
||||
expect(cardViewToggle.closest('[role="button"]')).toHaveClass('active');
|
||||
});
|
||||
});
|
||||
|
||||
it('shows edit modal', async () => {
|
||||
renderChartList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Switch to list view
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active and data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cool chart 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click edit button
|
||||
const editButtons = await screen.findAllByTestId('edit-alt');
|
||||
fireEvent.click(editButtons[0]);
|
||||
|
||||
// Verify modal appears
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete modal', async () => {
|
||||
renderChartList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Switch to list view
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active and data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cool chart 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click delete button
|
||||
const deleteButtons = await screen.findAllByRole('button', {
|
||||
name: 'delete',
|
||||
});
|
||||
fireEvent.click(deleteButtons[0]);
|
||||
|
||||
// Verify modal appears
|
||||
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows favorite stars for logged in user', async () => {
|
||||
renderChartList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Switch to list view
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active and data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cool chart 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for favorite stars to appear
|
||||
await waitFor(() => {
|
||||
const favoriteStars = screen.getAllByRole('img', {
|
||||
name: 'starred',
|
||||
});
|
||||
expect(favoriteStars.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('renders an "Import Chart" tooltip under import button', async () => {
|
||||
renderChartList();
|
||||
|
||||
const importButton = await screen.findByTestId('import-button');
|
||||
fireEvent.mouseEnter(importButton);
|
||||
|
||||
const importTooltip = await screen.findByRole('tooltip', {
|
||||
name: 'Import charts',
|
||||
});
|
||||
expect(importTooltip).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles dataset name display logic correctly', async () => {
|
||||
// Test different scenarios for datasource_name_text
|
||||
const testCharts = [
|
||||
{
|
||||
...mockCharts[0],
|
||||
id: 100,
|
||||
slice_name: 'Chart with schema.name',
|
||||
datasource_name_text: 'public.users_table',
|
||||
datasource_url: '/dataset/1',
|
||||
},
|
||||
{
|
||||
...mockCharts[1],
|
||||
id: 101,
|
||||
slice_name: 'Chart with just name',
|
||||
datasource_name_text: 'simple_table',
|
||||
datasource_url: '/dataset/2',
|
||||
},
|
||||
{
|
||||
...mockCharts[2],
|
||||
id: 102,
|
||||
slice_name: 'Chart with undefined name',
|
||||
datasource_name_text: undefined,
|
||||
datasource_url: '/dataset/3',
|
||||
},
|
||||
];
|
||||
|
||||
// Override the charts endpoint with test data
|
||||
fetchMock.get(
|
||||
chartsEndpoint,
|
||||
{
|
||||
result: testCharts,
|
||||
chart_count: 3,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList();
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Switch to list view to see the dataset column
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active and data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Chart with schema.name')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Test schema.name case - should display only the table name (after the dot)
|
||||
await waitFor(() => {
|
||||
const schemaNameLink = screen.getByText('users_table');
|
||||
expect(schemaNameLink).toBeInTheDocument();
|
||||
expect(schemaNameLink.closest('a')).toHaveAttribute('href', '/dataset/1');
|
||||
});
|
||||
|
||||
// Test just name case - should display the full name
|
||||
await waitFor(() => {
|
||||
const justNameLink = screen.getByText('simple_table');
|
||||
expect(justNameLink).toBeInTheDocument();
|
||||
expect(justNameLink.closest('a')).toHaveAttribute('href', '/dataset/2');
|
||||
});
|
||||
|
||||
// Test undefined case - should display empty string (no text content)
|
||||
await waitFor(() => {
|
||||
const undefinedNameRow = screen
|
||||
.getByText('Chart with undefined name')
|
||||
.closest('tr');
|
||||
const datasetCell = undefinedNameRow.querySelector('td:nth-child(4)'); // Dataset is the 4th column
|
||||
const linkElement = datasetCell.querySelector('a');
|
||||
expect(linkElement).toHaveTextContent('');
|
||||
expect(linkElement).toHaveAttribute('href', '/dataset/3');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChartList - anonymous view', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
// Reset favorite status for anonymous user
|
||||
fetchMock.get(
|
||||
chartFavoriteStatusEndpoint,
|
||||
{
|
||||
result: [],
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
// Reset charts endpoint to original mockCharts
|
||||
fetchMock.get(
|
||||
chartsEndpoint,
|
||||
{
|
||||
result: mockCharts,
|
||||
chart_count: 3,
|
||||
},
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not show favorite stars for anonymous user', async () => {
|
||||
renderChartList({ user: {} });
|
||||
|
||||
// Wait for list to load
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Switch to list view
|
||||
const listViewToggle = await screen.findByRole('img', {
|
||||
name: 'unordered-list',
|
||||
});
|
||||
const listViewButton = listViewToggle.closest('[role="button"]');
|
||||
fireEvent.click(listViewButton);
|
||||
|
||||
// Wait for list view to be active and data to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('cool chart 0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify no selected favorite stars are present
|
||||
await waitFor(() => {
|
||||
const favoriteStars = screen.queryAllByRole('img', {
|
||||
name: 'favorite-selected',
|
||||
});
|
||||
expect(favoriteStars).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,476 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { screen, waitFor, fireEvent } from 'spec/helpers/testing-library';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import {
|
||||
API_ENDPOINTS,
|
||||
mockCharts,
|
||||
renderChartList,
|
||||
setupMocks,
|
||||
} from './ChartList.testHelpers';
|
||||
|
||||
const mockPush = jest.fn();
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useHistory: () => ({ push: mockPush }),
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
// Increase default timeout for all tests
|
||||
jest.setTimeout(30000);
|
||||
|
||||
const mockUser = {
|
||||
userId: 1,
|
||||
firstName: 'Test',
|
||||
lastName: 'User',
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
['can_export', 'Chart'],
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
// Filter utilities
|
||||
const findFilterByLabel = (labelText: string) => {
|
||||
const containers = screen.getAllByTestId('select-filter-container');
|
||||
for (const container of containers) {
|
||||
const label = container.querySelector('label');
|
||||
if (label?.textContent === labelText) {
|
||||
return container.querySelector('[role="combobox"], .ant-select');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
describe('ChartList', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
fetchMock.restore();
|
||||
// Reset feature flag mock
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
});
|
||||
|
||||
it('renders component with basic structure', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
expect(await screen.findByTestId('chart-list-view')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charts')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verify New Chart button existence and functionality', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify New Chart button exists
|
||||
const newChartButton = screen.getByRole('button', { name: /chart/i });
|
||||
expect(newChartButton).toBeInTheDocument();
|
||||
expect(screen.getByTestId('plus')).toBeInTheDocument();
|
||||
|
||||
// Click the New Chart button
|
||||
fireEvent.click(newChartButton);
|
||||
|
||||
// Verify it triggers navigation to chart creation
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledWith('/chart/add');
|
||||
});
|
||||
});
|
||||
|
||||
it('verify Import button existence and functionality', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Verify Import button exists
|
||||
const importButton = screen.getByTestId('import-button');
|
||||
expect(importButton).toBeInTheDocument();
|
||||
|
||||
// Click the Import button
|
||||
fireEvent.click(importButton);
|
||||
|
||||
// Verify import modal opens
|
||||
await waitFor(() => {
|
||||
const importModal = screen.getByRole('dialog');
|
||||
expect(importModal).toBeInTheDocument();
|
||||
expect(importModal).toHaveTextContent(/import/i);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during initial data fetch', async () => {
|
||||
// Delay the chart data response to test loading state
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: mockCharts, chart_count: 3 }), 200),
|
||||
),
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Component should render immediately with loading state
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
|
||||
// Wait for data to eventually load
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('makes correct API calls on initial load', async () => {
|
||||
renderChartList(mockUser);
|
||||
|
||||
await waitFor(() => {
|
||||
const infoCalls = fetchMock.calls(/chart\/_info/);
|
||||
const dataCalls = fetchMock.calls(/chart\/\?q/);
|
||||
|
||||
expect(infoCalls).toHaveLength(1);
|
||||
expect(dataCalls).toHaveLength(1);
|
||||
expect(dataCalls[0][0]).toContain(
|
||||
'order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state while API calls are in progress', async () => {
|
||||
// Mock delayed API responses
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS_INFO,
|
||||
new Promise(resolve =>
|
||||
setTimeout(
|
||||
() => resolve({ permissions: ['can_read', 'can_write'] }),
|
||||
100,
|
||||
),
|
||||
),
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: mockCharts, chart_count: 3 }), 150),
|
||||
),
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Main container should render immediately
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
|
||||
// Eventually data should load
|
||||
await waitFor(
|
||||
() => {
|
||||
const infoCalls = fetchMock.calls(/chart\/_info/);
|
||||
const dataCalls = fetchMock.calls(/chart\/\?q/);
|
||||
|
||||
expect(infoCalls).toHaveLength(1);
|
||||
expect(dataCalls).toHaveLength(1);
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('maintains component structure during loading', async () => {
|
||||
// Only delay data loading, not permissions
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS,
|
||||
new Promise(resolve =>
|
||||
setTimeout(() => resolve({ result: mockCharts, chart_count: 3 }), 200),
|
||||
),
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
|
||||
// Core structure should be available immediately
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
expect(screen.getByText('Charts')).toBeInTheDocument();
|
||||
|
||||
// View toggles should be available during loading
|
||||
expect(screen.getByRole('img', { name: 'appstore' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'unordered-list' }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Wait for permissions to load, then action buttons should appear
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Bulk select' }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 500 },
|
||||
);
|
||||
|
||||
// Wait for data to eventually load
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText(mockCharts[0].slice_name)).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 1000 },
|
||||
);
|
||||
});
|
||||
|
||||
it('handles API errors gracefully', async () => {
|
||||
// Mock API failure
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS_INFO,
|
||||
{ throws: new Error('API Error') },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Should handle error gracefully and still render component
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles empty results', async () => {
|
||||
// Mock empty chart data (not permissions)
|
||||
fetchMock.get(
|
||||
API_ENDPOINTS.CHARTS,
|
||||
{ result: [], chart_count: 0 },
|
||||
{ overwriteRoutes: true },
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
// Should render component even with no data
|
||||
expect(screen.getByTestId('chart-list-view')).toBeInTheDocument();
|
||||
|
||||
// Global controls should still be functional with no data
|
||||
expect(screen.getByRole('img', { name: 'appstore' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('img', { name: 'unordered-list' }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Bulk select' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChartList - Global Filter Interactions', () => {
|
||||
beforeEach(() => {
|
||||
setupMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.resetHistory();
|
||||
fetchMock.restore();
|
||||
// Reset feature flag mock
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockReset();
|
||||
});
|
||||
|
||||
it('renders search filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify search filter renders correctly
|
||||
expect(screen.getByTestId('filters-search')).toBeInTheDocument();
|
||||
expect(screen.getByPlaceholderText(/type a value/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Type filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const typeFilter = findFilterByLabel('Type');
|
||||
expect(typeFilter).toBeVisible();
|
||||
expect(typeFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Dataset filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const datasetFilter = findFilterByLabel('Dataset');
|
||||
expect(datasetFilter).toBeVisible();
|
||||
expect(datasetFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Owner filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const ownerFilter = findFilterByLabel('Owner');
|
||||
expect(ownerFilter).toBeVisible();
|
||||
expect(ownerFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Certified filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
const certifiedFilter = findFilterByLabel('Certified');
|
||||
expect(certifiedFilter).toBeVisible();
|
||||
expect(certifiedFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Favorite filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const favoriteFilter = findFilterByLabel('Favorite');
|
||||
expect(favoriteFilter).toBeVisible();
|
||||
expect(favoriteFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Dashboard filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const dashboardFilter = findFilterByLabel('Dashboard');
|
||||
expect(dashboardFilter).toBeVisible();
|
||||
expect(dashboardFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Modified by filter correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const modifiedByFilter = findFilterByLabel('Modified by');
|
||||
expect(modifiedByFilter).toBeVisible();
|
||||
expect(modifiedByFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('renders Tags filter when TAGGING_SYSTEM is enabled', async () => {
|
||||
// Mock feature flag to enable tags
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) =>
|
||||
feature === 'TAGGING_SYSTEM' ||
|
||||
feature !== 'LISTVIEWS_DEFAULT_CARD_VIEW',
|
||||
);
|
||||
|
||||
// Render with tag permissions
|
||||
const userWithTagPerms = {
|
||||
...mockUser,
|
||||
roles: {
|
||||
Admin: [
|
||||
['can_sqllab', 'Superset'],
|
||||
['can_write', 'Dashboard'],
|
||||
['can_write', 'Chart'],
|
||||
['can_read', 'Tag'],
|
||||
['can_write', 'Tag'],
|
||||
],
|
||||
},
|
||||
};
|
||||
renderChartList(userWithTagPerms);
|
||||
|
||||
const tagsFilter = findFilterByLabel('Tag');
|
||||
expect(tagsFilter).toBeVisible();
|
||||
expect(tagsFilter).toBeEnabled();
|
||||
});
|
||||
|
||||
it('does not render Tags filter when TAGGING_SYSTEM is disabled', async () => {
|
||||
(
|
||||
isFeatureEnabled as jest.MockedFunction<typeof isFeatureEnabled>
|
||||
).mockImplementation(
|
||||
(feature: string) =>
|
||||
feature !== 'LISTVIEWS_DEFAULT_CARD_VIEW' &&
|
||||
feature !== 'TAGGING_SYSTEM',
|
||||
);
|
||||
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
await screen.findByTestId('listview-table');
|
||||
|
||||
// Check that Tag filter is not present in filter containers
|
||||
const containers = screen.getAllByTestId('select-filter-container');
|
||||
const filterLabels = containers
|
||||
.map(container => {
|
||||
const label = container.querySelector('label');
|
||||
return label?.textContent;
|
||||
})
|
||||
.filter(Boolean);
|
||||
expect(filterLabels).not.toContain('Tag');
|
||||
});
|
||||
|
||||
it('allows filters to be reset correctly', async () => {
|
||||
renderChartList(mockUser);
|
||||
await screen.findByTestId('chart-list-view');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('listview-table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Apply search filter
|
||||
const searchInput = screen.getByTestId('filters-search');
|
||||
fireEvent.change(searchInput, { target: { value: 'test' } });
|
||||
|
||||
// Clear search
|
||||
fireEvent.change(searchInput, { target: { value: '' } });
|
||||
|
||||
// Verify filter UI is reset
|
||||
expect((searchInput as HTMLInputElement).value).toBe('');
|
||||
});
|
||||
});
|
||||
@@ -1,332 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import fetchMock from 'fetch-mock';
|
||||
import { render } from 'spec/helpers/testing-library';
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { QueryParamProvider } from 'use-query-params';
|
||||
import ChartList from 'src/pages/ChartList';
|
||||
import handleResourceExport from 'src/utils/export';
|
||||
|
||||
export const mockHandleResourceExport =
|
||||
handleResourceExport as jest.MockedFunction<typeof handleResourceExport>;
|
||||
|
||||
export const mockCharts = [
|
||||
{
|
||||
id: 0,
|
||||
url: '/superset/slice/0/',
|
||||
viz_type: 'table',
|
||||
slice_name: 'Test Chart 0',
|
||||
|
||||
// ✅ Basic case - has some data
|
||||
owners: [{ first_name: 'Test', last_name: 'User', id: 1 }],
|
||||
dashboards: [{ dashboard_title: 'Test Dashboard', id: 1 }],
|
||||
tags: [{ name: 'basic', type: 1, id: 1 }],
|
||||
|
||||
datasource_name_text: 'public.test_dataset',
|
||||
datasource_url: '/superset/explore/table/1/',
|
||||
datasource_id: 1,
|
||||
|
||||
changed_by_name: 'user',
|
||||
changed_by: {
|
||||
first_name: 'Test',
|
||||
last_name: 'User',
|
||||
id: 1,
|
||||
},
|
||||
changed_on_utc: new Date().toISOString(),
|
||||
changed_on_delta_humanized: '1 day ago',
|
||||
last_saved_at: new Date().toISOString(),
|
||||
|
||||
created_by: 'user',
|
||||
description: 'Test chart description',
|
||||
thumbnail_url: '/api/v1/chart/0/thumbnail/',
|
||||
certified_by: null,
|
||||
certification_details: null,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
url: '/superset/slice/1/',
|
||||
viz_type: 'bar',
|
||||
slice_name: 'Test Chart 1',
|
||||
|
||||
// ✅ FULL DATA CASE - everything populated for comprehensive testing
|
||||
owners: [
|
||||
{ first_name: 'Admin', last_name: 'User', id: 2 },
|
||||
{ first_name: 'Data', last_name: 'Analyst', id: 3 },
|
||||
],
|
||||
dashboards: [
|
||||
{ dashboard_title: 'Sales Dashboard', id: 2 },
|
||||
{ dashboard_title: 'Analytics Dashboard', id: 3 },
|
||||
{ dashboard_title: 'Executive Dashboard', id: 4 },
|
||||
],
|
||||
tags: [
|
||||
{ name: 'production', type: 1, id: 2 },
|
||||
{ name: 'sales', type: 1, id: 3 },
|
||||
{ name: 'analytics', type: 1, id: 4 },
|
||||
],
|
||||
|
||||
datasource_name_text: 'sales_data',
|
||||
datasource_url: '/superset/explore/table/2/',
|
||||
datasource_id: 2,
|
||||
|
||||
changed_by_name: 'admin',
|
||||
changed_by: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
id: 2,
|
||||
},
|
||||
changed_on_utc: new Date().toISOString(),
|
||||
changed_on_delta_humanized: '2 days ago',
|
||||
last_saved_at: new Date().toISOString(),
|
||||
|
||||
created_by: 'admin',
|
||||
description: 'Comprehensive sales analytics chart',
|
||||
thumbnail_url: '/api/v1/chart/1/thumbnail/',
|
||||
certified_by: 'Data Team',
|
||||
certification_details: 'Approved for production use',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
url: '/superset/slice/2/',
|
||||
viz_type: 'line',
|
||||
slice_name: 'Test Chart 2',
|
||||
|
||||
// ✅ EDGE CASE - no owners, no dataset, no dashboards, no tags
|
||||
owners: [],
|
||||
dashboards: [],
|
||||
tags: [],
|
||||
|
||||
datasource_name_text: null,
|
||||
datasource_url: null,
|
||||
datasource_id: null,
|
||||
|
||||
changed_by_name: 'system',
|
||||
changed_by: {
|
||||
first_name: 'System',
|
||||
last_name: 'User',
|
||||
id: 999,
|
||||
},
|
||||
changed_on_utc: new Date().toISOString(),
|
||||
changed_on_delta_humanized: '3 days ago',
|
||||
last_saved_at: new Date().toISOString(),
|
||||
|
||||
created_by: 'system',
|
||||
description: null,
|
||||
thumbnail_url: '/api/v1/chart/2/thumbnail/',
|
||||
certified_by: null,
|
||||
certification_details: null,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
url: '/superset/slice/3/',
|
||||
viz_type: 'area',
|
||||
slice_name: 'Test Chart 3',
|
||||
|
||||
// ✅ TRUNCATION TEST - Exactly at limits (4 owners, 20 dashboards)
|
||||
owners: [
|
||||
{ first_name: 'Admin', last_name: 'User', id: 2 },
|
||||
{ first_name: 'Data', last_name: 'Analyst', id: 3 },
|
||||
{ first_name: 'Limit', last_name: 'User', id: 40 },
|
||||
{ first_name: 'Test', last_name: 'User', id: 43 },
|
||||
],
|
||||
dashboards: Array.from({ length: 20 }, (_, i) => ({
|
||||
dashboard_title: `Dashboard ${i + 1}`,
|
||||
id: 200 + i,
|
||||
})),
|
||||
tags: [{ name: 'limit-test', type: 1, id: 10 }],
|
||||
|
||||
datasource_name_text: 'public.limits_dataset',
|
||||
datasource_url: '/superset/explore/table/4/',
|
||||
datasource_id: 4,
|
||||
|
||||
changed_by_name: 'limit_user',
|
||||
changed_by: {
|
||||
first_name: 'Limit',
|
||||
last_name: 'User',
|
||||
id: 40,
|
||||
},
|
||||
changed_on_utc: new Date().toISOString(),
|
||||
changed_on_delta_humanized: '4 days ago',
|
||||
last_saved_at: new Date().toISOString(),
|
||||
|
||||
created_by: 'limit_user',
|
||||
description: 'Chart at exact truncation limits',
|
||||
thumbnail_url: '/api/v1/chart/3/thumbnail/',
|
||||
certified_by: 'QA Team',
|
||||
certification_details: 'Verified for limit testing',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
url: '/superset/slice/4/',
|
||||
viz_type: 'bubble',
|
||||
slice_name: 'Test Chart 4',
|
||||
|
||||
// ✅ TRUNCATION TEST - Just above limits (5 owners shows +1, 21 dashboards)
|
||||
owners: [
|
||||
{ first_name: 'Admin', last_name: 'User', id: 2 },
|
||||
{ first_name: 'Data', last_name: 'Analyst', id: 3 },
|
||||
{ first_name: 'Limit', last_name: 'User', id: 40 },
|
||||
{ first_name: 'Test', last_name: 'User', id: 43 },
|
||||
{ first_name: 'Overflow', last_name: 'User', id: 50 },
|
||||
],
|
||||
dashboards: Array.from({ length: 21 }, (_, i) => ({
|
||||
dashboard_title: `Extra Dashboard ${i + 1}`,
|
||||
id: 300 + i,
|
||||
})),
|
||||
tags: [{ name: 'overflow', type: 1, id: 11 }],
|
||||
|
||||
datasource_name_text: 'public.overflow_dataset',
|
||||
datasource_url: '/superset/explore/table/5/',
|
||||
datasource_id: 5,
|
||||
|
||||
changed_by_name: 'overflow_user',
|
||||
changed_by: {
|
||||
first_name: 'Overflow',
|
||||
last_name: 'User',
|
||||
id: 50,
|
||||
},
|
||||
changed_on_utc: new Date().toISOString(),
|
||||
changed_on_delta_humanized: '5 days ago',
|
||||
last_saved_at: new Date().toISOString(),
|
||||
|
||||
created_by: 'overflow_user',
|
||||
description: 'Chart exceeding truncation limits',
|
||||
thumbnail_url: '/api/v1/chart/4/thumbnail/',
|
||||
certified_by: null,
|
||||
certification_details: null,
|
||||
},
|
||||
];
|
||||
|
||||
// Shared store utilities
|
||||
export const createMockStore = (initialState: any = {}) =>
|
||||
configureStore({
|
||||
reducer: {
|
||||
user: (state = initialState.user || {}) => state,
|
||||
common: (state = initialState.common || {}) => state,
|
||||
charts: (state = initialState.charts || {}) => state,
|
||||
},
|
||||
preloadedState: initialState,
|
||||
middleware: getDefaultMiddleware =>
|
||||
getDefaultMiddleware({
|
||||
serializableCheck: false,
|
||||
immutableCheck: false,
|
||||
}),
|
||||
});
|
||||
|
||||
export const createDefaultStoreState = (user: any) => ({
|
||||
user,
|
||||
common: {
|
||||
conf: {
|
||||
SUPERSET_WEBSERVER_TIMEOUT: 60000,
|
||||
},
|
||||
},
|
||||
charts: {
|
||||
chartList: mockCharts,
|
||||
},
|
||||
});
|
||||
|
||||
export const renderChartList = (user: any, props = {}, storeState = {}) => {
|
||||
const defaultStoreState = createDefaultStoreState(user);
|
||||
const storeStateWithUser = {
|
||||
...defaultStoreState,
|
||||
user,
|
||||
...storeState,
|
||||
};
|
||||
|
||||
const store = createMockStore(storeStateWithUser);
|
||||
|
||||
return render(
|
||||
<Provider store={store}>
|
||||
<MemoryRouter>
|
||||
<QueryParamProvider>
|
||||
<ChartList user={user} {...props} />
|
||||
</QueryParamProvider>
|
||||
</MemoryRouter>
|
||||
</Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
// API endpoint constants for reuse across tests
|
||||
export const API_ENDPOINTS = {
|
||||
CHARTS_INFO: 'glob:*/api/v1/chart/_info*',
|
||||
CHARTS: 'glob:*/api/v1/chart/?*',
|
||||
CHART_FAVORITE_STATUS: 'glob:*/api/v1/chart/favorite_status*',
|
||||
CHART_VIZ_TYPES: 'glob:*/api/v1/chart/viz_types*',
|
||||
CHART_THUMBNAILS: 'glob:*/api/v1/chart/*/thumbnail/*',
|
||||
DATASETS: 'glob:*/api/v1/dataset/?q=*',
|
||||
DASHBOARDS: 'glob:*/api/v1/dashboard/?q=*',
|
||||
CHART_RELATED_OWNERS: 'glob:*/api/v1/chart/related/owners*',
|
||||
CHART_RELATED_CHANGED_BY: 'glob:*/api/v1/chart/related/changed_by*',
|
||||
CATCH_ALL: 'glob:*',
|
||||
};
|
||||
|
||||
export const setupMocks = () => {
|
||||
fetchMock.reset();
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHARTS_INFO, {
|
||||
permissions: ['can_read', 'can_write', 'can_export'],
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHARTS, {
|
||||
result: mockCharts,
|
||||
chart_count: mockCharts.length,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHART_FAVORITE_STATUS, {
|
||||
result: [],
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHART_VIZ_TYPES, {
|
||||
result: [
|
||||
{ text: 'Table', value: 'table' },
|
||||
{ text: 'Bar Chart', value: 'bar' },
|
||||
{ text: 'Line Chart', value: 'line' },
|
||||
],
|
||||
count: 3,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHART_THUMBNAILS, {
|
||||
body: new Blob(),
|
||||
sendAsJson: false,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.DATASETS, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.DASHBOARDS, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHART_RELATED_OWNERS, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CHART_RELATED_CHANGED_BY, {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
fetchMock.get(API_ENDPOINTS.CATCH_ALL, { result: [], count: 0 });
|
||||
};
|
||||
@@ -338,22 +338,6 @@ function ThemesList({
|
||||
|
||||
const subMenuButtons: SubMenuProps['buttons'] = [];
|
||||
|
||||
if (canImport) {
|
||||
subMenuButtons.push({
|
||||
name: (
|
||||
<Tooltip
|
||||
id="import-tooltip"
|
||||
title={t('Import themes')}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Icons.DownloadOutlined iconSize="l" data-test="import-button" />
|
||||
</Tooltip>
|
||||
),
|
||||
buttonStyle: 'link',
|
||||
onClick: openThemeImportModal,
|
||||
});
|
||||
}
|
||||
|
||||
if (canDelete || canExport) {
|
||||
subMenuButtons.push({
|
||||
name: t('Bulk select'),
|
||||
@@ -374,6 +358,22 @@ function ThemesList({
|
||||
});
|
||||
}
|
||||
|
||||
if (canImport) {
|
||||
subMenuButtons.push({
|
||||
name: (
|
||||
<Tooltip
|
||||
id="import-tooltip"
|
||||
title={t('Import themes')}
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Icons.DownloadOutlined iconSize="l" data-test="import-button" />
|
||||
</Tooltip>
|
||||
),
|
||||
buttonStyle: 'link',
|
||||
onClick: openThemeImportModal,
|
||||
});
|
||||
}
|
||||
|
||||
menuData.buttons = subMenuButtons;
|
||||
|
||||
const filters: ListViewFilters = useMemo(
|
||||
|
||||
@@ -1,225 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
getChartMetadataRegistry,
|
||||
ChartMetadata,
|
||||
Behavior,
|
||||
} from '@superset-ui/core';
|
||||
import { nativeFilterGate } from 'src/dashboard/components/nativeFilters/utils';
|
||||
|
||||
/**
|
||||
* Unit tests for chart registry filtering and option generation logic.
|
||||
* This tests the pure functions used in ChartList for filtering chart types.
|
||||
*/
|
||||
|
||||
describe('Chart Registry Utils', () => {
|
||||
describe('Type filter option generation', () => {
|
||||
let registry: ReturnType<typeof getChartMetadataRegistry>;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = getChartMetadataRegistry();
|
||||
registry.clear();
|
||||
});
|
||||
|
||||
it('generates correct options from chart metadata registry', () => {
|
||||
// Register test chart types
|
||||
registry
|
||||
.registerValue(
|
||||
'table',
|
||||
new ChartMetadata({
|
||||
name: 'Table',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'line',
|
||||
new ChartMetadata({
|
||||
name: 'Line Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'native_filter',
|
||||
new ChartMetadata({
|
||||
name: 'Native Filter Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [Behavior.NativeFilter],
|
||||
}),
|
||||
);
|
||||
|
||||
// Generate options like ChartList does
|
||||
const options = registry
|
||||
.keys()
|
||||
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
|
||||
.map(k => ({ label: registry.get(k)?.name || k, value: k }))
|
||||
.sort((a, b) => {
|
||||
if (!a.label || !b.label) return 0;
|
||||
if (a.label > b.label) return 1;
|
||||
if (a.label < b.label) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
expect(options).toEqual([
|
||||
{ label: 'Line Chart', value: 'line' },
|
||||
{ label: 'Table', value: 'table' },
|
||||
]);
|
||||
|
||||
// Native filter chart should be filtered out
|
||||
expect(
|
||||
options.find(opt => opt.value === 'native_filter'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles empty registry gracefully', () => {
|
||||
const options = registry
|
||||
.keys()
|
||||
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
|
||||
.map(k => ({ label: registry.get(k)?.name || k, value: k }));
|
||||
|
||||
expect(options).toEqual([]);
|
||||
});
|
||||
|
||||
it('falls back to chart key when name is missing', () => {
|
||||
registry.registerValue(
|
||||
'custom_chart',
|
||||
new ChartMetadata({
|
||||
name: '', // Empty name
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const options = registry
|
||||
.keys()
|
||||
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
|
||||
.map(k => ({ label: registry.get(k)?.name || k, value: k }));
|
||||
|
||||
expect(options).toEqual([
|
||||
{ label: 'custom_chart', value: 'custom_chart' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts options alphabetically by label', () => {
|
||||
registry
|
||||
.registerValue(
|
||||
'zebra',
|
||||
new ChartMetadata({
|
||||
name: 'Zebra Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'apple',
|
||||
new ChartMetadata({
|
||||
name: 'Apple Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'banana',
|
||||
new ChartMetadata({
|
||||
name: 'Banana Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const options = registry
|
||||
.keys()
|
||||
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
|
||||
.map(k => ({ label: registry.get(k)?.name || k, value: k }))
|
||||
.sort((a, b) => {
|
||||
if (!a.label || !b.label) return 0;
|
||||
if (a.label > b.label) return 1;
|
||||
if (a.label < b.label) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
expect(options).toEqual([
|
||||
{ label: 'Apple Chart', value: 'apple' },
|
||||
{ label: 'Banana Chart', value: 'banana' },
|
||||
{ label: 'Zebra Chart', value: 'zebra' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('handles mixed chart behaviors correctly', () => {
|
||||
registry
|
||||
.registerValue(
|
||||
'regular',
|
||||
new ChartMetadata({
|
||||
name: 'Regular Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'interactive',
|
||||
new ChartMetadata({
|
||||
name: 'Interactive Chart',
|
||||
thumbnail: '',
|
||||
behaviors: [Behavior.InteractiveChart],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'native_with_interactive',
|
||||
new ChartMetadata({
|
||||
name: 'Native Filter with Interactive',
|
||||
thumbnail: '',
|
||||
behaviors: [Behavior.NativeFilter, Behavior.InteractiveChart],
|
||||
}),
|
||||
)
|
||||
.registerValue(
|
||||
'pure_native',
|
||||
new ChartMetadata({
|
||||
name: 'Pure Native Filter',
|
||||
thumbnail: '',
|
||||
behaviors: [Behavior.NativeFilter],
|
||||
}),
|
||||
);
|
||||
|
||||
const options = registry
|
||||
.keys()
|
||||
.filter(k => nativeFilterGate(registry.get(k)?.behaviors || []))
|
||||
.map(k => ({ label: registry.get(k)?.name || k, value: k }))
|
||||
.sort((a, b) => {
|
||||
if (!a.label || !b.label) return 0;
|
||||
if (a.label > b.label) return 1;
|
||||
if (a.label < b.label) return -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
// Should include regular, interactive, and native with interactive
|
||||
// Should exclude pure native filter
|
||||
expect(options).toEqual([
|
||||
{ label: 'Interactive Chart', value: 'interactive' },
|
||||
{
|
||||
label: 'Native Filter with Interactive',
|
||||
value: 'native_with_interactive',
|
||||
},
|
||||
{ label: 'Regular Chart', value: 'regular' },
|
||||
]);
|
||||
|
||||
expect(options.find(opt => opt.value === 'pure_native')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -102,157 +102,4 @@ describe('useListViewResource', () => {
|
||||
'/api/v1/example/?q=(filters:!((col:status,opr:equals,value:active)),order_column:foo,order_direction:asc,page:0,page_size:10,select_columns:!(id,name))',
|
||||
});
|
||||
});
|
||||
|
||||
describe('ChartList-specific filter scenarios', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('converts Type filter to correct API call for charts', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Chart', jest.fn()),
|
||||
);
|
||||
|
||||
const typeFilter = [{ id: 'viz_type', operator: 'eq', value: 'table' }];
|
||||
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
|
||||
filters: typeFilter,
|
||||
});
|
||||
|
||||
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
|
||||
endpoint: expect.stringContaining('/api/v1/chart/?q='),
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[1];
|
||||
const { endpoint } = call[0];
|
||||
|
||||
expect(endpoint).toMatch(/col:viz_type/);
|
||||
expect(endpoint).toMatch(/opr:eq/);
|
||||
expect(endpoint).toMatch(/value:table/);
|
||||
expect(endpoint).toMatch(/order_column:changed_on_delta_humanized/);
|
||||
expect(endpoint).toMatch(/order_direction:desc/);
|
||||
});
|
||||
|
||||
it('converts chart search filter with ChartAllText operator', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Chart', jest.fn()),
|
||||
);
|
||||
|
||||
const searchFilter = [
|
||||
{
|
||||
id: 'slice_name',
|
||||
operator: 'chart_all_text',
|
||||
value: 'test chart',
|
||||
},
|
||||
];
|
||||
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
|
||||
filters: searchFilter,
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[1];
|
||||
const { endpoint } = call[0];
|
||||
|
||||
expect(endpoint).toContain('col%3Aslice_name');
|
||||
expect(endpoint).toContain('opr%3Achart_all_text');
|
||||
expect(endpoint).toContain("value%3A'test+chart'");
|
||||
});
|
||||
|
||||
it('converts chart-specific favorite filter', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Chart', jest.fn()),
|
||||
);
|
||||
|
||||
const favoriteFilter = [
|
||||
{ id: 'id', operator: 'chart_is_favorite', value: true },
|
||||
];
|
||||
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
|
||||
filters: favoriteFilter,
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[1];
|
||||
const { endpoint } = call[0];
|
||||
|
||||
expect(endpoint).toMatch(/col:id/);
|
||||
expect(endpoint).toMatch(/opr:chart_is_favorite/);
|
||||
expect(endpoint).toContain('value:!t');
|
||||
});
|
||||
|
||||
it('handles multiple chart filters correctly', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Chart', jest.fn()),
|
||||
);
|
||||
|
||||
const multipleFilters = [
|
||||
{ id: 'viz_type', operator: 'eq', value: 'table' },
|
||||
{ id: 'slice_name', operator: 'chart_all_text', value: 'test' },
|
||||
];
|
||||
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'changed_on_delta_humanized', desc: true }],
|
||||
filters: multipleFilters,
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[1];
|
||||
const { endpoint } = call[0];
|
||||
|
||||
// Should contain both filters
|
||||
expect(endpoint).toMatch(/col:viz_type/);
|
||||
expect(endpoint).toMatch(/value:table/);
|
||||
expect(endpoint).toMatch(/col:slice_name/);
|
||||
expect(endpoint).toMatch(/value:test/);
|
||||
});
|
||||
|
||||
it('handles chart sorting scenarios', async () => {
|
||||
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
|
||||
json: { result: [], count: 0 },
|
||||
} as unknown as JsonResponse);
|
||||
|
||||
const { result } = renderHook(() =>
|
||||
useListViewResource('chart', 'Chart', jest.fn()),
|
||||
);
|
||||
|
||||
// Test alphabetical sort (slice_name ASC)
|
||||
result.current.fetchData({
|
||||
pageIndex: 0,
|
||||
pageSize: 25,
|
||||
sortBy: [{ id: 'slice_name', desc: false }],
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const call = fetchSpy.mock.calls[1];
|
||||
const { endpoint } = call[0];
|
||||
|
||||
expect(endpoint).toMatch(/order_column:slice_name/);
|
||||
expect(endpoint).toMatch(/order_direction:asc/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
from flask import current_app, Flask
|
||||
from werkzeug.local import LocalProxy
|
||||
|
||||
from superset.app import create_app # noqa: F401
|
||||
@@ -34,7 +35,9 @@ from superset.security import SupersetSecurityManager # noqa: F401
|
||||
# to declare "global" dependencies is to define it in extensions.py,
|
||||
# then initialize it in app.create_app(). These fields will be removed
|
||||
# in subsequent PRs as things are migrated towards the factory pattern
|
||||
app: Flask = current_app
|
||||
cache = cache_manager.cache
|
||||
conf = LocalProxy(lambda: current_app.config)
|
||||
get_feature_flags = feature_flag_manager.get_feature_flags
|
||||
get_manifest_files = manifest_processor.get_manifest_files
|
||||
is_feature_enabled = feature_flag_manager.is_feature_enabled
|
||||
|
||||
@@ -29,6 +29,9 @@ from superset.advanced_data_type.types import AdvancedDataTypeResponse
|
||||
from superset.extensions import event_logger
|
||||
from superset.views.base_api import BaseSupersetApi
|
||||
|
||||
config = app.config
|
||||
ADVANCED_DATA_TYPES = config["ADVANCED_DATA_TYPES"]
|
||||
|
||||
|
||||
class AdvancedDataTypeRestApi(BaseSupersetApi):
|
||||
"""
|
||||
@@ -93,7 +96,7 @@ class AdvancedDataTypeRestApi(BaseSupersetApi):
|
||||
item = kwargs["rison"]
|
||||
advanced_data_type = item["type"]
|
||||
values = item["values"]
|
||||
addon = app.config["ADVANCED_DATA_TYPES"].get(advanced_data_type)
|
||||
addon = ADVANCED_DATA_TYPES.get(advanced_data_type)
|
||||
if not addon:
|
||||
return self.response(
|
||||
400,
|
||||
@@ -145,4 +148,4 @@ class AdvancedDataTypeRestApi(BaseSupersetApi):
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
return self.response(200, result=list(app.config["ADVANCED_DATA_TYPES"].keys()))
|
||||
return self.response(200, result=list(ADVANCED_DATA_TYPES.keys()))
|
||||
|
||||
@@ -117,8 +117,9 @@ class AsyncQueryManager:
|
||||
self._load_explore_json_into_cache_job: Any = None
|
||||
|
||||
def init_app(self, app: Flask) -> None:
|
||||
cache_type = app.config.get("CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
data_cache_type = app.config.get("DATA_CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
config = app.config
|
||||
cache_type = config.get("CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
data_cache_type = config.get("DATA_CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
if cache_type in [None, "null"] or data_cache_type in [None, "null"]:
|
||||
raise Exception( # pylint: disable=broad-exception-raised
|
||||
"""
|
||||
@@ -127,28 +128,26 @@ class AsyncQueryManager:
|
||||
"""
|
||||
)
|
||||
|
||||
self._cache = get_cache_backend(app.config)
|
||||
self._cache = get_cache_backend(config)
|
||||
logger.debug("Using GAQ Cache backend as %s", type(self._cache).__name__)
|
||||
|
||||
if len(app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]) < 32:
|
||||
if len(config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]) < 32:
|
||||
raise AsyncQueryTokenException(
|
||||
"Please provide a JWT secret at least 32 bytes long"
|
||||
)
|
||||
|
||||
self._stream_prefix = app.config["GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX"]
|
||||
self._stream_limit = app.config["GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT"]
|
||||
self._stream_limit_firehose = app.config[
|
||||
self._stream_prefix = config["GLOBAL_ASYNC_QUERIES_REDIS_STREAM_PREFIX"]
|
||||
self._stream_limit = config["GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT"]
|
||||
self._stream_limit_firehose = config[
|
||||
"GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT_FIREHOSE"
|
||||
]
|
||||
self._jwt_cookie_name = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME"]
|
||||
self._jwt_cookie_secure = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE"]
|
||||
self._jwt_cookie_samesite = app.config[
|
||||
"GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE"
|
||||
]
|
||||
self._jwt_cookie_domain = app.config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN"]
|
||||
self._jwt_secret = app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
||||
self._jwt_cookie_name = config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_NAME"]
|
||||
self._jwt_cookie_secure = config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SECURE"]
|
||||
self._jwt_cookie_samesite = config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_SAMESITE"]
|
||||
self._jwt_cookie_domain = config["GLOBAL_ASYNC_QUERIES_JWT_COOKIE_DOMAIN"]
|
||||
self._jwt_secret = config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]
|
||||
|
||||
if app.config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
if config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
self.register_request_handlers(app)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -16,9 +16,10 @@
|
||||
# under the License.
|
||||
import logging
|
||||
|
||||
from flask import current_app as app, Response
|
||||
from flask import Response
|
||||
from flask_appbuilder.api import expose, protect, safe
|
||||
|
||||
from superset import conf
|
||||
from superset.available_domains.schemas import AvailableDomainsSchema
|
||||
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
|
||||
from superset.extensions import event_logger
|
||||
@@ -69,6 +70,6 @@ class AvailableDomainsRestApi(BaseSupersetApi):
|
||||
$ref: '#/components/responses/403'
|
||||
"""
|
||||
result = self.available_domains_schema.dump(
|
||||
{"domains": app.config.get("SUPERSET_WEBSERVER_DOMAINS")}
|
||||
{"domains": conf.get("SUPERSET_WEBSERVER_DOMAINS")}
|
||||
)
|
||||
return self.response(200, result=result)
|
||||
|
||||
@@ -30,7 +30,7 @@ from marshmallow import ValidationError
|
||||
from werkzeug.wrappers import Response as WerkzeugResponse
|
||||
from werkzeug.wsgi import FileWrapper
|
||||
|
||||
from superset import is_feature_enabled
|
||||
from superset import app, is_feature_enabled
|
||||
from superset.charts.filters import (
|
||||
ChartAllTextFilter,
|
||||
ChartCertifiedFilter,
|
||||
@@ -101,6 +101,7 @@ from superset.views.base_api import (
|
||||
from superset.views.filters import BaseFilterRelatedUsers, FilterRelatedOwners
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
config = app.config
|
||||
|
||||
|
||||
class ChartRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
@@ -20,7 +20,7 @@ import contextlib
|
||||
import logging
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import current_app as app, g, make_response, request, Response
|
||||
from flask import current_app, g, make_response, request, Response
|
||||
from flask_appbuilder.api import expose, protect
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import ValidationError
|
||||
@@ -379,7 +379,7 @@ class ChartDataRestApi(ChartRestApi):
|
||||
# return multi-query results bundled as a zip file
|
||||
def _process_data(query_data: Any) -> Any:
|
||||
if result_format == ChartDataResultFormat.CSV:
|
||||
encoding = app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
encoding = current_app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
return query_data.encode(encoding)
|
||||
return query_data
|
||||
|
||||
|
||||
@@ -20,11 +20,11 @@ from __future__ import annotations
|
||||
import inspect
|
||||
from typing import Any, TYPE_CHECKING
|
||||
|
||||
from flask import current_app
|
||||
from flask_babel import gettext as _
|
||||
from marshmallow import EXCLUDE, fields, post_load, Schema, validate
|
||||
from marshmallow.validate import Length, Range
|
||||
|
||||
from superset import app
|
||||
from superset.common.chart_data import ChartDataResultFormat, ChartDataResultType
|
||||
from superset.db_engine_specs.base import builtin_time_grains
|
||||
from superset.utils import pandas_postprocessing, schema as utils
|
||||
@@ -40,25 +40,7 @@ if TYPE_CHECKING:
|
||||
from superset.common.query_context import QueryContext
|
||||
from superset.common.query_context_factory import QueryContextFactory
|
||||
|
||||
|
||||
def get_time_grain_choices() -> Any:
|
||||
"""Get time grain choices including addons from config"""
|
||||
try:
|
||||
# Try to get config from current app context
|
||||
time_grain_addons = current_app.config.get("TIME_GRAIN_ADDONS", {})
|
||||
except RuntimeError:
|
||||
# Outside app context, use empty addons
|
||||
time_grain_addons = {}
|
||||
|
||||
return [
|
||||
i
|
||||
for i in {
|
||||
**builtin_time_grains,
|
||||
**time_grain_addons,
|
||||
}.keys()
|
||||
if i
|
||||
]
|
||||
|
||||
config = app.config
|
||||
|
||||
#
|
||||
# RISON/JSON schemas for query parameters
|
||||
@@ -642,7 +624,13 @@ class ChartDataProphetOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
|
||||
"[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
|
||||
"example": "P1D",
|
||||
},
|
||||
validate=validate.OneOf(choices=get_time_grain_choices()),
|
||||
validate=validate.OneOf(
|
||||
choices=[
|
||||
i
|
||||
for i in {**builtin_time_grains, **config["TIME_GRAIN_ADDONS"]}.keys()
|
||||
if i
|
||||
]
|
||||
),
|
||||
required=True,
|
||||
)
|
||||
periods = fields.Integer(
|
||||
@@ -1001,7 +989,13 @@ class ChartDataExtrasSchema(Schema):
|
||||
"[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
|
||||
"example": "P1D",
|
||||
},
|
||||
validate=validate.OneOf(choices=get_time_grain_choices()),
|
||||
validate=validate.OneOf(
|
||||
choices=[
|
||||
i
|
||||
for i in {**builtin_time_grains, **config["TIME_GRAIN_ADDONS"]}.keys()
|
||||
if i
|
||||
]
|
||||
),
|
||||
allow_none=True,
|
||||
)
|
||||
instant_time_comparison_range = fields.String(
|
||||
|
||||
@@ -30,6 +30,7 @@ def load_examples_run(
|
||||
load_big_data: bool = False,
|
||||
only_metadata: bool = False,
|
||||
force: bool = False,
|
||||
cleanup: bool = False,
|
||||
) -> None:
|
||||
if only_metadata:
|
||||
logger.info("Loading examples metadata")
|
||||
@@ -40,51 +41,41 @@ def load_examples_run(
|
||||
# pylint: disable=import-outside-toplevel
|
||||
import superset.examples.data_loading as examples
|
||||
|
||||
# Clear old examples if requested
|
||||
if cleanup:
|
||||
clear_old_examples()
|
||||
|
||||
examples.load_css_templates()
|
||||
|
||||
if load_test_data:
|
||||
# Import test fixtures from tests directory
|
||||
from tests.fixtures.examples.energy import load_energy
|
||||
from tests.fixtures.examples.supported_charts_dashboard import (
|
||||
load_supported_charts_dashboard,
|
||||
)
|
||||
from tests.fixtures.examples.tabbed_dashboard import load_tabbed_dashboard
|
||||
|
||||
logger.info("Loading energy related dataset")
|
||||
examples.load_energy(only_metadata, force)
|
||||
load_energy(only_metadata, force)
|
||||
|
||||
logger.info("Loading [World Bank's Health Nutrition and Population Stats]")
|
||||
examples.load_world_bank_health_n_pop(only_metadata, force)
|
||||
|
||||
logger.info("Loading [Birth names]")
|
||||
examples.load_birth_names(only_metadata, force)
|
||||
|
||||
if load_test_data:
|
||||
logger.info("Loading [Tabbed dashboard]")
|
||||
examples.load_tabbed_dashboard(only_metadata)
|
||||
load_tabbed_dashboard(only_metadata)
|
||||
|
||||
logger.info("Loading [Supported Charts Dashboard]")
|
||||
examples.load_supported_charts_dashboard()
|
||||
load_supported_charts_dashboard()
|
||||
else:
|
||||
logger.info("Loading [Random long/lat data]")
|
||||
examples.load_long_lat_data(only_metadata, force)
|
||||
|
||||
logger.info("Loading [Country Map data]")
|
||||
examples.load_country_map_data(only_metadata, force)
|
||||
|
||||
logger.info("Loading [San Francisco population polygons]")
|
||||
examples.load_sf_population_polygons(only_metadata, force)
|
||||
|
||||
logger.info("Loading [Flights data]")
|
||||
examples.load_flights(only_metadata, force)
|
||||
|
||||
logger.info("Loading [BART lines]")
|
||||
examples.load_bart_lines(only_metadata, force)
|
||||
|
||||
logger.info("Loading [Misc Charts] dashboard")
|
||||
examples.load_misc_dashboard()
|
||||
|
||||
logger.info("Loading DECK.gl demo")
|
||||
examples.load_deck_dash()
|
||||
|
||||
if load_big_data:
|
||||
# Import test fixture from tests directory
|
||||
from tests.fixtures.examples.big_data import load_big_data as load_big_data_func
|
||||
|
||||
logger.info("Loading big synthetic data for tests")
|
||||
examples.load_big_data()
|
||||
load_big_data_func()
|
||||
|
||||
# load examples that are stored as YAML config files
|
||||
logger.info("Loading examples from YAML configuration files")
|
||||
examples.load_examples_from_configs(force, load_test_data)
|
||||
|
||||
|
||||
@@ -112,4 +103,222 @@ def load_examples(
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Loads a set of Slices and Dashboards and a supporting dataset"""
|
||||
# Show deprecation warning
|
||||
click.echo(
|
||||
click.style(
|
||||
"\nWARNING: 'superset load-examples' is deprecated. "
|
||||
"Please use 'superset examples load' instead.\n",
|
||||
fg="yellow",
|
||||
),
|
||||
err=True,
|
||||
)
|
||||
|
||||
load_examples_run(load_test_data, load_big_data, only_metadata, force)
|
||||
|
||||
|
||||
# New CLI structure
|
||||
@click.group(name="examples", help="Manage example data")
|
||||
def examples_cli() -> None:
|
||||
"""Group for example-related commands."""
|
||||
pass
|
||||
|
||||
|
||||
@examples_cli.command(name="load", help="Load example data into the database")
|
||||
@with_appcontext
|
||||
@transaction()
|
||||
@click.option("--load-test-data", "-t", is_flag=True, help="Load additional test data")
|
||||
@click.option("--load-big-data", "-b", is_flag=True, help="Load additional big data")
|
||||
@click.option(
|
||||
"--only-metadata",
|
||||
"-m",
|
||||
is_flag=True,
|
||||
help="Only load metadata, skip actual data",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
help="Force load data even if table already exists",
|
||||
)
|
||||
def load(
|
||||
load_test_data: bool = False,
|
||||
load_big_data: bool = False,
|
||||
only_metadata: bool = False,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Load example datasets, charts, and dashboards."""
|
||||
load_examples_run(
|
||||
load_test_data, load_big_data, only_metadata, force, cleanup=False
|
||||
)
|
||||
|
||||
|
||||
def clear_old_examples() -> bool:
|
||||
"""
|
||||
Clear old Python-generated examples.
|
||||
Returns True if clear was performed, False otherwise.
|
||||
"""
|
||||
from superset import db
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.examples.utils import _has_old_examples
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard, dashboard_slices
|
||||
from superset.models.slice import Slice
|
||||
|
||||
# Check if old examples exist
|
||||
if not _has_old_examples():
|
||||
logger.info("No old examples found to clear")
|
||||
return False
|
||||
|
||||
# Find the examples database
|
||||
examples_db = db.session.query(Database).filter_by(database_name="examples").first()
|
||||
|
||||
if not examples_db:
|
||||
return False
|
||||
|
||||
logger.info("Found examples database (id=%s)", examples_db.id)
|
||||
logger.info("Clearing old examples...")
|
||||
|
||||
# 1. Get all datasets from examples database
|
||||
example_datasets = (
|
||||
db.session.query(SqlaTable).filter_by(database_id=examples_db.id).all()
|
||||
)
|
||||
dataset_ids = [ds.id for ds in example_datasets]
|
||||
logger.info("Found %d example datasets", len(example_datasets))
|
||||
|
||||
# 2. Find all charts using these datasets
|
||||
example_charts = []
|
||||
if dataset_ids:
|
||||
example_charts = (
|
||||
db.session.query(Slice)
|
||||
.filter(
|
||||
Slice.datasource_id.in_(dataset_ids),
|
||||
Slice.datasource_type == "table",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
logger.info("Found %d example charts", len(example_charts))
|
||||
|
||||
chart_ids = [chart.id for chart in example_charts]
|
||||
|
||||
# 3. Find dashboards that contain these charts
|
||||
example_dashboards = []
|
||||
if chart_ids:
|
||||
# Get dashboards that have relationships with our example charts
|
||||
example_dashboards = (
|
||||
db.session.query(Dashboard)
|
||||
.join(dashboard_slices)
|
||||
.filter(dashboard_slices.c.slice_id.in_(chart_ids))
|
||||
.distinct()
|
||||
.all()
|
||||
)
|
||||
logger.info("Found %d example dashboards", len(example_dashboards))
|
||||
|
||||
# Remove dashboard-slice relationships first
|
||||
db.session.execute(
|
||||
dashboard_slices.delete().where(dashboard_slices.c.slice_id.in_(chart_ids))
|
||||
)
|
||||
logger.info(
|
||||
"Removed dashboard-slice relationships for %d charts",
|
||||
len(chart_ids),
|
||||
)
|
||||
|
||||
# 4. Delete dashboards that are now empty (contain only example charts)
|
||||
for dashboard in example_dashboards:
|
||||
# Since we already deleted the relationships, check if dashboard is empty
|
||||
remaining_charts = (
|
||||
db.session.query(dashboard_slices.c.slice_id)
|
||||
.filter(dashboard_slices.c.dashboard_id == dashboard.id)
|
||||
.count()
|
||||
)
|
||||
|
||||
if remaining_charts == 0:
|
||||
db.session.delete(dashboard)
|
||||
logger.info(
|
||||
"Deleted dashboard: %s (slug: %s)",
|
||||
dashboard.dashboard_title,
|
||||
dashboard.slug,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Keeping dashboard %s as it contains non-example charts",
|
||||
dashboard.dashboard_title,
|
||||
)
|
||||
|
||||
# 5. Delete charts
|
||||
for chart in example_charts:
|
||||
db.session.delete(chart)
|
||||
logger.info("Deleted %d example charts", len(example_charts))
|
||||
|
||||
# 6. Delete the database - this will cascade delete all datasets,
|
||||
# columns, and metrics thanks to the cascade="all, delete-orphan"
|
||||
db.session.delete(examples_db)
|
||||
|
||||
logger.info("Examples database and all related objects removed successfully")
|
||||
return True
|
||||
|
||||
|
||||
@examples_cli.command(name="clear-old", help="Clear old Python-based example data")
|
||||
@with_appcontext
|
||||
@transaction()
|
||||
@click.option(
|
||||
"--confirm",
|
||||
is_flag=True,
|
||||
help="Skip confirmation prompt",
|
||||
)
|
||||
def clear_old(confirm: bool = False) -> None:
|
||||
"""Clear old Python-generated example datasets, charts, and dashboards."""
|
||||
if not confirm:
|
||||
click.confirm(
|
||||
"This will delete old Python-based example data. Are you sure?",
|
||||
abort=True,
|
||||
)
|
||||
|
||||
try:
|
||||
if clear_old_examples():
|
||||
logger.info("Old examples cleared successfully")
|
||||
else:
|
||||
logger.info("No old examples found to clear")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to clear old examples: {e}")
|
||||
raise
|
||||
|
||||
|
||||
@examples_cli.command(name="clear", help="Clear all example data (NOT YET IMPLEMENTED)")
|
||||
@with_appcontext
|
||||
def clear() -> None:
|
||||
"""Clear all example data including YAML-based examples."""
|
||||
click.echo(
|
||||
click.style(
|
||||
"Clearing YAML-based examples is NOT YET IMPLEMENTED.\n"
|
||||
"Use 'superset examples clear-old' to remove old Python-based examples.",
|
||||
fg="yellow",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@examples_cli.command(name="reload", help="Clear and reload example data")
|
||||
@with_appcontext
|
||||
@transaction()
|
||||
@click.option("--load-test-data", "-t", is_flag=True, help="Load additional test data")
|
||||
@click.option("--load-big-data", "-b", is_flag=True, help="Load additional big data")
|
||||
@click.option(
|
||||
"--only-metadata",
|
||||
"-m",
|
||||
is_flag=True,
|
||||
help="Only load metadata, skip actual data",
|
||||
)
|
||||
@click.option(
|
||||
"--force",
|
||||
"-f",
|
||||
is_flag=True,
|
||||
help="Force load data even if table already exists",
|
||||
)
|
||||
def reload(
|
||||
load_test_data: bool = False,
|
||||
load_big_data: bool = False,
|
||||
only_metadata: bool = False,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Clear existing examples and load fresh ones."""
|
||||
# This is essentially the old --cleanup behavior
|
||||
load_examples_run(load_test_data, load_big_data, only_metadata, force, cleanup=True)
|
||||
|
||||
48
superset/cli/lib.py
Executable file
48
superset/cli/lib.py
Executable file
@@ -0,0 +1,48 @@
|
||||
#!/usr/bin/env python
|
||||
# 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 logging
|
||||
|
||||
from superset import config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
feature_flags = config.DEFAULT_FEATURE_FLAGS.copy()
|
||||
feature_flags.update(config.FEATURE_FLAGS)
|
||||
feature_flags_func = config.GET_FEATURE_FLAGS_FUNC
|
||||
if feature_flags_func:
|
||||
try:
|
||||
# pylint: disable=not-callable
|
||||
feature_flags = feature_flags_func(feature_flags)
|
||||
except Exception: # pylint: disable=broad-except # noqa: S110
|
||||
# bypass any feature flags that depend on context
|
||||
# that's not available
|
||||
pass
|
||||
|
||||
|
||||
def normalize_token(token_name: str) -> str:
|
||||
"""
|
||||
As of click>=7, underscores in function names are replaced by dashes.
|
||||
To avoid the need to rename all cli functions, e.g. load_examples to
|
||||
load-examples, this function is used to convert dashes back to
|
||||
underscores.
|
||||
|
||||
:param token_name: token name possibly containing dashes
|
||||
:return: token name where dashes are replaced with underscores
|
||||
"""
|
||||
return token_name.replace("_", "-")
|
||||
@@ -22,39 +22,18 @@ from typing import Any
|
||||
|
||||
import click
|
||||
from colorama import Fore, Style
|
||||
from flask import current_app
|
||||
from flask.cli import FlaskGroup, with_appcontext
|
||||
|
||||
from superset import appbuilder, cli, security_manager
|
||||
from superset import app, appbuilder, cli, security_manager
|
||||
from superset.cli.lib import normalize_token
|
||||
from superset.extensions import db
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def normalize_token(token_name: str) -> str:
|
||||
"""
|
||||
As of click>=7, underscores in function names are replaced by dashes.
|
||||
To avoid the need to rename all cli functions, e.g. load_examples to
|
||||
load-examples, this function is used to convert dashes back to
|
||||
underscores.
|
||||
|
||||
:param token_name: token name possibly containing dashes
|
||||
:return: token name where dashes are replaced with underscores
|
||||
"""
|
||||
return token_name.replace("_", "-")
|
||||
|
||||
|
||||
def create_app() -> Any:
|
||||
"""Create app instance for CLI"""
|
||||
from superset.app import create_app as create_superset_app
|
||||
|
||||
return create_superset_app()
|
||||
|
||||
|
||||
@click.group(
|
||||
cls=FlaskGroup,
|
||||
create_app=create_app,
|
||||
context_settings={"token_normalize_func": normalize_token},
|
||||
)
|
||||
@with_appcontext
|
||||
@@ -62,6 +41,10 @@ def superset() -> None:
|
||||
"""\033[1;37mThe Apache Superset CLI\033[0m"""
|
||||
# NOTE: codes above are ANSI color codes for bold white in CLI header ^^^
|
||||
|
||||
@app.shell_context_processor
|
||||
def make_shell_context() -> dict[str, Any]:
|
||||
return {"app": app, "db": db}
|
||||
|
||||
|
||||
# add sub-commands
|
||||
for load, module_name, is_pkg in pkgutil.walk_packages( # noqa: B007
|
||||
@@ -90,14 +73,8 @@ def init() -> None:
|
||||
@click.option("--verbose", "-v", is_flag=True, help="Show extra information")
|
||||
def version(verbose: bool) -> None:
|
||||
"""Prints the current version number"""
|
||||
|
||||
print(Fore.BLUE + "-=" * 15)
|
||||
print(
|
||||
Fore.YELLOW
|
||||
+ "Superset "
|
||||
+ Fore.CYAN
|
||||
+ f"{current_app.config['VERSION_STRING']}"
|
||||
)
|
||||
print(Fore.YELLOW + "Superset " + Fore.CYAN + f"{app.config['VERSION_STRING']}")
|
||||
print(Fore.BLUE + "-=" * 15)
|
||||
if verbose:
|
||||
print("[DB] : " + f"{db.engine}")
|
||||
|
||||
@@ -18,66 +18,57 @@
|
||||
import sys
|
||||
|
||||
import click
|
||||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
from werkzeug.security import check_password_hash
|
||||
|
||||
from superset.cli.lib import feature_flags
|
||||
|
||||
@click.command()
|
||||
@with_appcontext
|
||||
@click.option("--username", prompt="Admin Username", help="Admin Username")
|
||||
@click.option(
|
||||
"--silent",
|
||||
is_flag=True,
|
||||
prompt=(
|
||||
"Are you sure you want to reset Superset? "
|
||||
"This action cannot be undone. Continue?"
|
||||
),
|
||||
help="Confirmation flag",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-users",
|
||||
default=None,
|
||||
help="Comma separated list of users to exclude from reset",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-roles",
|
||||
default=None,
|
||||
help="Comma separated list of roles to exclude from reset",
|
||||
)
|
||||
def factory_reset(
|
||||
username: str, silent: bool, exclude_users: str, exclude_roles: str
|
||||
) -> None:
|
||||
"""Factory Reset Apache Superset"""
|
||||
if feature_flags.get("ENABLE_FACTORY_RESET_COMMAND"):
|
||||
|
||||
# Check feature flag inside the command
|
||||
if not current_app.config.get("FEATURE_FLAGS", {}).get(
|
||||
"ENABLE_FACTORY_RESET_COMMAND"
|
||||
):
|
||||
click.secho(
|
||||
"Factory reset command is disabled. Enable "
|
||||
"ENABLE_FACTORY_RESET_COMMAND feature flag.",
|
||||
fg="red",
|
||||
)
|
||||
sys.exit(1)
|
||||
@click.command()
|
||||
@with_appcontext
|
||||
@click.option("--username", prompt="Admin Username", help="Admin Username")
|
||||
@click.option(
|
||||
"--silent",
|
||||
is_flag=True,
|
||||
prompt=(
|
||||
"Are you sure you want to reset Superset? "
|
||||
"This action cannot be undone. Continue?"
|
||||
),
|
||||
help="Confirmation flag",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-users",
|
||||
default=None,
|
||||
help="Comma separated list of users to exclude from reset",
|
||||
)
|
||||
@click.option(
|
||||
"--exclude-roles",
|
||||
default=None,
|
||||
help="Comma separated list of roles to exclude from reset",
|
||||
)
|
||||
def factory_reset(
|
||||
username: str, silent: bool, exclude_users: str, exclude_roles: str
|
||||
) -> None:
|
||||
"""Factory Reset Apache Superset"""
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import security_manager
|
||||
from superset.commands.security.reset import ResetSupersetCommand
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from superset import security_manager
|
||||
from superset.commands.security.reset import ResetSupersetCommand
|
||||
|
||||
# Validate the user
|
||||
password = click.prompt("Admin Password", hide_input=True)
|
||||
user = security_manager.find_user(username)
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
click.secho("Invalid credentials", fg="red")
|
||||
sys.exit(1)
|
||||
if not any(role.name == "Admin" for role in user.roles):
|
||||
click.secho("Permission Denied", fg="red")
|
||||
sys.exit(1)
|
||||
# Validate the user
|
||||
password = click.prompt("Admin Password", hide_input=True)
|
||||
user = security_manager.find_user(username)
|
||||
if not user or not check_password_hash(user.password, password):
|
||||
click.secho("Invalid credentials", fg="red")
|
||||
sys.exit(1)
|
||||
if not any(role.name == "Admin" for role in user.roles):
|
||||
click.secho("Permission Denied", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
ResetSupersetCommand(silent, user, exclude_users, exclude_roles).run()
|
||||
click.secho("Factory reset complete", fg="green")
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
click.secho(f"Factory reset failed: {ex}", fg="red")
|
||||
sys.exit(1)
|
||||
try:
|
||||
ResetSupersetCommand(silent, user, exclude_users, exclude_roles).run()
|
||||
click.secho("Factory reset complete", fg="green")
|
||||
except Exception as ex: # pylint: disable=broad-except
|
||||
click.secho(f"Factory reset failed: {ex}", fg="red")
|
||||
sys.exit(1)
|
||||
|
||||
@@ -18,11 +18,10 @@ import logging
|
||||
|
||||
import click
|
||||
from colorama import Fore
|
||||
from flask import current_app
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
import superset.utils.database as database_utils
|
||||
from superset import security_manager
|
||||
from superset import app, security_manager
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -39,7 +38,7 @@ def load_test_users() -> None:
|
||||
"""
|
||||
print(Fore.GREEN + "Loading a set of users for unit tests")
|
||||
|
||||
if current_app.config["TESTING"]:
|
||||
if app.config["TESTING"]:
|
||||
sm = security_manager
|
||||
|
||||
examples_db = database_utils.get_example_database()
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from inspect import isclass
|
||||
from typing import Any
|
||||
|
||||
@@ -27,6 +28,8 @@ from superset.models.slice import Slice
|
||||
from superset.utils import json
|
||||
from superset.utils.core import AnnotationType, get_user
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def filter_chart_annotations(chart_config: dict[str, Any]) -> None:
|
||||
"""
|
||||
@@ -63,10 +66,13 @@ def import_chart(
|
||||
if not overwrite or not can_write:
|
||||
return existing
|
||||
config["id"] = existing.id
|
||||
logger.info(f"Updating existing chart: {config.get('slice_name')}")
|
||||
elif not can_write:
|
||||
raise ImportFailedError(
|
||||
"Chart doesn't exist and user doesn't have permission to create charts"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Creating new chart: {config.get('slice_name')}")
|
||||
|
||||
filter_chart_annotations(config)
|
||||
|
||||
|
||||
@@ -123,6 +123,9 @@ class ExportDashboardsCommand(ExportModelsCommand):
|
||||
include_defaults=True,
|
||||
export_uuids=True,
|
||||
)
|
||||
# Remove theme_id from export to make dashboards theme-free
|
||||
payload.pop("theme_id", None)
|
||||
|
||||
# TODO (betodealmeida): move this logic to export_to_dict once this
|
||||
# becomes the default export endpoint
|
||||
for key, new_name in JSON_KEYS.items():
|
||||
|
||||
@@ -28,9 +28,8 @@ from superset.temporary_cache.utils import cache_key
|
||||
class GetFilterStateCommand(GetTemporaryCacheCommand):
|
||||
def __init__(self, cmd_params: CommandParameters) -> None:
|
||||
super().__init__(cmd_params)
|
||||
self._refresh_timeout = app.config["FILTER_STATE_CACHE_CONFIG"].get(
|
||||
"REFRESH_TIMEOUT_ON_RETRIEVAL"
|
||||
)
|
||||
config = app.config["FILTER_STATE_CACHE_CONFIG"]
|
||||
self._refresh_timeout = config.get("REFRESH_TIMEOUT_ON_RETRIEVAL")
|
||||
|
||||
def get(self, cmd_params: CommandParameters) -> Optional[str]:
|
||||
resource_id = cmd_params.resource_id
|
||||
|
||||
@@ -166,11 +166,14 @@ def import_dashboard( # noqa: C901
|
||||
elif not overwrite or not can_write:
|
||||
return existing
|
||||
config["id"] = existing.id
|
||||
logger.info(f"Updating existing dashboard: {config.get('dashboard_title')}")
|
||||
elif not can_write:
|
||||
raise ImportFailedError(
|
||||
"Dashboard doesn't exist and user doesn't "
|
||||
"have permission to create dashboards"
|
||||
)
|
||||
else:
|
||||
logger.info(f"Creating new dashboard: {config.get('dashboard_title')}")
|
||||
|
||||
# TODO (betodealmeida): move this logic to import_from_dict
|
||||
config = config.copy()
|
||||
|
||||
@@ -19,11 +19,10 @@ import textwrap
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset import app, db, security_manager
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardColorsConfigUpdateFailedError,
|
||||
@@ -176,7 +175,7 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
|
||||
to=email,
|
||||
subject=f"[Report: {report.name}] Deactivated",
|
||||
html_content=html_content,
|
||||
config=current_app.config,
|
||||
config=app.config,
|
||||
)
|
||||
|
||||
def deactivate_reports(reports_list: list[ReportSchedule]) -> None:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user