mirror of
https://github.com/apache/superset.git
synced 2026-06-14 03:59:22 +00:00
Compare commits
35 Commits
examples
...
flask_conf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7b0f1c795 | ||
|
|
ed73ac4737 | ||
|
|
59fa496221 | ||
|
|
590d39abeb | ||
|
|
c67143592b | ||
|
|
6e469eb922 | ||
|
|
ffe1a0c9ee | ||
|
|
c2a05ea919 | ||
|
|
54f17134b6 | ||
|
|
ad8d0bb2fb | ||
|
|
a21a1824e3 | ||
|
|
30e731a15b | ||
|
|
faef33d6ba | ||
|
|
92bf3b9d4e | ||
|
|
29b4c480f3 | ||
|
|
1a9da0ff78 | ||
|
|
cb27d5fe8d | ||
|
|
6c9cda758a | ||
|
|
967134f540 | ||
|
|
25bb353f9d | ||
|
|
9cf2472291 | ||
|
|
cf5b976659 | ||
|
|
70394e79ef | ||
|
|
ea64f3122e | ||
|
|
50197fc33e | ||
|
|
c480fa7fcf | ||
|
|
6fc734da51 | ||
|
|
762a11b0bb | ||
|
|
f168dd69a8 | ||
|
|
becd0b8883 | ||
|
|
fd4570625a | ||
|
|
54a5b58e40 | ||
|
|
a611278e04 | ||
|
|
5c2eb0a68c | ||
|
|
0cbf4d5d4d |
20
.devcontainer/Dockerfile
Normal file
20
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# 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,3 +3,14 @@
|
||||
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.
|
||||
|
||||
62
.devcontainer/bashrc-additions
Normal file
62
.devcontainer/bashrc-additions
Normal file
@@ -0,0 +1,62 @@
|
||||
# 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 ""
|
||||
20
.devcontainer/build-and-push-image.sh
Executable file
20
.devcontainer/build-and-push-image.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/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,8 +1,15 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// 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",
|
||||
// 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"]
|
||||
},
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
@@ -32,10 +39,17 @@
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
|
||||
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.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
|
||||
},
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
|
||||
@@ -3,30 +3,76 @@
|
||||
|
||||
echo "🔧 Setting up Superset development environment..."
|
||||
|
||||
# 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
|
||||
# System dependencies and uv are now pre-installed in the Docker image
|
||||
# This speeds up Codespace creation significantly!
|
||||
|
||||
# Install uv for fast Python package management
|
||||
echo "📦 Installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# Create virtual environment using uv
|
||||
echo "🐍 Creating Python virtual environment..."
|
||||
if ! uv venv; then
|
||||
echo "❌ Failed to create virtual environment"
|
||||
exit 1
|
||||
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 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
|
||||
|
||||
# Install Claude Code CLI via npm
|
||||
echo "🤖 Installing Claude Code..."
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
if npm install -g @anthropic-ai/claude-code; then
|
||||
echo "✅ Claude Code installed"
|
||||
else
|
||||
echo "⚠️ Claude Code installation failed (non-critical)"
|
||||
fi
|
||||
|
||||
# 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 "🚀 Run '.devcontainer/start-superset.sh' to start Superset"
|
||||
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 ""
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/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"
|
||||
|
||||
@@ -13,10 +18,37 @@ else
|
||||
echo "📁 Using current directory: $(pwd)"
|
||||
fi
|
||||
|
||||
# Check if docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
sleep 5
|
||||
# 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
|
||||
fi
|
||||
|
||||
# Clean up any existing containers
|
||||
@@ -24,16 +56,33 @@ echo "🧹 Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose-light.yml down
|
||||
|
||||
# Start services
|
||||
echo "🏗️ Building and starting services..."
|
||||
echo "🏗️ Starting Superset in background (daemon mode)..."
|
||||
echo ""
|
||||
echo "📝 Once started, login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin"
|
||||
echo ""
|
||||
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
|
||||
|
||||
# Run docker-compose and capture exit code
|
||||
docker-compose -f docker-compose-light.yml up
|
||||
# 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
|
||||
EXIT_CODE=$?
|
||||
|
||||
# If it failed, provide helpful instructions
|
||||
|
||||
4
.github/workflows/superset-docs-deploy.yml
vendored
4
.github/workflows/superset-docs-deploy.yml
vendored
@@ -47,10 +47,12 @@ jobs:
|
||||
java-version: '21'
|
||||
- name: Install Graphviz
|
||||
run: sudo apt-get install -y graphviz
|
||||
- name: Compute Entity Relationship diagram (ERD)
|
||||
- name: Generate documentation artifacts
|
||||
env:
|
||||
SUPERSET_SECRET_KEY: not-a-secret
|
||||
CI: true
|
||||
run: |
|
||||
# Generate ERD
|
||||
python scripts/erd/erd.py
|
||||
curl -L http://sourceforge.net/projects/plantuml/files/1.2023.7/plantuml.1.2023.7.jar/download > ~/plantuml.jar
|
||||
java -jar ~/plantuml.jar -v -tsvg -r -o "${{ github.workspace }}/docs/static/img/" "${{ github.workspace }}/scripts/erd/erd.puml"
|
||||
|
||||
5
.github/workflows/superset-docs-verify.yml
vendored
5
.github/workflows/superset-docs-verify.yml
vendored
@@ -64,6 +64,11 @@ jobs:
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: './docs/.nvmrc'
|
||||
- name: Setup Python Backend
|
||||
uses: ./.github/actions/setup-backend
|
||||
with:
|
||||
python-version: 'current'
|
||||
requirements-type: 'base'
|
||||
- name: yarn install
|
||||
run: |
|
||||
yarn install --check-cache
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -131,3 +131,6 @@ superset/static/stats/statistics.html
|
||||
# LLM-related
|
||||
CLAUDE.local.md
|
||||
.aider*
|
||||
|
||||
# Temporary scratchpad for development
|
||||
.scratchpad/
|
||||
|
||||
@@ -23,25 +23,57 @@ MIN_MEM_FREE_GB=3
|
||||
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
|
||||
|
||||
echo_mem_warn() {
|
||||
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 if running in Codespaces first
|
||||
if [[ -n "${CODESPACES}" ]]; then
|
||||
echo "Memory available: Codespaces managed"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||
# 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
|
||||
cat <<EOF
|
||||
===============================================
|
||||
======== Memory Insufficient Warning =========
|
||||
===============================================
|
||||
|
||||
It looks like you only have ${MEM_FREE_GB}GB of
|
||||
memory free. Please increase your Docker
|
||||
It looks like you only have ${MEM_AVAIL_GB}GB of
|
||||
memory ${MEM_TYPE}. 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 check Ok [${MEM_FREE_GB}GB free]"
|
||||
echo "Memory available: ${MEM_AVAIL_GB} GB"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
86
docs/docs/configuration/configuration-reference.mdx
Normal file
86
docs/docs/configuration/configuration-reference.mdx
Normal file
@@ -0,0 +1,86 @@
|
||||
---
|
||||
title: Configuration Reference
|
||||
hide_title: true
|
||||
sidebar_position: 1
|
||||
version: 1
|
||||
---
|
||||
|
||||
import ConfigurationTable from '@site/src/components/ConfigurationTable';
|
||||
|
||||
# Configuration Reference
|
||||
|
||||
This page provides a comprehensive reference for all Superset configuration options. These settings can be configured in your `superset_config.py` file or through environment variables.
|
||||
|
||||
## How to Use This Reference
|
||||
|
||||
- **Search**: Use the search box to find specific configuration settings
|
||||
- **Filter by Category**: Use the dropdown to filter by configuration category
|
||||
- **Environment Variables**: All configurations can be set via environment variables with the `SUPERSET__` prefix
|
||||
- **Impact Level**: Each setting shows its impact level (low, medium, high)
|
||||
- **Restart Required**: Settings marked with "RESTART" require a server restart to take effect
|
||||
|
||||
## Configuration Settings
|
||||
|
||||
<ConfigurationTable showEnvironmentVariables={true} />
|
||||
|
||||
## Setting Configuration Values
|
||||
|
||||
### In superset_config.py
|
||||
|
||||
```python
|
||||
# Example configuration in superset_config.py
|
||||
SECRET_KEY = 'your-secret-key-here'
|
||||
SQLALCHEMY_DATABASE_URI = 'postgresql://user:pass@localhost/superset'
|
||||
CACHE_DEFAULT_TIMEOUT = 300
|
||||
```
|
||||
|
||||
### Via Environment Variables
|
||||
|
||||
```bash
|
||||
# All configuration keys can be set via environment variables
|
||||
export SUPERSET__SECRET_KEY="your-secret-key-here"
|
||||
export SUPERSET__SQLALCHEMY_DATABASE_URI="postgresql://user:pass@localhost/superset"
|
||||
export SUPERSET__CACHE_DEFAULT_TIMEOUT=300
|
||||
```
|
||||
|
||||
### Configuration Precedence
|
||||
|
||||
Configuration values are loaded in the following order (later values override earlier ones):
|
||||
|
||||
1. **Default values** from `superset/config_defaults.py`
|
||||
2. **Base configuration** from `superset/config.py`
|
||||
3. **Custom configuration file** (if specified via `SUPERSET_CONFIG_PATH`)
|
||||
4. **superset_config module** (if available in PYTHONPATH)
|
||||
5. **Environment variables** with `SUPERSET__` prefix
|
||||
|
||||
## Configuration Categories
|
||||
|
||||
The configuration settings are organized into the following categories:
|
||||
|
||||
- **Security**: Authentication, authorization, and security-related settings
|
||||
- **Database**: Database connection and SQL-related configurations
|
||||
- **Performance**: Caching, timeouts, and performance optimization settings
|
||||
- **Features**: Feature flags and optional functionality toggles
|
||||
- **UI**: User interface and theming configurations
|
||||
- **Logging**: Logging and monitoring configurations
|
||||
- **Email**: Email and notification settings
|
||||
- **Async**: Asynchronous processing and Celery settings
|
||||
- **General**: Miscellaneous configuration options
|
||||
|
||||
## Important Security Notes
|
||||
|
||||
- Always set a strong `SECRET_KEY` in production
|
||||
- Use environment variables for sensitive configuration values
|
||||
- Never commit sensitive configuration values to version control
|
||||
- Regularly rotate secrets and passwords
|
||||
- Review security-related configurations before deploying
|
||||
|
||||
## Need Help?
|
||||
|
||||
For detailed information about specific configuration topics, see:
|
||||
|
||||
- [Configuring Superset](./configuring-superset.mdx) - General configuration guide
|
||||
- [Security](../security/security.mdx) - Security configuration
|
||||
- [Database Configuration](./databases.mdx) - Database-specific settings
|
||||
- [Cache Configuration](./cache.mdx) - Caching setup
|
||||
- [Async Queries](./async-queries-celery.mdx) - Celery configuration
|
||||
@@ -1,19 +1,36 @@
|
||||
---
|
||||
title: Configuring Superset
|
||||
hide_title: true
|
||||
sidebar_position: 1
|
||||
sidebar_position: 2
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Configuring Superset
|
||||
|
||||
## superset_config.py
|
||||
## Configuration Overview
|
||||
|
||||
Superset provides a flexible, multi-layered configuration system that supports:
|
||||
|
||||
1. **File-based configuration** - Traditional Python configuration files
|
||||
2. **Environment variables** - For containerized deployments and CI/CD
|
||||
3. **Database-backed settings** - Runtime configuration changes (coming soon)
|
||||
4. **Structured metadata** - Rich documentation and validation schemas
|
||||
|
||||
### Configuration Priority
|
||||
|
||||
Configuration values are loaded in the following order (later values override earlier ones):
|
||||
|
||||
1. **Default configuration** - Built-in defaults from `superset/config_defaults.py`
|
||||
2. **Environment variables** - Values prefixed with `SUPERSET__`
|
||||
3. **User configuration file** - Your custom `superset_config.py` file
|
||||
|
||||
### superset_config.py
|
||||
|
||||
Superset exposes hundreds of configurable parameters through its
|
||||
[config.py module](https://github.com/apache/superset/blob/master/superset/config.py). The
|
||||
[config_defaults.py module](https://github.com/apache/superset/blob/master/superset/config_defaults.py). The
|
||||
variables and objects exposed act as a public interface of the bulk of what you may want
|
||||
to configure, alter and interface with. In this python module, you'll find all these
|
||||
parameters, sensible defaults, as well as rich documentation in the form of comments
|
||||
parameters, sensible defaults, as well as structured metadata documentation.
|
||||
|
||||
To configure your application, you need to create your own configuration module, which
|
||||
will allow you to override few or many of these parameters. Instead of altering the core module,
|
||||
@@ -77,12 +94,12 @@ MAPBOX_API_KEY = ''
|
||||
|
||||
:::tip
|
||||
Note that it is typical to copy and paste [only] the portions of the
|
||||
core [superset/config.py](https://github.com/apache/superset/blob/master/superset/config.py) that
|
||||
core [superset/config_defaults.py](https://github.com/apache/superset/blob/master/superset/config_defaults.py) that
|
||||
you want to alter, along with the related comments into your own `superset_config.py` file.
|
||||
:::
|
||||
|
||||
All the parameters and default values defined
|
||||
in [superset/config.py](https://github.com/apache/superset/blob/master/superset/config.py)
|
||||
in [superset/config_defaults.py](https://github.com/apache/superset/blob/master/superset/config_defaults.py)
|
||||
can be altered in your local `superset_config.py`. Administrators will want to read through the file
|
||||
to understand what can be configured locally as well as the default values in place.
|
||||
|
||||
@@ -97,6 +114,102 @@ for more information on how to configure it.
|
||||
|
||||
At the very least, you'll want to change `SECRET_KEY` and `SQLALCHEMY_DATABASE_URI`. Continue reading for more about each of these.
|
||||
|
||||
## Environment Variables Configuration
|
||||
|
||||
For containerized deployments and CI/CD pipelines, Superset supports configuration through environment variables. This is particularly useful for:
|
||||
|
||||
- **Docker deployments** - Configure containers without rebuilding images
|
||||
- **Kubernetes environments** - Use ConfigMaps and Secrets
|
||||
- **CI/CD pipelines** - Set configuration dynamically based on environment
|
||||
- **Development workflows** - Override settings locally without changing files
|
||||
|
||||
### Environment Variable Format
|
||||
|
||||
All Superset environment variables must use the `SUPERSET__` prefix (note the double underscore):
|
||||
|
||||
```bash
|
||||
# Basic settings
|
||||
export SUPERSET__ROW_LIMIT=100000
|
||||
export SUPERSET__SQLLAB_TIMEOUT=60
|
||||
|
||||
# Database configuration
|
||||
export SUPERSET__SQLALCHEMY_DATABASE_URI="postgresql://user:pass@localhost/superset"
|
||||
|
||||
# Secret key
|
||||
export SUPERSET__SECRET_KEY="your-secret-key-here"
|
||||
```
|
||||
|
||||
### JSON and Complex Values
|
||||
|
||||
Environment variables automatically parse JSON values for complex configuration:
|
||||
|
||||
```bash
|
||||
# Feature flags as JSON
|
||||
export SUPERSET__FEATURE_FLAGS='{"ENABLE_TEMPLATE_PROCESSING": true, "ENABLE_EXPLORE_DRAG_AND_DROP": true}'
|
||||
|
||||
# Database configuration
|
||||
export SUPERSET__DATABASE_CONFIG='{"timeout": 60, "pool_size": 10}'
|
||||
```
|
||||
|
||||
### Nested Configuration
|
||||
|
||||
For nested configuration objects, use triple underscores (`___`) to separate levels:
|
||||
|
||||
```bash
|
||||
# This sets FEATURE_FLAGS["ENABLE_TEMPLATE_PROCESSING"] = true
|
||||
export SUPERSET__FEATURE_FLAGS__ENABLE_TEMPLATE_PROCESSING=true
|
||||
|
||||
# This sets THEME_DEFAULT["token"]["colorPrimary"] = "#ff0000"
|
||||
export SUPERSET__THEME_DEFAULT__token__colorPrimary="#ff0000"
|
||||
```
|
||||
|
||||
### Environment Variable Examples
|
||||
|
||||
You can view examples of environment variable configuration:
|
||||
|
||||
```bash
|
||||
# Show all available environment variable examples
|
||||
superset config env-examples
|
||||
|
||||
# Show current configuration and sources
|
||||
superset config show --verbose
|
||||
|
||||
# Get a specific configuration value
|
||||
superset config get ROW_LIMIT
|
||||
```
|
||||
|
||||
### Docker Environment Variables
|
||||
|
||||
When using Docker, you can set environment variables in your `docker-compose.yml`:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
superset:
|
||||
image: apache/superset:latest
|
||||
environment:
|
||||
- SUPERSET__ROW_LIMIT=100000
|
||||
- SUPERSET__SQLLAB_TIMEOUT=60
|
||||
- SUPERSET__FEATURE_FLAGS__ENABLE_TEMPLATE_PROCESSING=true
|
||||
# ... other configuration
|
||||
```
|
||||
|
||||
Or use an environment file:
|
||||
|
||||
```bash
|
||||
# .env file
|
||||
SUPERSET__ROW_LIMIT=100000
|
||||
SUPERSET__SQLLAB_TIMEOUT=60
|
||||
SUPERSET__SECRET_KEY=your-secret-key-here
|
||||
```
|
||||
|
||||
```yaml
|
||||
services:
|
||||
superset:
|
||||
image: apache/superset:latest
|
||||
env_file:
|
||||
- .env
|
||||
```
|
||||
|
||||
## Specifying a SECRET_KEY
|
||||
|
||||
### Adding an initial SECRET_KEY
|
||||
@@ -546,3 +659,93 @@ FEATURE_FLAGS = {
|
||||
```
|
||||
|
||||
A current list of feature flags can be found in [RESOURCES/FEATURE_FLAGS.md](https://github.com/apache/superset/blob/master/RESOURCES/FEATURE_FLAGS.md).
|
||||
|
||||
## Configuration Introspection
|
||||
|
||||
Superset provides CLI commands to inspect and understand your current configuration:
|
||||
|
||||
### View Current Configuration
|
||||
|
||||
```bash
|
||||
# Show all configuration as YAML
|
||||
superset config show
|
||||
|
||||
# Filter configuration by pattern
|
||||
superset config show --filter "ROW_LIMIT"
|
||||
|
||||
# Show configuration with sources (where each value comes from)
|
||||
superset config show --verbose
|
||||
```
|
||||
|
||||
### Get Specific Configuration Values
|
||||
|
||||
```bash
|
||||
# Get a specific configuration value with source information
|
||||
superset config get ROW_LIMIT
|
||||
|
||||
# Output shows both the value and where it came from:
|
||||
# ROW_LIMIT:
|
||||
# source: environment (SUPERSET__ROW_LIMIT)
|
||||
# value: 100000
|
||||
```
|
||||
|
||||
### Environment Variable Examples
|
||||
|
||||
```bash
|
||||
# Show examples of environment variables for all documented settings
|
||||
superset config env-examples
|
||||
|
||||
# This shows:
|
||||
# - Basic environment variable syntax
|
||||
# - JSON formatting examples
|
||||
# - Nested configuration examples
|
||||
# - All documented settings with their metadata
|
||||
```
|
||||
|
||||
### Configuration Sources
|
||||
|
||||
The CLI will show you where each configuration value comes from:
|
||||
|
||||
- **`environment (SUPERSET__KEY)`** - Value set via environment variable
|
||||
- **`superset_config.py`** - Value set in your custom configuration file
|
||||
- **`config_defaults.py`** - Default value from Superset's built-in configuration
|
||||
|
||||
This helps you understand the configuration precedence and troubleshoot configuration issues.
|
||||
|
||||
## Configuration Reference
|
||||
|
||||
The following table shows all documented configuration settings with their metadata:
|
||||
|
||||
import ConfigurationTable from '@site/src/components/ConfigurationTable';
|
||||
|
||||
<ConfigurationTable showEnvironmentVariables={true} />
|
||||
|
||||
## Environment Variables Examples
|
||||
|
||||
Here are ready-to-use environment variable examples:
|
||||
|
||||
import EnvironmentVariablesExample from '@site/src/components/EnvironmentVariablesExample';
|
||||
|
||||
<EnvironmentVariablesExample />
|
||||
|
||||
## Configuration Metadata and Documentation
|
||||
|
||||
Superset's configuration system includes rich metadata for many settings, providing:
|
||||
|
||||
- **Type information** - Whether a setting expects an integer, string, boolean, or object
|
||||
- **Validation rules** - Minimum/maximum values, allowed options
|
||||
- **Documentation** - Detailed descriptions of what each setting does
|
||||
- **Impact levels** - How significant changes to this setting are
|
||||
- **Restart requirements** - Whether changing this setting requires a restart
|
||||
|
||||
This metadata is used for:
|
||||
- **CLI documentation** - The `superset config env-examples` command shows this information
|
||||
- **Future admin UI** - Settings management interface (coming soon)
|
||||
- **Validation** - Ensuring configuration values are valid
|
||||
- **API documentation** - Automatic generation of configuration schemas
|
||||
|
||||
You can also access this information via CLI:
|
||||
|
||||
```bash
|
||||
superset config env-examples
|
||||
```
|
||||
|
||||
@@ -87,8 +87,66 @@ 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=codespaces&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
|
||||
|
||||
:::caution
|
||||
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||
@@ -421,14 +421,6 @@ 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.
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
"scripts": {
|
||||
"docusaurus": "docusaurus",
|
||||
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
|
||||
"start": "yarn run _init && docusaurus start",
|
||||
"build": "yarn run _init && DEBUG=docusaurus:* docusaurus build",
|
||||
"_update-config": "bash scripts/generate_docs.sh",
|
||||
"start": "yarn run _init && yarn run _update-config && docusaurus start",
|
||||
"build": "yarn run _init && yarn run _update-config && DEBUG=docusaurus:* docusaurus build",
|
||||
"swizzle": "docusaurus swizzle",
|
||||
"deploy": "docusaurus deploy",
|
||||
"clear": "docusaurus clear",
|
||||
@@ -26,6 +27,8 @@
|
||||
"@emotion/styled": "^10.0.27",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@superset-ui/style": "^0.14.23",
|
||||
"ag-grid-community": "^34.1.0",
|
||||
"ag-grid-react": "^34.1.0",
|
||||
"antd": "^5.26.3",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"less": "^4.3.0",
|
||||
@@ -34,6 +37,7 @@
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-github-btn": "^1.4.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-svg-pan-zoom": "^3.13.1",
|
||||
"swagger-ui-react": "^5.26.0"
|
||||
},
|
||||
|
||||
116
docs/scripts/export_config_metadata.py
Normal file
116
docs/scripts/export_config_metadata.py
Normal file
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export configuration metadata to JSON for documentation generation.
|
||||
|
||||
This script loads configuration metadata from the Python metadata module
|
||||
and exports it in JSON format for the documentation React components.
|
||||
|
||||
This script is called by docs/scripts/generate_docs.sh as part of the
|
||||
unified documentation generation process.
|
||||
"""
|
||||
|
||||
import json as json_module
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Add the superset directory to Python path
|
||||
superset_root = Path(__file__).parent.parent.parent
|
||||
sys.path.insert(0, str(superset_root))
|
||||
|
||||
|
||||
def infer_impact(key: str) -> str:
|
||||
"""Infer the impact level based on the configuration key name."""
|
||||
name_lower = key.lower()
|
||||
|
||||
# High impact - security, database, core functionality
|
||||
if any(
|
||||
term in name_lower
|
||||
for term in [
|
||||
"secret",
|
||||
"key",
|
||||
"password",
|
||||
"database",
|
||||
"uri",
|
||||
"url",
|
||||
"security",
|
||||
"auth",
|
||||
]
|
||||
):
|
||||
return "high"
|
||||
|
||||
# Medium impact - performance, features, UI
|
||||
elif any(
|
||||
term in name_lower
|
||||
for term in ["limit", "timeout", "cache", "feature", "flag", "theme"]
|
||||
):
|
||||
return "medium"
|
||||
|
||||
# Low impact - logging, debugging, minor settings
|
||||
else:
|
||||
return "low"
|
||||
|
||||
|
||||
def infer_requires_restart(key: str) -> bool:
|
||||
"""Infer if the configuration requires a restart based on the key name."""
|
||||
name_lower = key.lower()
|
||||
|
||||
# These typically require restart
|
||||
if any(
|
||||
term in name_lower
|
||||
for term in [
|
||||
"secret",
|
||||
"key",
|
||||
"database",
|
||||
"uri",
|
||||
"url",
|
||||
"security",
|
||||
"auth",
|
||||
"ssl",
|
||||
"tls",
|
||||
]
|
||||
):
|
||||
return True
|
||||
|
||||
# These typically don't require restart
|
||||
elif any(
|
||||
term in name_lower for term in ["limit", "timeout", "cache", "log", "debug"]
|
||||
):
|
||||
return False
|
||||
|
||||
# Default to requiring restart for safety
|
||||
return True
|
||||
|
||||
|
||||
def export_config_metadata() -> List[Dict[str, Any]]:
|
||||
"""Export configuration metadata as JSON."""
|
||||
try:
|
||||
# Import from Python metadata module
|
||||
from superset.config_metadata import export_for_documentation
|
||||
|
||||
# Get metadata from Python source
|
||||
metadata_export = export_for_documentation()
|
||||
|
||||
# Export as JSON for documentation
|
||||
output_dir = Path(__file__).parent.parent / "src" / "resources"
|
||||
output_dir.mkdir(exist_ok=True)
|
||||
|
||||
# Write the full export (includes categories, etc.)
|
||||
with open(output_dir / "config_metadata.json", "w") as f:
|
||||
json_module.dump(metadata_export, f, indent=2)
|
||||
|
||||
output_file = output_dir / "config_metadata.json"
|
||||
print(
|
||||
f"Exported {len(metadata_export['all_settings'])} configuration settings to {output_file}"
|
||||
)
|
||||
|
||||
return metadata_export["all_settings"]
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing config_metadata: {e}")
|
||||
print("Please ensure superset/config_metadata.py exists")
|
||||
return []
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
export_config_metadata()
|
||||
101
docs/scripts/generate_docs.sh
Executable file
101
docs/scripts/generate_docs.sh
Executable file
@@ -0,0 +1,101 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# 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.
|
||||
|
||||
# Unified documentation generation script
|
||||
# This script generates all dynamic documentation artifacts needed for the docs build
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 Generating documentation artifacts..."
|
||||
|
||||
# Navigate to the docs directory
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
# Track any failures
|
||||
FAILED_TASKS=()
|
||||
|
||||
# 1. Extract configuration schema and export metadata
|
||||
echo "📊 Extracting configuration schema and exporting metadata..."
|
||||
if python ../scripts/extract_config_schema.py && python scripts/export_config_metadata.py; then
|
||||
echo "✅ Configuration metadata exported successfully"
|
||||
else
|
||||
echo "⚠️ Warning: Failed to export configuration metadata"
|
||||
echo " The documentation build will continue with existing metadata"
|
||||
FAILED_TASKS+=("config_metadata")
|
||||
fi
|
||||
|
||||
# 2. Generate OpenAPI documentation
|
||||
echo "🔌 Generating OpenAPI documentation..."
|
||||
if python -c "
|
||||
import sys
|
||||
sys.path.insert(0, '..')
|
||||
from superset.app import create_app
|
||||
from superset.cli.update import update_api_docs
|
||||
import os
|
||||
|
||||
# Set required environment variables
|
||||
os.environ['SUPERSET_SECRET_KEY'] = 'not-a-secret'
|
||||
|
||||
app = create_app()
|
||||
with app.app_context():
|
||||
update_api_docs()
|
||||
"; then
|
||||
echo "✅ OpenAPI documentation generated successfully"
|
||||
else
|
||||
echo "⚠️ Warning: Failed to generate OpenAPI documentation"
|
||||
echo " The documentation build will continue with existing OpenAPI spec"
|
||||
FAILED_TASKS+=("openapi")
|
||||
fi
|
||||
|
||||
# 3. Generate ERD (Entity Relationship Diagram) if in CI environment
|
||||
if [ -n "$CI" ] && [ -f "../scripts/erd/erd.py" ]; then
|
||||
echo "🗂️ Generating Entity Relationship Diagram..."
|
||||
if python ../scripts/erd/erd.py; then
|
||||
echo "✅ ERD generated successfully"
|
||||
else
|
||||
echo "⚠️ Warning: Failed to generate ERD"
|
||||
echo " The documentation build will continue without updated ERD"
|
||||
FAILED_TASKS+=("erd")
|
||||
fi
|
||||
fi
|
||||
|
||||
# Summary
|
||||
echo ""
|
||||
echo "📝 Documentation generation summary:"
|
||||
echo " - Configuration metadata: ${FAILED_TASKS[*]}" | grep -q "config_metadata" && echo " - Configuration metadata: ❌ Failed" || echo " - Configuration metadata: ✅ Success"
|
||||
echo " - OpenAPI documentation: ${FAILED_TASKS[*]}" | grep -q "openapi" && echo " - OpenAPI documentation: ❌ Failed" || echo " - OpenAPI documentation: ✅ Success"
|
||||
if [ -n "$CI" ]; then
|
||||
echo " - ERD generation: ${FAILED_TASKS[*]}" | grep -q "erd" && echo " - ERD generation: ❌ Failed" || echo " - ERD generation: ✅ Success"
|
||||
fi
|
||||
|
||||
if [ ${#FAILED_TASKS[@]} -eq 0 ]; then
|
||||
echo ""
|
||||
echo "🎉 All documentation artifacts generated successfully!"
|
||||
else
|
||||
echo ""
|
||||
echo "⚠️ Some tasks failed but documentation build can continue"
|
||||
echo " Failed tasks: ${FAILED_TASKS[*]}"
|
||||
echo " To fix missing dependencies, run: pip install -e ."
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📁 Generated files:"
|
||||
[ -f "src/resources/config_metadata.json" ] && echo " - src/resources/config_metadata.json"
|
||||
[ -f "static/resources/openapi.json" ] && echo " - static/resources/openapi.json"
|
||||
[ -f "static/img/erd.svg" ] && echo " - static/img/erd.svg"
|
||||
329
docs/src/components/ConfigurationTable.tsx
Normal file
329
docs/src/components/ConfigurationTable.tsx
Normal file
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 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 React, { useState, useMemo, useCallback } from 'react';
|
||||
import { AgGridReact } from 'ag-grid-react';
|
||||
import { ColDef, GridReadyEvent, GridApi, ModuleRegistry, AllCommunityModule } from 'ag-grid-community';
|
||||
import 'ag-grid-community/styles/ag-grid.css';
|
||||
import 'ag-grid-community/styles/ag-theme-material.css';
|
||||
import configMetadata from '../resources/config_metadata.json';
|
||||
|
||||
// Register AG Grid modules
|
||||
ModuleRegistry.registerModules([AllCommunityModule]);
|
||||
|
||||
// ConfigSetting interface is defined for type safety but not directly used
|
||||
// as AG Grid uses dynamic property access
|
||||
// interface ConfigSetting {
|
||||
// key: string;
|
||||
// title: string;
|
||||
// description: string;
|
||||
// details: string;
|
||||
// type: string;
|
||||
// category: string;
|
||||
// group: string;
|
||||
// default: any;
|
||||
// env_var: string;
|
||||
// external: boolean;
|
||||
// source: string;
|
||||
// supports_callable: boolean;
|
||||
// }
|
||||
|
||||
interface ConfigurationTableProps {
|
||||
category?: string;
|
||||
showEnvironmentVariables?: boolean;
|
||||
}
|
||||
|
||||
// Custom cell renderers
|
||||
|
||||
const KeyCellRenderer = (props: { value: string }) => {
|
||||
return <span style={{ fontWeight: 'bold' }}>{props.value}</span>;
|
||||
};
|
||||
|
||||
const TypeCellRenderer = (props: { value: string }) => {
|
||||
return <code>{props.value}</code>;
|
||||
};
|
||||
|
||||
const DefaultCellRenderer = (props: { value: unknown }) => {
|
||||
const formatDefault = (value: unknown): string => {
|
||||
if (value === null || value === undefined || value === 'None') return 'None';
|
||||
if (typeof value === 'object') {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const formatted = formatDefault(props.value);
|
||||
const isLong = formatted.length > 50;
|
||||
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
whiteSpace: isLong ? 'pre-wrap' : 'nowrap',
|
||||
wordBreak: isLong ? 'break-all' : 'normal',
|
||||
}}
|
||||
title={isLong ? formatted : undefined}
|
||||
>
|
||||
{isLong ? formatted.substring(0, 50) + '...' : formatted}
|
||||
</code>
|
||||
);
|
||||
};
|
||||
|
||||
const BooleanCellRenderer = (props: { value: boolean }) => {
|
||||
return props.value ? '✅ Yes' : '❌ No';
|
||||
};
|
||||
|
||||
const GroupCellRenderer = (props: { value: string | null }) => {
|
||||
if (!props.value) return null;
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: '#f0f0f0',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '0.9em',
|
||||
}}
|
||||
>
|
||||
{props.value}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const DescriptionCellRenderer = (props: { value: string; data: { details?: string } }) => {
|
||||
const hasDetails = props.data.details && props.data.details.trim() !== '';
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
<span>{props.value || 'No description available'}</span>
|
||||
{hasDetails && (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '16px',
|
||||
height: '16px',
|
||||
backgroundColor: '#e8e8e8',
|
||||
color: '#666',
|
||||
borderRadius: '50%',
|
||||
fontSize: '0.8em',
|
||||
fontWeight: 'bold',
|
||||
cursor: 'help',
|
||||
flexShrink: 0,
|
||||
border: '1px solid #d0d0d0',
|
||||
}}
|
||||
title={props.data.details}
|
||||
>
|
||||
i
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ConfigurationTable: React.FC<ConfigurationTableProps> = ({
|
||||
category, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
showEnvironmentVariables = false,
|
||||
}) => {
|
||||
const [gridApi, setGridApi] = useState<GridApi | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
|
||||
// Process data to include only enriched configs
|
||||
const rowData = useMemo(() => {
|
||||
return configMetadata.all_settings;
|
||||
}, []);
|
||||
|
||||
// Column definitions
|
||||
const columnDefs = useMemo<ColDef[]>(() => {
|
||||
const columns: ColDef[] = [
|
||||
{
|
||||
field: 'key',
|
||||
headerName: 'Configuration Key',
|
||||
cellRenderer: KeyCellRenderer,
|
||||
width: 280,
|
||||
pinned: 'left',
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
headerName: 'Description',
|
||||
cellRenderer: DescriptionCellRenderer,
|
||||
flex: 2,
|
||||
minWidth: 300,
|
||||
wrapText: true,
|
||||
autoHeight: true,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
field: 'type',
|
||||
headerName: 'Type',
|
||||
cellRenderer: TypeCellRenderer,
|
||||
width: 120,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'default',
|
||||
headerName: 'Default',
|
||||
cellRenderer: DefaultCellRenderer,
|
||||
width: 200,
|
||||
filter: 'agTextColumnFilter',
|
||||
},
|
||||
{
|
||||
field: 'category',
|
||||
headerName: 'Category',
|
||||
width: 120,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
{
|
||||
field: 'group',
|
||||
headerName: 'Group',
|
||||
cellRenderer: GroupCellRenderer,
|
||||
width: 180,
|
||||
filter: 'agTextColumnFilter',
|
||||
floatingFilter: true,
|
||||
},
|
||||
];
|
||||
|
||||
if (showEnvironmentVariables) {
|
||||
columns.push({
|
||||
field: 'env_var',
|
||||
headerName: 'Environment Variable',
|
||||
width: 250,
|
||||
filter: 'agTextColumnFilter',
|
||||
cellRenderer: (props: { value: string }) => (
|
||||
<code>{props.value}</code>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
columns.push(
|
||||
{
|
||||
field: 'external',
|
||||
headerName: 'External',
|
||||
cellRenderer: BooleanCellRenderer,
|
||||
width: 100,
|
||||
filter: true,
|
||||
},
|
||||
);
|
||||
|
||||
return columns;
|
||||
}, [showEnvironmentVariables]);
|
||||
|
||||
const defaultColDef = useMemo<ColDef>(() => ({
|
||||
sortable: true,
|
||||
resizable: true,
|
||||
}), []);
|
||||
|
||||
const onGridReady = useCallback((params: GridReadyEvent) => {
|
||||
setGridApi(params.api);
|
||||
}, []);
|
||||
|
||||
const onFilterTextBoxChanged = useCallback(() => {
|
||||
if (gridApi) {
|
||||
gridApi.setGridOption('quickFilterText', searchText);
|
||||
}
|
||||
}, [gridApi, searchText]);
|
||||
|
||||
const exportToCsv = useCallback(() => {
|
||||
if (gridApi) {
|
||||
gridApi.exportDataAsCsv({
|
||||
fileName: 'superset_configuration.csv',
|
||||
});
|
||||
}
|
||||
}, [gridApi]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', height: '800px' }}>
|
||||
{/* Controls */}
|
||||
<div style={{ marginBottom: '20px', display: 'flex', gap: '15px', alignItems: 'center' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Quick filter across all columns..."
|
||||
value={searchText}
|
||||
onChange={(e) => {
|
||||
setSearchText(e.target.value);
|
||||
onFilterTextBoxChanged();
|
||||
}}
|
||||
style={{
|
||||
padding: '8px 12px',
|
||||
border: '1px solid #ddd',
|
||||
borderRadius: '4px',
|
||||
width: '100%',
|
||||
maxWidth: '400px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={exportToCsv}
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
backgroundColor: '#1890ff',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
Export to CSV
|
||||
</button>
|
||||
|
||||
<div style={{ color: '#666' }}>
|
||||
{rowData.length} configurations
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AG Grid */}
|
||||
<div className="ag-theme-material" style={{ height: '100%', width: '100%' }}>
|
||||
<AgGridReact
|
||||
rowData={rowData}
|
||||
columnDefs={columnDefs}
|
||||
defaultColDef={defaultColDef}
|
||||
onGridReady={onGridReady}
|
||||
animateRows={true}
|
||||
enableCellTextSelection={true}
|
||||
ensureDomOrder={true}
|
||||
tooltipShowDelay={500}
|
||||
pagination={true}
|
||||
paginationPageSize={50}
|
||||
paginationPageSizeSelector={[20, 50, 100, 200]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Help text */}
|
||||
<div style={{ marginTop: '15px', color: '#666' }}>
|
||||
<p>
|
||||
<strong>Tips:</strong> Click column headers to sort. Use the filter row below headers for column-specific filtering.
|
||||
Hold Shift to sort by multiple columns. Right-click headers for more options.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default ConfigurationTable;
|
||||
181
docs/src/components/EnvironmentVariablesExample.tsx
Normal file
181
docs/src/components/EnvironmentVariablesExample.tsx
Normal file
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* 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 React, { useState } from 'react';
|
||||
import configMetadata from '../resources/config_metadata.json';
|
||||
|
||||
interface EnvironmentVariablesExampleProps {
|
||||
category?: string;
|
||||
}
|
||||
|
||||
const EnvironmentVariablesExample: React.FC<
|
||||
EnvironmentVariablesExampleProps
|
||||
> = ({ category }) => {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
|
||||
// Get settings based on category
|
||||
const getSettings = () => {
|
||||
if (category && configMetadata.by_category[category]) {
|
||||
return configMetadata.by_category[category];
|
||||
}
|
||||
return configMetadata.all_settings;
|
||||
};
|
||||
|
||||
const settings = getSettings();
|
||||
const displaySettings = showAll ? settings : settings.slice(0, 5);
|
||||
|
||||
const formatDefaultForEnv = (value: unknown): string => {
|
||||
if (value === null || value === undefined) return '""';
|
||||
if (typeof value === 'object') {
|
||||
return `'${JSON.stringify(value)}'`;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return `"${value}"`;
|
||||
}
|
||||
return String(value);
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
const generateEnvExample = (setting: { default: unknown; env_var: string }): string => {
|
||||
const example = formatDefaultForEnv(setting.default);
|
||||
return `export ${setting.env_var}=${example}`;
|
||||
};
|
||||
|
||||
const generateAllEnvVars = (): string => {
|
||||
return [
|
||||
'# Superset Configuration Environment Variables',
|
||||
'# Generated from configuration metadata',
|
||||
'',
|
||||
...displaySettings.map(setting =>
|
||||
[
|
||||
`# ${setting.title}`,
|
||||
`# ${setting.description}`,
|
||||
`# Type: ${setting.type}`,
|
||||
`# Impact: ${setting.impact}${
|
||||
setting.requires_restart ? ' (requires restart)' : ''
|
||||
}`,
|
||||
generateEnvExample(setting),
|
||||
'',
|
||||
].join('\n'),
|
||||
),
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ margin: '20px 0' }}>
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: '#f6f8fa',
|
||||
border: '1px solid #e1e4e8',
|
||||
borderRadius: '6px',
|
||||
padding: '16px',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '10px',
|
||||
}}
|
||||
>
|
||||
<h4 style={{ margin: 0, color: '#24292e' }}>
|
||||
Environment Variables {category && `(${category})`}
|
||||
</h4>
|
||||
<button
|
||||
onClick={() => copyToClipboard(generateAllEnvVars())}
|
||||
style={{
|
||||
backgroundColor: '#0366d6',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
title="Copy all environment variables"
|
||||
>
|
||||
📋 Copy All
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: '#f6f8fa',
|
||||
border: 'none',
|
||||
padding: '0',
|
||||
margin: '0',
|
||||
fontFamily:
|
||||
'SFMono-Regular, Consolas, "Liberation Mono", Menlo, monospace',
|
||||
fontSize: '12px',
|
||||
lineHeight: '1.45',
|
||||
overflow: 'auto',
|
||||
maxHeight: '400px',
|
||||
}}
|
||||
>
|
||||
<code>{generateAllEnvVars()}</code>
|
||||
</pre>
|
||||
|
||||
{!showAll && settings.length > 5 && (
|
||||
<div
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
marginTop: '10px',
|
||||
borderTop: '1px solid #e1e4e8',
|
||||
paddingTop: '10px',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: '1px solid #0366d6',
|
||||
color: '#0366d6',
|
||||
padding: '6px 12px',
|
||||
borderRadius: '4px',
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
Show all {settings.length} settings
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
marginTop: '10px',
|
||||
fontSize: '14px',
|
||||
color: '#586069',
|
||||
}}
|
||||
>
|
||||
<strong>Usage:</strong> Save to a <code>.env</code> file or export
|
||||
directly in your shell.
|
||||
{category && ` Showing ${settings.length} ${category} settings.`}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnvironmentVariablesExample;
|
||||
4938
docs/src/resources/config_metadata.json
Normal file
4938
docs/src/resources/config_metadata.json
Normal file
File diff suppressed because it is too large
Load Diff
2893
docs/static/resources/openapi.json
vendored
2893
docs/static/resources/openapi.json
vendored
File diff suppressed because it is too large
Load Diff
@@ -3993,6 +3993,26 @@ address@^1.0.1:
|
||||
resolved "https://registry.yarnpkg.com/address/-/address-1.2.2.tgz#2b5248dac5485a6390532c6a517fda2e3faac89e"
|
||||
integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA==
|
||||
|
||||
ag-charts-types@12.1.0:
|
||||
version "12.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ag-charts-types/-/ag-charts-types-12.1.0.tgz#75104b90e5f6ae01b7248ec3f6a8dabc65c3cbb6"
|
||||
integrity sha512-qeODwJ1EqKjpwEbp0mQ2wQ0arRNYaZo2BafdAGfcuOwjOBlagSwJvUg5MCvAYZ/W/mg2uEmt7jKMNfDy4ul4+Q==
|
||||
|
||||
ag-grid-community@34.1.0, ag-grid-community@^34.1.0:
|
||||
version "34.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ag-grid-community/-/ag-grid-community-34.1.0.tgz#6356562b3a544a50bbab6a3d0929029567bbd7bc"
|
||||
integrity sha512-3rZiOyyCGqSNqqTsrWafDVj1WfK43jfb53Ka5sqzdOG/yu6ySUFmdc0h/OuGLnkzwW5PC29coQwbS2rkb4c9dA==
|
||||
dependencies:
|
||||
ag-charts-types "12.1.0"
|
||||
|
||||
ag-grid-react@^34.1.0:
|
||||
version "34.1.0"
|
||||
resolved "https://registry.yarnpkg.com/ag-grid-react/-/ag-grid-react-34.1.0.tgz#9d89a75f5994a5187cbdf1f44132eb06c2741623"
|
||||
integrity sha512-CY1p4/JnvcwOt2HipmsqME9CWz7M21nb3OB1DhJGOvNUaxo1wF6Hb/pKpa20F3F/E93wQCNmCz+gfrSLPuJrQw==
|
||||
dependencies:
|
||||
ag-grid-community "34.1.0"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
aggregate-error@^3.0.0:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a"
|
||||
@@ -7220,6 +7240,11 @@ html-tags@^3.3.1:
|
||||
resolved "https://registry.yarnpkg.com/html-tags/-/html-tags-3.3.1.tgz#a04026a18c882e4bba8a01a3d39cfe465d40b5ce"
|
||||
integrity sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==
|
||||
|
||||
html-url-attributes@^3.0.0:
|
||||
version "3.0.1"
|
||||
resolved "https://registry.yarnpkg.com/html-url-attributes/-/html-url-attributes-3.0.1.tgz#83b052cd5e437071b756cd74ae70f708870c2d87"
|
||||
integrity sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==
|
||||
|
||||
html-void-elements@^3.0.0:
|
||||
version "3.0.0"
|
||||
resolved "https://registry.yarnpkg.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7"
|
||||
@@ -10887,6 +10912,23 @@ react-loadable-ssr-addon-v5-slorber@^1.0.1:
|
||||
dependencies:
|
||||
"@types/react" "*"
|
||||
|
||||
react-markdown@^10.1.0:
|
||||
version "10.1.0"
|
||||
resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-10.1.0.tgz#e22bc20faddbc07605c15284255653c0f3bad5ca"
|
||||
integrity sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==
|
||||
dependencies:
|
||||
"@types/hast" "^3.0.0"
|
||||
"@types/mdast" "^4.0.0"
|
||||
devlop "^1.0.0"
|
||||
hast-util-to-jsx-runtime "^2.0.0"
|
||||
html-url-attributes "^3.0.0"
|
||||
mdast-util-to-hast "^13.0.0"
|
||||
remark-parse "^11.0.0"
|
||||
remark-rehype "^11.0.0"
|
||||
unified "^11.0.0"
|
||||
unist-util-visit "^5.0.0"
|
||||
vfile "^6.0.0"
|
||||
|
||||
react-redux@^9.2.0:
|
||||
version "9.2.0"
|
||||
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.2.0.tgz#96c3ab23fb9a3af2cb4654be4b51c989e32366f5"
|
||||
|
||||
291
scripts/extract_config_schema.py
Executable file
291
scripts/extract_config_schema.py
Executable file
@@ -0,0 +1,291 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Extract configuration schema from config_defaults.py.
|
||||
|
||||
This script parses the existing config_defaults.py file and extracts:
|
||||
- All configuration keys and their default values
|
||||
- Comments above each key as descriptions
|
||||
- Types inferred from the default values
|
||||
|
||||
The output is a comprehensive JSON schema that can be used for:
|
||||
- Documentation generation
|
||||
- Configuration validation
|
||||
- IDE autocomplete
|
||||
"""
|
||||
|
||||
import ast
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List
|
||||
|
||||
# Import the complex object handlers
|
||||
sys.path.append(str(Path(__file__).parent.parent))
|
||||
try:
|
||||
from superset.config_objects import (
|
||||
get_default_for_complex_object,
|
||||
get_fully_qualified_type,
|
||||
get_object_import_info,
|
||||
is_complex_object,
|
||||
)
|
||||
except ImportError:
|
||||
# Fallback if import fails
|
||||
def get_default_for_complex_object(key: str) -> tuple[Any, str]:
|
||||
return f"<Complex object: {key}>", "unknown"
|
||||
|
||||
def is_complex_object(key: str) -> bool:
|
||||
return False
|
||||
|
||||
def get_fully_qualified_type(obj: Any) -> str:
|
||||
return type(obj).__name__
|
||||
|
||||
def get_object_import_info(obj: Any) -> dict[str, Any]:
|
||||
return {
|
||||
"module": None,
|
||||
"name": str(type(obj).__name__),
|
||||
"import_statement": None,
|
||||
}
|
||||
|
||||
|
||||
def infer_type(value: Any) -> str:
|
||||
"""Infer the configuration type from the default value."""
|
||||
if value is None:
|
||||
return "null"
|
||||
elif isinstance(value, bool):
|
||||
return "boolean"
|
||||
elif isinstance(value, int):
|
||||
return "integer"
|
||||
elif isinstance(value, float):
|
||||
return "number"
|
||||
elif isinstance(value, str):
|
||||
return "string"
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return "array"
|
||||
elif isinstance(value, dict):
|
||||
return "object"
|
||||
else:
|
||||
return "unknown"
|
||||
|
||||
|
||||
def extract_comments_before_line(lines: List[str], line_num: int) -> List[str]:
|
||||
"""Extract comments immediately before a configuration line."""
|
||||
comments: List[str] = []
|
||||
current_line = line_num - 2 # line_num is 1-based, so -2 to get previous line
|
||||
|
||||
# Look backwards for comments, but only go back a few lines to avoid
|
||||
# picking up unrelated comments
|
||||
max_lookback = min(5, current_line + 1)
|
||||
|
||||
for i in range(max_lookback):
|
||||
if current_line - i < 0:
|
||||
break
|
||||
|
||||
line = lines[current_line - i].strip()
|
||||
if line.startswith("#"):
|
||||
# Remove the '#' and clean up the comment
|
||||
comment = line[1:].strip()
|
||||
if comment: # Only add non-empty comments
|
||||
comments.insert(0, comment)
|
||||
elif line == "":
|
||||
# Empty line - continue looking
|
||||
continue
|
||||
else:
|
||||
# Non-comment, non-empty line - stop looking
|
||||
break
|
||||
|
||||
return comments
|
||||
|
||||
|
||||
def safe_eval(node: ast.AST) -> Any: # noqa: C901
|
||||
"""Safely evaluate an AST node to get its value."""
|
||||
try:
|
||||
# Handle basic constant values
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
elif isinstance(node, ast.Num): # Python < 3.8
|
||||
return node.n
|
||||
elif isinstance(node, ast.Str): # Python < 3.8
|
||||
return node.s
|
||||
elif isinstance(node, ast.List):
|
||||
return [safe_eval(item) for item in node.elts]
|
||||
elif isinstance(node, ast.Dict):
|
||||
return {
|
||||
safe_eval(k): safe_eval(v)
|
||||
for k, v in zip(node.keys, node.values, strict=False)
|
||||
if k is not None
|
||||
}
|
||||
elif isinstance(node, ast.Name):
|
||||
# Handle common constants
|
||||
if node.id in ("True", "False", "None"):
|
||||
return {"True": True, "False": False, "None": None}[node.id]
|
||||
else:
|
||||
return f"<{node.id}>" # Placeholder for variables
|
||||
elif isinstance(node, ast.Call):
|
||||
# Handle function calls - try to identify the function being called
|
||||
if isinstance(node.func, ast.Name):
|
||||
func_name = node.func.id
|
||||
if func_name in ("int", "float", "str", "bool"):
|
||||
# Handle type constructors
|
||||
if node.args:
|
||||
arg_val = safe_eval(node.args[0])
|
||||
if isinstance(arg_val, (int, float, str, bool)):
|
||||
try:
|
||||
return eval(func_name)(arg_val) # noqa: S307
|
||||
except Exception:
|
||||
return f"<{func_name}()>"
|
||||
return f"<{func_name}()>"
|
||||
elif func_name == "timedelta":
|
||||
# Handle timedelta calls
|
||||
return "<timedelta()>"
|
||||
else:
|
||||
return f"<{func_name}()>"
|
||||
elif isinstance(node.func, ast.Attribute):
|
||||
# Handle method calls like obj.method()
|
||||
method_name = (
|
||||
ast.unparse(node.func) if hasattr(ast, "unparse") else "method_call"
|
||||
)
|
||||
return f"<{method_name}()>"
|
||||
else:
|
||||
return "<function_call>"
|
||||
elif isinstance(node, ast.Attribute):
|
||||
# Handle attribute access like obj.attr
|
||||
try:
|
||||
attr_str = ast.unparse(node) if hasattr(ast, "unparse") else "attribute"
|
||||
return f"<{attr_str}>"
|
||||
except Exception:
|
||||
return "<attribute>"
|
||||
else:
|
||||
# For everything else, just return a descriptive placeholder
|
||||
return f"<{type(node).__name__}>"
|
||||
except Exception:
|
||||
return "<unknown>"
|
||||
|
||||
|
||||
def extract_config_schema(config_file: Path) -> Dict[str, Any]:
|
||||
"""Extract configuration schema from config_defaults.py."""
|
||||
with open(config_file, "r") as f:
|
||||
content = f.read()
|
||||
lines = content.splitlines()
|
||||
|
||||
# Parse the Python file
|
||||
tree = ast.parse(content)
|
||||
|
||||
schema = {}
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
# Check if this is a simple assignment to a variable
|
||||
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
||||
var_name = node.targets[0].id
|
||||
|
||||
# Only include uppercase variables (configuration convention)
|
||||
if var_name.isupper():
|
||||
# Get the default value
|
||||
default_value = safe_eval(node.value)
|
||||
|
||||
# Check if this is a complex object
|
||||
if is_complex_object(var_name):
|
||||
# Get the proper default value and type for complex objects
|
||||
default_value, type_name = get_default_for_complex_object(
|
||||
var_name
|
||||
)
|
||||
config_type = type_name
|
||||
else:
|
||||
# Infer type from default value
|
||||
config_type = infer_type(default_value)
|
||||
|
||||
# Get comments before this line
|
||||
comments = extract_comments_before_line(lines, node.lineno)
|
||||
description = " ".join(comments) if comments else ""
|
||||
|
||||
# Determine category based on variable name patterns
|
||||
category = categorize_config(var_name)
|
||||
|
||||
schema[var_name] = {
|
||||
"type": config_type,
|
||||
"default": default_value,
|
||||
"description": description,
|
||||
"category": category,
|
||||
}
|
||||
|
||||
# Add additional metadata for complex objects
|
||||
if is_complex_object(var_name):
|
||||
schema[var_name]["is_complex_object"] = True
|
||||
|
||||
return schema
|
||||
|
||||
|
||||
def categorize_config(var_name: str) -> str:
|
||||
"""Categorize configuration variables based on their names."""
|
||||
name_lower = var_name.lower()
|
||||
|
||||
if any(term in name_lower for term in ["limit", "timeout", "cache", "pool"]):
|
||||
return "performance"
|
||||
elif any(term in name_lower for term in ["feature", "flag", "enable", "disable"]):
|
||||
return "features"
|
||||
elif any(term in name_lower for term in ["theme", "color", "style", "ui"]):
|
||||
return "ui"
|
||||
elif any(term in name_lower for term in ["db", "database", "sql", "query"]):
|
||||
return "database"
|
||||
elif any(term in name_lower for term in ["auth", "security", "login", "oauth"]):
|
||||
return "security"
|
||||
elif any(term in name_lower for term in ["log", "debug", "stats"]):
|
||||
return "logging"
|
||||
elif any(term in name_lower for term in ["mail", "smtp", "email"]):
|
||||
return "email"
|
||||
elif any(term in name_lower for term in ["celery", "async", "worker"]):
|
||||
return "async"
|
||||
else:
|
||||
return "general"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Extract configuration schema and save to JSON."""
|
||||
superset_root = Path(__file__).parent.parent
|
||||
config_file = superset_root / "superset" / "config_defaults.py"
|
||||
|
||||
if not config_file.exists():
|
||||
print(f"Error: {config_file} not found")
|
||||
sys.exit(1)
|
||||
|
||||
print("Extracting configuration schema...")
|
||||
schema = extract_config_schema(config_file)
|
||||
|
||||
# Create output structure
|
||||
output = {
|
||||
"metadata": {
|
||||
"generated_from": str(config_file),
|
||||
"total_configs": len(schema),
|
||||
"description": (
|
||||
"Superset configuration schema extracted from config_defaults.py"
|
||||
),
|
||||
},
|
||||
"configs": schema,
|
||||
"by_category": {},
|
||||
}
|
||||
|
||||
# Group by category
|
||||
for key, config in schema.items():
|
||||
category = config["category"]
|
||||
if category not in output["by_category"]:
|
||||
output["by_category"][category] = {}
|
||||
output["by_category"][category][key] = config
|
||||
|
||||
# Save to JSON
|
||||
output_file = superset_root / "superset" / "config_schema.json"
|
||||
with open(output_file, "w") as f:
|
||||
json.dump(output, f, indent=2, default=str)
|
||||
|
||||
print("✅ Schema extracted successfully!")
|
||||
print(f"📊 Total configurations: {len(schema)}")
|
||||
print(f"📂 Categories: {list(output['by_category'].keys())}")
|
||||
print(f"💾 Saved to: {output_file}")
|
||||
|
||||
# Show some stats
|
||||
print("\n📈 Category breakdown:")
|
||||
for category, configs in output["by_category"].items():
|
||||
print(f" {category}: {len(configs)} configs")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
324
scripts/extract_config_types.py
Normal file
324
scripts/extract_config_types.py
Normal file
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
# 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.
|
||||
"""Extract configuration types from runtime inspection of config.py.
|
||||
|
||||
This script imports the actual config module and extracts type information
|
||||
through runtime introspection, providing more accurate type data than
|
||||
static analysis.
|
||||
"""
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
# Add superset to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
|
||||
def get_source_comment(module_path: str, var_name: str) -> Optional[str]:
|
||||
"""Extract comment from source code for a variable."""
|
||||
try:
|
||||
with open(module_path, "r") as f:
|
||||
content = f.read()
|
||||
|
||||
tree = ast.parse(content)
|
||||
lines = content.splitlines()
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
|
||||
if node.targets[0].id == var_name:
|
||||
# Look for comments above this line
|
||||
line_num = node.lineno - 1 # Convert to 0-based
|
||||
comments = []
|
||||
|
||||
# Look backwards for comments
|
||||
for i in range(min(5, line_num)):
|
||||
check_line = line_num - i - 1
|
||||
if check_line < 0:
|
||||
break
|
||||
|
||||
line = lines[check_line].strip()
|
||||
if line.startswith("#"):
|
||||
comment = line[1:].strip()
|
||||
if comment:
|
||||
comments.insert(0, comment)
|
||||
elif line and not line.startswith("#"):
|
||||
break
|
||||
|
||||
return " ".join(comments) if comments else None
|
||||
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def analyze_value(value: Any) -> Dict[str, Any]:
|
||||
"""Analyze a configuration value to extract type information."""
|
||||
analysis = {
|
||||
"python_type": type(value),
|
||||
"type_name": type(value).__name__,
|
||||
"module": getattr(type(value), "__module__", None),
|
||||
"is_callable": callable(value),
|
||||
"is_none": value is None,
|
||||
}
|
||||
|
||||
# Basic type categorization
|
||||
if value is None:
|
||||
analysis["category"] = "null"
|
||||
elif isinstance(value, bool):
|
||||
analysis["category"] = "boolean"
|
||||
elif isinstance(value, int):
|
||||
analysis["category"] = "integer"
|
||||
elif isinstance(value, float):
|
||||
analysis["category"] = "number"
|
||||
elif isinstance(value, str):
|
||||
analysis["category"] = "string"
|
||||
elif isinstance(value, (list, tuple)):
|
||||
analysis["category"] = "array"
|
||||
# Sample item types
|
||||
if value:
|
||||
item_types = list(set(type(item).__name__ for item in value[:5]))
|
||||
analysis["item_types"] = item_types
|
||||
elif isinstance(value, dict):
|
||||
analysis["category"] = "object"
|
||||
# Sample key/value types
|
||||
if value:
|
||||
keys = list(value.keys())[:5]
|
||||
key_types = list(set(type(k).__name__ for k in keys))
|
||||
val_types = list(set(type(value[k]).__name__ for k in keys))
|
||||
analysis["key_types"] = key_types
|
||||
analysis["value_types"] = val_types
|
||||
elif callable(value):
|
||||
analysis["category"] = "function"
|
||||
try:
|
||||
analysis["signature"] = str(inspect.signature(value))
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
analysis["category"] = "object"
|
||||
analysis["class_name"] = f"{type(value).__module__}.{type(value).__name__}"
|
||||
|
||||
# Serialization check
|
||||
try:
|
||||
import json
|
||||
|
||||
json.dumps(value)
|
||||
analysis["serializable"] = True
|
||||
except Exception:
|
||||
analysis["serializable"] = False
|
||||
|
||||
return analysis
|
||||
|
||||
|
||||
def categorize_config_key(key: str) -> str:
|
||||
"""Categorize a configuration key based on its name."""
|
||||
key_lower = key.lower()
|
||||
|
||||
if any(
|
||||
term in key_lower
|
||||
for term in ["secret", "key", "password", "auth", "oauth", "login"]
|
||||
):
|
||||
return "security"
|
||||
elif any(
|
||||
term in key_lower for term in ["db", "database", "sql", "query", "engine"]
|
||||
):
|
||||
return "database"
|
||||
elif any(
|
||||
term in key_lower for term in ["limit", "timeout", "cache", "pool", "async"]
|
||||
):
|
||||
return "performance"
|
||||
elif any(term in key_lower for term in ["feature", "flag", "enable", "disable"]):
|
||||
return "features"
|
||||
elif any(
|
||||
term in key_lower for term in ["theme", "color", "style", "ui", "frontend"]
|
||||
):
|
||||
return "ui"
|
||||
elif any(term in key_lower for term in ["log", "debug", "stats", "event"]):
|
||||
return "logging"
|
||||
elif any(term in key_lower for term in ["mail", "smtp", "email"]):
|
||||
return "email"
|
||||
elif any(term in key_lower for term in ["celery", "worker", "beat", "task"]):
|
||||
return "async"
|
||||
else:
|
||||
return "general"
|
||||
|
||||
|
||||
def extract_config_types() -> Dict[str, Any]:
|
||||
"""Extract type information from the config module."""
|
||||
try:
|
||||
# Import the config module
|
||||
from superset import config
|
||||
|
||||
# Get module path for comment extraction
|
||||
config_path = inspect.getfile(config)
|
||||
|
||||
results = {}
|
||||
|
||||
# Get all uppercase attributes (configuration convention)
|
||||
for name in dir(config):
|
||||
if name.isupper() and not name.startswith("_"):
|
||||
value = getattr(config, name)
|
||||
|
||||
# Analyze the value
|
||||
analysis = analyze_value(value)
|
||||
|
||||
# Get source comment
|
||||
comment = get_source_comment(config_path, name)
|
||||
|
||||
# Categorize
|
||||
category = categorize_config_key(name)
|
||||
|
||||
results[name] = {
|
||||
"key": name,
|
||||
"value_analysis": analysis,
|
||||
"description": comment,
|
||||
"category": category,
|
||||
"current_value": value
|
||||
if analysis.get("serializable")
|
||||
else f"<{analysis['type_name']} instance>",
|
||||
}
|
||||
|
||||
return results
|
||||
|
||||
except ImportError as e:
|
||||
print(f"Error importing config: {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def compare_with_metadata() -> Dict[str, Any]:
|
||||
"""Compare runtime config with defined metadata."""
|
||||
from superset.config_metadata import CONFIG_METADATA
|
||||
|
||||
runtime_configs = extract_config_types()
|
||||
|
||||
comparison = {
|
||||
"in_metadata_only": [],
|
||||
"in_runtime_only": [],
|
||||
"type_mismatches": [],
|
||||
"matching": [],
|
||||
}
|
||||
|
||||
metadata_keys = set(CONFIG_METADATA.keys())
|
||||
runtime_keys = set(runtime_configs.keys())
|
||||
|
||||
# Keys only in metadata
|
||||
comparison["in_metadata_only"] = sorted(metadata_keys - runtime_keys)
|
||||
|
||||
# Keys only in runtime
|
||||
comparison["in_runtime_only"] = sorted(runtime_keys - metadata_keys)
|
||||
|
||||
# Check for type mismatches
|
||||
for key in metadata_keys & runtime_keys:
|
||||
metadata_type = CONFIG_METADATA[key].type
|
||||
runtime_type = runtime_configs[key]["value_analysis"]["python_type"]
|
||||
|
||||
if metadata_type != runtime_type:
|
||||
comparison["type_mismatches"].append(
|
||||
{
|
||||
"key": key,
|
||||
"metadata_type": str(metadata_type),
|
||||
"runtime_type": str(runtime_type),
|
||||
}
|
||||
)
|
||||
else:
|
||||
comparison["matching"].append(key)
|
||||
|
||||
return comparison
|
||||
|
||||
|
||||
def suggest_metadata_entries() -> List[str]:
|
||||
"""Suggest metadata entries for configs not yet documented."""
|
||||
runtime_configs = extract_config_types()
|
||||
from superset.config_metadata import CONFIG_METADATA
|
||||
|
||||
suggestions = []
|
||||
|
||||
for key, info in runtime_configs.items():
|
||||
if key not in CONFIG_METADATA:
|
||||
analysis = info["value_analysis"]
|
||||
|
||||
# Build suggested metadata entry
|
||||
suggestion = f""" "{key}": ConfigMetadata(
|
||||
key="{key}",
|
||||
type={analysis["type_name"]},
|
||||
default={repr(info["current_value"]) if analysis["serializable"] else f"{analysis['type_name']}()"},
|
||||
description="{info.get("description", "TODO: Add description")}",
|
||||
category="{info["category"]}",
|
||||
impact="medium",
|
||||
requires_restart={"True" if info["category"] in ["security", "database"] else "False"},"""
|
||||
|
||||
if analysis["category"] == "integer":
|
||||
suggestion += "\n min_value=1,"
|
||||
|
||||
if not analysis["serializable"]:
|
||||
suggestion += f'\n serializable=False,\n doc_default="<{analysis["type_name"]} instance>",'
|
||||
|
||||
suggestion += "\n ),"
|
||||
|
||||
suggestions.append(suggestion)
|
||||
|
||||
return suggestions
|
||||
|
||||
|
||||
def main():
|
||||
"""Main function to run type extraction."""
|
||||
print("Extracting configuration types from runtime...")
|
||||
|
||||
# Extract types
|
||||
runtime_configs = extract_config_types()
|
||||
print(f"Found {len(runtime_configs)} configuration variables")
|
||||
|
||||
# Compare with metadata
|
||||
print("\nComparing with defined metadata...")
|
||||
comparison = compare_with_metadata()
|
||||
|
||||
print(f" - Matching: {len(comparison['matching'])}")
|
||||
print(f" - Only in metadata: {len(comparison['in_metadata_only'])}")
|
||||
print(f" - Only in runtime: {len(comparison['in_runtime_only'])}")
|
||||
print(f" - Type mismatches: {len(comparison['type_mismatches'])}")
|
||||
|
||||
if comparison["in_runtime_only"]:
|
||||
print(f"\nConfigs missing metadata: {len(comparison['in_runtime_only'])}")
|
||||
print("Generating suggestions...")
|
||||
|
||||
suggestions = suggest_metadata_entries()
|
||||
|
||||
# Save suggestions to file
|
||||
output_file = Path(__file__).parent / "suggested_metadata.py"
|
||||
with open(output_file, "w") as f:
|
||||
f.write("# Suggested metadata entries for undocumented configs\n\n")
|
||||
f.write("\n\n".join(suggestions))
|
||||
|
||||
print(f"Suggestions saved to: {output_file}")
|
||||
|
||||
# Show type distribution
|
||||
type_dist = {}
|
||||
for config in runtime_configs.values():
|
||||
cat = config["value_analysis"]["category"]
|
||||
type_dist[cat] = type_dist.get(cat, 0) + 1
|
||||
|
||||
print("\nType distribution:")
|
||||
for cat, count in sorted(type_dist.items()):
|
||||
print(f" {cat}: {count}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -33,4 +33,4 @@ superset load-test-users
|
||||
|
||||
echo "Running tests"
|
||||
|
||||
pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
||||
pytest --durations-min=2 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
||||
|
||||
3025
scripts/suggested_metadata.py
Normal file
3025
scripts/suggested_metadata.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -94,67 +94,12 @@ 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,11 +65,16 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
|
||||
)
|
||||
.should('be.visible')
|
||||
.find('[role="menuitem"]')
|
||||
.then($el => {
|
||||
cy.wrap($el)
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
||||
});
|
||||
.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');
|
||||
|
||||
if (isLegacy) {
|
||||
return cy.wait('@legacyData');
|
||||
@@ -240,7 +245,7 @@ describe('Drill by modal', () => {
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('opens the modal from the context menu', () => {
|
||||
it.only('opens the modal from the context menu', () => {
|
||||
openTableContextMenu('boy');
|
||||
drillBy('state').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
dataTestChartName,
|
||||
} from 'cypress/support/directories';
|
||||
|
||||
import { waitForChartLoad } from 'cypress/utils';
|
||||
import {
|
||||
addParentFilterWithValue,
|
||||
applyNativeFilterValueWithIndex,
|
||||
@@ -160,6 +161,74 @@ 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,11 +68,13 @@ function verifyDashboardSearch() {
|
||||
function verifyDashboardLink() {
|
||||
interceptDashboardGet();
|
||||
openDashboardsAddedTo();
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu-submenu-popup a')
|
||||
.first()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click();
|
||||
.click({ force: true });
|
||||
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.2",
|
||||
"jest": "^30.0.4",
|
||||
"yeoman-test": "^10.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -177,6 +177,7 @@ 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'> = {
|
||||
@@ -204,6 +205,7 @@ 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": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"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": "^4.0.0",
|
||||
"reselect": "^5.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
|
||||
@@ -17,11 +17,8 @@
|
||||
* 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 } from 'reselect';
|
||||
import { createSelector, lruMemoize } from 'reselect';
|
||||
import {
|
||||
AppSection,
|
||||
Behavior,
|
||||
@@ -37,7 +34,7 @@ import {
|
||||
SetDataMaskHook,
|
||||
} from '../types/Base';
|
||||
import { QueryData, DataRecordFilters } from '..';
|
||||
import { SupersetTheme } from '../../theme';
|
||||
import { supersetTheme, SupersetTheme } from '../../theme';
|
||||
|
||||
// TODO: more specific typing for these fields of ChartProps
|
||||
type AnnotationData = PlainObject;
|
||||
@@ -109,6 +106,8 @@ export interface ChartPropsConfig {
|
||||
theme: SupersetTheme;
|
||||
/* legend index */
|
||||
legendIndex?: number;
|
||||
inContextMenu?: boolean;
|
||||
emitCrossFilters?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 800;
|
||||
@@ -161,7 +160,11 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
|
||||
|
||||
theme: SupersetTheme;
|
||||
|
||||
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
|
||||
constructor(
|
||||
config: ChartPropsConfig & { formData?: FormData } = {
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) {
|
||||
const {
|
||||
annotationData = {},
|
||||
datasource = {},
|
||||
@@ -276,5 +279,16 @@ 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', () => {
|
||||
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
|
||||
const props1 = selector({
|
||||
width: 800,
|
||||
height: 600,
|
||||
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
|
||||
theme: supersetTheme,
|
||||
});
|
||||
expect(props1).not.toBe(props2);
|
||||
expect(props1).not.toBe(props3);
|
||||
expect(props1).toBe(props3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useState } from 'react';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
||||
@@ -69,34 +69,42 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
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 />
|
||||
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.',
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
</InfoText>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map((column: ComparisonColumn) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -358,7 +358,22 @@ const config: ControlPanelConfig = {
|
||||
['x_axis_time_format'],
|
||||
[xAxisLabelRotation],
|
||||
[xAxisLabelInterval],
|
||||
...richTooltipSection,
|
||||
[<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
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -212,6 +212,7 @@ export default function transformProps(
|
||||
sortSeriesAscendingB,
|
||||
timeGrainSqla,
|
||||
percentageThreshold,
|
||||
showQueryIdentifiers = false,
|
||||
metrics = [],
|
||||
metricsB = [],
|
||||
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
@@ -395,10 +396,17 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
let displayName = `${entryName} (Query A)`;
|
||||
let displayName: string;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -453,10 +461,17 @@ export default function transformProps(
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName = `${entryName} (Query B)`;
|
||||
let displayName: string;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
||||
// 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;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -696,14 +711,13 @@ export default function transformProps(
|
||||
zoomable,
|
||||
),
|
||||
// @ts-ignore
|
||||
data: rawSeriesA
|
||||
.concat(rawSeriesB)
|
||||
data: series
|
||||
.filter(
|
||||
entry =>
|
||||
extractForecastSeriesContext((entry.name || '') as string).type ===
|
||||
ForecastSeriesEnum.Observation,
|
||||
)
|
||||
.map(entry => entry.name || '')
|
||||
.map(entry => entry.id || entry.name || '')
|
||||
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
|
||||
@@ -60,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval?: number | string;
|
||||
colorScheme?: string;
|
||||
@@ -133,6 +134,7 @@ 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,
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
width,
|
||||
echartOptions,
|
||||
setDataMask,
|
||||
labelMap,
|
||||
selectedValues,
|
||||
formData,
|
||||
onContextMenu,
|
||||
@@ -52,45 +51,47 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(treePathInfo: TreePathInfo[]) => {
|
||||
const treePath = extractTreePathInfo(treePathInfo);
|
||||
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 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 labels = values.map(value => labelMap[value]);
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
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)[],
|
||||
};
|
||||
}),
|
||||
filters: [
|
||||
{
|
||||
col: columns[treePath.length - 1],
|
||||
op: '==' as const,
|
||||
val: value,
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
value: labels.length ? labels : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
value,
|
||||
selectedValues: [joinedTreePath],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: selected.includes(name),
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
},
|
||||
[columns, labelMap, selectedValues],
|
||||
[columns, selectedValues],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -101,7 +102,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
|
||||
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
||||
},
|
||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
||||
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
|
||||
@@ -116,49 +116,48 @@ const chartPropsConfig = {
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: false,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
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',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
// Check that series IDs don't include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
);
|
||||
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');
|
||||
});
|
||||
|
||||
@@ -59,7 +59,6 @@ import {
|
||||
Space,
|
||||
RawAntdSelect as Select,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
@@ -564,52 +563,62 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
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
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<div
|
||||
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;
|
||||
`}
|
||||
>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
</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>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -63,7 +63,7 @@ export enum ContextMenuItem {
|
||||
export interface ChartContextMenuProps {
|
||||
id: number;
|
||||
formData: QueryFormData;
|
||||
onSelection: () => void;
|
||||
onSelection: (args?: any) => void;
|
||||
onClose: () => void;
|
||||
additionalConfig?: {
|
||||
crossFilter?: Record<string, any>;
|
||||
@@ -123,6 +123,12 @@ 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
|
||||
@@ -264,6 +270,7 @@ const ChartContextMenu = (
|
||||
<DrillByMenuItems
|
||||
drillByConfig={filters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
contextMenuY={clientY}
|
||||
submenuIndex={submenuIndex}
|
||||
@@ -311,6 +318,7 @@ const ChartContextMenu = (
|
||||
onOpenChange={setOpenKeys}
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -166,8 +166,12 @@ 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 => {
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
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.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -186,15 +190,19 @@ 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(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const searchbox = await waitFor(
|
||||
() => screen.getAllByPlaceholderText('Search columns')[1],
|
||||
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||
);
|
||||
expect(searchbox).toBeInTheDocument();
|
||||
|
||||
@@ -204,19 +212,26 @@ 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(screen.getByText(colName)).toBeInTheDocument();
|
||||
expect(within(submenu).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(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
||||
expect(
|
||||
within(submenu).queryByText(col.column_name),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -238,17 +253,23 @@ 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(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
excludedColNames.forEach(colName => {
|
||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
||||
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,7 +290,11 @@ 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(() => screen.getByText('col1'));
|
||||
const col1Element = await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
return within(submenu).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 { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
@@ -68,6 +68,7 @@ export interface DrillByMenuItemsProps {
|
||||
submenuIndex?: number;
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
open: boolean;
|
||||
@@ -100,6 +101,7 @@ export const DrillByMenuItems = ({
|
||||
submenuIndex = 0,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
excludedColumns,
|
||||
openNewModal = true,
|
||||
open,
|
||||
@@ -124,6 +126,7 @@ export const DrillByMenuItems = ({
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
onCloseMenu();
|
||||
},
|
||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||
);
|
||||
@@ -264,15 +267,14 @@ export const DrillByMenuItems = ({
|
||||
const { columns, ...rest } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<MenuItemWithTruncation
|
||||
menuKey={`drill-by-item-${column.column_name}`}
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</MenuItemWithTruncation>
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,3 +18,4 @@
|
||||
*/
|
||||
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* 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,9 +18,14 @@
|
||||
*/
|
||||
|
||||
import { ReactNode, CSSProperties, useCallback } from 'react';
|
||||
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
|
||||
import {
|
||||
css,
|
||||
truncationCSS,
|
||||
useCSSTextTruncation,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
import { Flex, Tooltip } from '@superset-ui/core/components';
|
||||
import { MenuItemProps } from 'antd';
|
||||
|
||||
export type MenuItemWithTruncationProps = {
|
||||
@@ -113,7 +118,12 @@ export const MenuItemWithTruncation = ({
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||
<Tooltip
|
||||
title={itemIsTruncated ? tooltipText : null}
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
ref={itemRef}
|
||||
css={css`
|
||||
@@ -127,3 +137,50 @@ 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,11 +16,29 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
cleanup,
|
||||
} 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}`,
|
||||
@@ -29,37 +47,99 @@ 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', () => {
|
||||
let container: HTMLElement;
|
||||
it('renders empty state with no users', () => {
|
||||
const { container } = render(<FacePile users={[]} />, { store });
|
||||
|
||||
beforeEach(() => {
|
||||
({ container } = render(<FacePile users={users} />, { store }));
|
||||
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a valid element', () => {
|
||||
const exposedFaces = screen.getAllByText(/U/);
|
||||
expect(exposedFaces).toHaveLength(4);
|
||||
const overflownFaces = screen.getByText('+6');
|
||||
expect(overflownFaces).toBeVisible();
|
||||
it('renders single user without truncation', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
|
||||
store,
|
||||
});
|
||||
|
||||
// Display user info when hovering over one of exposed face in the pile.
|
||||
fireEvent.mouseEnter(exposedFaces[0]);
|
||||
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);
|
||||
act(() => jest.runAllTimers());
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
|
||||
});
|
||||
|
||||
it('renders an Avatar', () => {
|
||||
expect(container.querySelector('.ant-avatar')).toBeVisible();
|
||||
});
|
||||
it('displays avatar images when Slack avatars are enabled', () => {
|
||||
// Enable Slack avatars feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'SLACK_ENABLE_AVATARS',
|
||||
);
|
||||
|
||||
it('hides overflow', () => {
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { tagToSelectOption } from 'src/components/Tag/utils';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
|
||||
|
||||
describe('tagToSelectOption', () => {
|
||||
test('converts a Tag object with table_name to a SelectTagsValue', () => {
|
||||
@@ -35,3 +37,166 @@ 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,3 +78,129 @@ 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 } from '@superset-ui/core/components/Menu';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
|
||||
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 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,9 +74,6 @@ 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,
|
||||
@@ -172,163 +169,220 @@ 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;
|
||||
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>}
|
||||
/>,
|
||||
),
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
selectable={false}
|
||||
data-test="header-actions-menu"
|
||||
onClick={handleMenuClick}
|
||||
>
|
||||
{!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>
|
||||
items={menuItems}
|
||||
/>
|
||||
);
|
||||
}, [
|
||||
css,
|
||||
showReportSubMenu,
|
||||
isDropdownVisible,
|
||||
directPathToChild,
|
||||
handleMenuClick,
|
||||
changeCss,
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
changeRefreshInterval,
|
||||
emailSubject,
|
||||
url,
|
||||
dashboardComponentId,
|
||||
changeCss,
|
||||
colorNamespace,
|
||||
colorScheme,
|
||||
css,
|
||||
customCss,
|
||||
dashboardId,
|
||||
dashboardInfo,
|
||||
dashboardTitle,
|
||||
downloadMenuItem,
|
||||
editMode,
|
||||
expandedSlices,
|
||||
handleMenuClick,
|
||||
isLoading,
|
||||
lastModifiedTime,
|
||||
layout,
|
||||
onSave,
|
||||
refreshFrequency,
|
||||
refreshLimit,
|
||||
refreshWarning,
|
||||
reportMenuItem,
|
||||
shareMenuItems,
|
||||
shouldPersistRefreshFrequency,
|
||||
userCanCurate,
|
||||
userCanEdit,
|
||||
userCanSave,
|
||||
userCanShare,
|
||||
]);
|
||||
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||
|
||||
@@ -438,10 +438,9 @@ 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 } from '@superset-ui/core/components/Menu';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
NoAnimationDropdown,
|
||||
Tooltip,
|
||||
Button,
|
||||
ModalTrigger,
|
||||
} from '@superset-ui/core/components';
|
||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
||||
import { useShareMenuItems } 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 { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||
import { useDrillDetailMenuItems } 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,183 +334,199 @@ const SliceHeaderControls = (
|
||||
animationDuration: '0s',
|
||||
};
|
||||
|
||||
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>
|
||||
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',
|
||||
},
|
||||
];
|
||||
|
||||
<Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item>
|
||||
if (slice.description) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.ToggleChartDescription,
|
||||
label: props.isDescriptionExpanded
|
||||
? t('Hide chart description')
|
||||
: t('Show chart description'),
|
||||
});
|
||||
}
|
||||
|
||||
<Menu.Divider />
|
||||
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 },
|
||||
});
|
||||
}
|
||||
|
||||
{slice.description && (
|
||||
<Menu.Item key={MenuKeys.ToggleChartDescription}>
|
||||
{props.isDescriptionExpanded
|
||||
? t('Hide chart description')
|
||||
: t('Show chart description')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
if (canEditCrossFilters) {
|
||||
newMenuItems.push({
|
||||
key: MenuKeys.CrossFilterScoping,
|
||||
label: t('Cross-filtering scoping'),
|
||||
});
|
||||
}
|
||||
|
||||
{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 || canEditCrossFilters) {
|
||||
newMenuItems.push({ type: 'divider' });
|
||||
}
|
||||
|
||||
{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 || 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}
|
||||
/>
|
||||
)}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
{(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')}
|
||||
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}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
{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 { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
||||
useDrillDetailMenuItems({
|
||||
formData: props.formData,
|
||||
filters: modalFilters,
|
||||
setFilters,
|
||||
setShowModal: setDrillModalIsOpen,
|
||||
key: MenuKeys.DrillToDetail,
|
||||
});
|
||||
|
||||
{isPivotTable && (
|
||||
<Menu.Item
|
||||
key={MenuKeys.ExportPivotXlsx}
|
||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Export to Pivoted 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'),
|
||||
});
|
||||
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
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} />,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
<Menu.Item
|
||||
key={MenuKeys.DownloadAsImage}
|
||||
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
|
||||
>
|
||||
{t('Download as image')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{isFullSize && (
|
||||
@@ -522,7 +538,15 @@ const SliceHeaderControls = (
|
||||
/>
|
||||
)}
|
||||
<NoAnimationDropdown
|
||||
popupRender={() => menu}
|
||||
popupRender={() => (
|
||||
<Menu
|
||||
onClick={handleMenuClick}
|
||||
data-test={`slice_${slice.slice_id}-menu`}
|
||||
id={`slice_${slice.slice_id}-menu`}
|
||||
selectable={false}
|
||||
items={newMenuItems}
|
||||
/>
|
||||
)}
|
||||
overlayStyle={dropdownOverlayStyle}
|
||||
trigger={['click']}
|
||||
placement="bottomRight"
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import DownloadMenuItems from '.';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { useDownloadMenuItems } from '.';
|
||||
|
||||
const createProps = () => ({
|
||||
pdfMenuItemTitle: 'Export to PDF',
|
||||
@@ -30,19 +30,17 @@ const createProps = () => ({
|
||||
submenuKey: 'download',
|
||||
});
|
||||
|
||||
const renderComponent = () => {
|
||||
render(
|
||||
<Menu forceSubMenuRender>
|
||||
<DownloadMenuItems {...createProps()} />
|
||||
</Menu>,
|
||||
{
|
||||
useRedux: true,
|
||||
},
|
||||
);
|
||||
const MenuWrapper = () => {
|
||||
const downloadMenuItem = useDownloadMenuItems(createProps());
|
||||
const menuItems: MenuItem[] = [downloadMenuItem];
|
||||
return <Menu forceSubMenuRender items={menuItems} />;
|
||||
};
|
||||
|
||||
test('Should render menu items', () => {
|
||||
renderComponent();
|
||||
render(<MenuWrapper />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
|
||||
expect(screen.getByText('Download as Image')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,16 +16,21 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { SyntheticEvent } from 'react';
|
||||
import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||
import { ComponentProps } from 'react';
|
||||
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 { DownloadScreenshotFormat } from './types';
|
||||
import DownloadAsPdf from './DownloadAsPdf';
|
||||
import DownloadAsImage from './DownloadAsImage';
|
||||
|
||||
export interface DownloadMenuItemProps
|
||||
extends ComponentProps<typeof Menu.SubMenu> {
|
||||
export interface UseDownloadMenuItemsProps {
|
||||
pdfMenuItemTitle: string;
|
||||
imageMenuItemTitle: string;
|
||||
dashboardTitle: string;
|
||||
@@ -33,56 +38,81 @@ export interface DownloadMenuItemProps
|
||||
dashboardId: number;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
submenuKey: string;
|
||||
}
|
||||
|
||||
const DownloadMenuItems = (props: DownloadMenuItemProps) => {
|
||||
export const useDownloadMenuItems = (
|
||||
props: UseDownloadMenuItemsProps,
|
||||
): MenuItem => {
|
||||
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);
|
||||
|
||||
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>
|
||||
);
|
||||
};
|
||||
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);
|
||||
};
|
||||
|
||||
export default DownloadMenuItems;
|
||||
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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
} from 'spec/helpers/testing-library';
|
||||
import * as copyTextToClipboard from 'src/utils/copy';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import ShareMenuItems from '.';
|
||||
import { ComponentProps } from 'react';
|
||||
import { useShareMenuItems, ShareMenuItemProps } from '.';
|
||||
|
||||
const spy = jest.spyOn(copyTextToClipboard, 'default');
|
||||
|
||||
@@ -69,17 +70,23 @@ 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(
|
||||
<Menu
|
||||
<MenuWrapper
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
shareProps={createProps()}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
|
||||
@@ -90,14 +97,13 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
||||
spy.mockResolvedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<Menu
|
||||
<MenuWrapper
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
shareProps={props}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -123,14 +129,13 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
spy.mockRejectedValue(undefined);
|
||||
const props = createProps();
|
||||
render(
|
||||
<Menu
|
||||
<MenuWrapper
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
shareProps={props}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -157,14 +162,13 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
||||
test('Click on "Share dashboard by email" and succeed', async () => {
|
||||
const props = createProps();
|
||||
render(
|
||||
<Menu
|
||||
<MenuWrapper
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
shareProps={props}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
@@ -191,14 +195,13 @@ test('Click on "Share dashboard by email" and fail', async () => {
|
||||
);
|
||||
const props = createProps();
|
||||
render(
|
||||
<Menu
|
||||
<MenuWrapper
|
||||
onClick={jest.fn()}
|
||||
selectable={false}
|
||||
data-test="main-menu"
|
||||
forceSubMenuRender
|
||||
>
|
||||
<ShareMenuItems {...props} />
|
||||
</Menu>,
|
||||
shareProps={props}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
|
||||
@@ -19,12 +19,13 @@
|
||||
import { ComponentProps, RefObject } from 'react';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { t, logging } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import { Menu, MenuItem } 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';
|
||||
|
||||
interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
|
||||
export interface ShareMenuItemProps
|
||||
extends ComponentProps<typeof Menu.SubMenu> {
|
||||
url?: string;
|
||||
copyMenuItemTitle: string;
|
||||
emailMenuItemTitle: string;
|
||||
@@ -40,9 +41,10 @@ interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
|
||||
setOpenKeys?: Function;
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const ShareMenuItems = (props: ShareMenuItemProps) => {
|
||||
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||
const {
|
||||
copyMenuItemTitle,
|
||||
emailMenuItemTitle,
|
||||
@@ -96,20 +98,23 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
|
||||
}
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
export default ShareMenuItems;
|
||||
|
||||
@@ -155,6 +155,34 @@ 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 = (
|
||||
@@ -226,6 +254,7 @@ 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,7 +53,6 @@ import {
|
||||
sections,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { rgba } from 'emotion-rgba';
|
||||
import { kebabCase, isEqual } from 'lodash';
|
||||
|
||||
import {
|
||||
@@ -118,16 +117,11 @@ 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;
|
||||
z-index: 999;
|
||||
background: linear-gradient(
|
||||
${rgba(theme.colorBgBase, 0)},
|
||||
${theme.colorBgBase} 35%
|
||||
);
|
||||
background: ${theme.colorBgContainer};
|
||||
flex-shrink: 0;
|
||||
|
||||
& > button {
|
||||
min-width: 156px;
|
||||
@@ -138,15 +132,18 @@ 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 {
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
padding-bottom: ${({ theme }) => theme.sizeUnit * 10}px;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
overflow: auto;
|
||||
flex: 1 1 100%;
|
||||
|
||||
@@ -30,10 +30,6 @@ 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;
|
||||
@@ -47,10 +43,9 @@ const LabelContainer = styled.div<{
|
||||
|
||||
padding: 0 ${theme.sizeUnit * 3}px;
|
||||
|
||||
background-color: ${theme.colors.grayscale.light5};
|
||||
background-color: ${theme.colorBgContainer};
|
||||
|
||||
border: 1px solid
|
||||
${isActive ? ACTIVE_BORDER_COLOR : theme.colors.grayscale.light2};
|
||||
border: 1px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
|
||||
cursor: pointer;
|
||||
@@ -58,11 +53,11 @@ const LabelContainer = styled.div<{
|
||||
transition: border-color 0.3s cubic-bezier(0.65, 0.05, 0.36, 1);
|
||||
:hover,
|
||||
:focus {
|
||||
border-color: ${ACTIVE_BORDER_COLOR};
|
||||
border-color: ${theme.colorPrimary};
|
||||
}
|
||||
|
||||
.date-label-content {
|
||||
color: ${isPlaceholder ? theme.colors.grayscale.light1 : theme.colorText};
|
||||
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
@@ -71,6 +66,7 @@ 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 HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
|
||||
@@ -123,12 +123,18 @@ 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 () => {
|
||||
@@ -203,14 +209,106 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
||||
|
||||
const handleMenuClick = useCallback(
|
||||
({ key, domEvent }) => {
|
||||
switch (key) {
|
||||
case MENU_KEYS.EDIT_PROPERTIES:
|
||||
const menu = useMemo(() => {
|
||||
const menuItems = [];
|
||||
|
||||
// Edit chart properties
|
||||
if (slice) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||
label: t('Edit chart properties'),
|
||||
onClick: () => {
|
||||
onOpenPropertiesModal();
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_CSV:
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 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: () => {
|
||||
exportCSV();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -219,18 +317,17 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
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:
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
downloadChildren.push(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_JSON,
|
||||
label: t('Export to .JSON'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportJson();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -239,8 +336,33 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_XLSX:
|
||||
},
|
||||
},
|
||||
{
|
||||
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: () => {
|
||||
exportExcel();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -249,225 +371,128 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
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:
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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: () => {
|
||||
copyLink();
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.EMBED_CODE:
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.SHARE_BY_EMAIL:
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.SHARE_BY_EMAIL,
|
||||
label: t('Share chart by email'),
|
||||
onClick: () => {
|
||||
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,
|
||||
],
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
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}>
|
||||
if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) {
|
||||
shareChildren.push({
|
||||
key: MENU_KEYS.EMBED_CODE,
|
||||
label: (
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
<div data-test="embed-code-button">{t('Embed code')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalTitle={t('Embed code')}
|
||||
modalBody={
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
<EmbedCodeContent
|
||||
formData={latestQueryFormData}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
maxWidth={`${theme.sizeUnit * 100}px`}
|
||||
destroyOnHidden
|
||||
responsive
|
||||
/>
|
||||
</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,
|
||||
],
|
||||
);
|
||||
),
|
||||
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,
|
||||
]);
|
||||
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||
};
|
||||
|
||||
@@ -1087,18 +1087,27 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
onChange={setDatabaseModel}
|
||||
placeholder={t('Choose a database...')}
|
||||
options={[
|
||||
...(availableDbs?.databases || [])
|
||||
.sort((a: DatabaseForm, b: DatabaseForm) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((database: DatabaseForm, index: number) => ({
|
||||
...(availableDbs?.databases || []).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,6 +43,7 @@ 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,12 +18,11 @@
|
||||
*/
|
||||
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
||||
import { Menu } from '@superset-ui/core/components/Menu';
|
||||
import HeaderReportDropdown, { HeaderReportProps } from '.';
|
||||
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { useHeaderReportMenuItems, HeaderReportProps } from './index';
|
||||
|
||||
const createProps = () => ({
|
||||
dashboardId: 1,
|
||||
useTextMenu: false,
|
||||
setShowReportSubMenu: jest.fn,
|
||||
showReportModal: jest.fn,
|
||||
setCurrentReportDeleting: jest.fn,
|
||||
@@ -115,13 +114,14 @@ 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(
|
||||
<Menu>
|
||||
<HeaderReportDropdown {...props} />
|
||||
</Menu>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
render(<MenuWrapper {...props} />, { useRedux: true, initialState });
|
||||
}
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -147,7 +147,7 @@ describe('Header Report Dropdown', () => {
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithUserAndReport);
|
||||
});
|
||||
expect(screen.getByRole('menuitem')).toBeInTheDocument();
|
||||
expect(screen.getAllByRole('menuitem')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the dropdown correctly', async () => {
|
||||
@@ -155,8 +155,6 @@ 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();
|
||||
@@ -168,8 +166,6 @@ 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();
|
||||
@@ -181,49 +177,34 @@ 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 textMenu is set to true and there is a report', async () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
it('renders Manage Email Reports Menu if there is a report', async () => {
|
||||
const mockedProps = createProps();
|
||||
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 textMenu is set to true and there is a report', async () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
it('renders Schedule Email Reports if there is a report', async () => {
|
||||
const mockedProps = createProps();
|
||||
|
||||
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 () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
const mockedProps = createProps();
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithNonAdminUser);
|
||||
});
|
||||
@@ -234,11 +215,8 @@ describe('Header Report Dropdown', () => {
|
||||
});
|
||||
|
||||
it('do not render Schedule Email Reports if user no permission', () => {
|
||||
let mockedProps = createProps();
|
||||
mockedProps = {
|
||||
...mockedProps,
|
||||
useTextMenu: true,
|
||||
};
|
||||
const mockedProps = createProps();
|
||||
|
||||
act(() => {
|
||||
setup(mockedProps, stateWithNonMenuAccessOnManage);
|
||||
});
|
||||
|
||||
@@ -16,24 +16,20 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { 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 { 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 { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { Checkbox } from '@superset-ui/core/components';
|
||||
import { AlertObject } from 'src/features/alerts/types';
|
||||
import { noOp } from 'src/utils/common';
|
||||
import { ChartState } from 'src/explore/types';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
@@ -41,35 +37,14 @@ 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();
|
||||
|
||||
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};
|
||||
}
|
||||
`;
|
||||
export enum CreationMethod {
|
||||
Charts = 'charts',
|
||||
Dashboards = 'dashboards',
|
||||
}
|
||||
|
||||
const StyledDropdownItemWithIcon = styled.div`
|
||||
display: flex;
|
||||
@@ -85,63 +60,59 @@ 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;
|
||||
}
|
||||
|
||||
// Same instance to be used in useEffects
|
||||
const EMPTY_OBJECT = {};
|
||||
|
||||
export default function HeaderReportDropDown({
|
||||
export const useHeaderReportMenuItems = ({
|
||||
dashboardId,
|
||||
chart,
|
||||
useTextMenu = false,
|
||||
setShowReportSubMenu,
|
||||
submenuTitle,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
}: HeaderReportProps) {
|
||||
}: HeaderReportProps): MenuItem | null => {
|
||||
const dispatch = useDispatch();
|
||||
const report = useSelector<any, AlertObject>(state => {
|
||||
const resourceType = dashboardId
|
||||
? CreationMethod.Dashboards
|
||||
: CreationMethod.Charts;
|
||||
return (
|
||||
reportSelector(state, resourceType, dashboardId || chart?.id) ||
|
||||
EMPTY_OBJECT
|
||||
);
|
||||
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 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) {
|
||||
// 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;
|
||||
}
|
||||
if (!isFeatureEnabled(FeatureFlag.AlertReports)) return false;
|
||||
if (!user?.userId) return false;
|
||||
if (!resourceId) return false;
|
||||
|
||||
const roles = Object.keys(user.roles || []);
|
||||
const permissions = roles.map(key =>
|
||||
@@ -152,17 +123,11 @@ export default function HeaderReportDropDown({
|
||||
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(
|
||||
@@ -170,113 +135,82 @@ export default function HeaderReportDropDown({
|
||||
userId: user.userId,
|
||||
filterField: dashboardId ? 'dashboard_id' : 'chart_id',
|
||||
creationMethod: dashboardId ? 'dashboards' : 'charts',
|
||||
resourceId: dashboardId || chart?.id,
|
||||
resourceId,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}, []);
|
||||
}, [dispatch, shouldFetch, user?.userId, dashboardId, resourceId]);
|
||||
|
||||
const showReportSubMenu = report && setShowReportSubMenu && canAddReports();
|
||||
// Don't show anything if user can't add reports
|
||||
if (!canAddReports()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (showReportSubMenu) {
|
||||
setShowReportSubMenu(true);
|
||||
} else if (!report && setShowReportSubMenu) {
|
||||
setShowReportSubMenu(false);
|
||||
// Handler functions
|
||||
const handleShowModal = () => showReportModal();
|
||||
const handleDeleteReport = () => setCurrentReportDeleting(report);
|
||||
const handleToggleActive = () => {
|
||||
if (report?.id) {
|
||||
dispatch(toggleActive(report, !report.active));
|
||||
}
|
||||
}, [report]);
|
||||
|
||||
const handleShowMenu = () => {
|
||||
showReportModal();
|
||||
};
|
||||
|
||||
const handleDeleteMenuClick = () => {
|
||||
setCurrentReportDeleting(report);
|
||||
};
|
||||
|
||||
const textMenu = () =>
|
||||
isEmpty(report) ? (
|
||||
<Menu.SubMenu title={submenuTitle} css={onMenuHover}>
|
||||
<Menu.Item onClick={handleShowMenu}>
|
||||
{DropdownItemExtension ? (
|
||||
// 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 ? (
|
||||
<StyledDropdownItemWithIcon>
|
||||
<div>{t('Set up an email report')}</div>
|
||||
<DropdownItemExtension />
|
||||
</StyledDropdownItemWithIcon>
|
||||
) : (
|
||||
t('Set up an email report')
|
||||
)}
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
</Menu.SubMenu>
|
||||
) : (
|
||||
<Menu.SubMenu
|
||||
title={submenuTitle}
|
||||
css={css`
|
||||
border: none;
|
||||
`}
|
||||
>
|
||||
<Menu.Item
|
||||
css={onMenuItemHover}
|
||||
onClick={() => toggleActiveKey(report, !isReportActive)}
|
||||
>
|
||||
),
|
||||
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: (
|
||||
<MenuItemWithCheckboxContainer>
|
||||
<Checkbox checked={isReportActive} onChange={noOp} />
|
||||
<Checkbox
|
||||
checked={report.active || false}
|
||||
onChange={noOp}
|
||||
css={theme => css`
|
||||
margin-right: ${theme.sizeUnit}px;
|
||||
`}
|
||||
/>
|
||||
{t('Email reports active')}
|
||||
</MenuItemWithCheckboxContainer>
|
||||
</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())}</>;
|
||||
}
|
||||
),
|
||||
onClick: handleToggleActive,
|
||||
},
|
||||
{
|
||||
key: 'edit-report',
|
||||
label: t('Edit email report'),
|
||||
onClick: handleShowModal,
|
||||
},
|
||||
{
|
||||
key: 'delete-report',
|
||||
label: t('Delete email report'),
|
||||
onClick: handleDeleteReport,
|
||||
danger: true,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -151,6 +151,8 @@ 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,
|
||||
@@ -271,6 +273,8 @@ export default function PluginFilterSelect(props: PluginFilterSelectProps) {
|
||||
} else {
|
||||
updateDataMask(values);
|
||||
}
|
||||
|
||||
isChangedByUser.current = true;
|
||||
},
|
||||
[updateDataMask, formData.nativeFilterId, clearAllTrigger],
|
||||
);
|
||||
@@ -368,6 +372,61 @@ 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,6 +134,7 @@ export default function PluginFilterTimegrain(
|
||||
ref={inputRef}
|
||||
options={options}
|
||||
onOpenChange={setFilterActive}
|
||||
sortComparator={() => 0} // Disable frontend sorting to preserve backend order
|
||||
/>
|
||||
</FormItem>
|
||||
</FilterPluginStyle>
|
||||
|
||||
@@ -0,0 +1,588 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,883 @@
|
||||
/**
|
||||
* 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,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
});
|
||||
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
476
superset-frontend/src/pages/ChartList/ChartList.test.tsx
Normal file
476
superset-frontend/src/pages/ChartList/ChartList.test.tsx
Normal file
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* 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('');
|
||||
});
|
||||
});
|
||||
332
superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx
Normal file
332
superset-frontend/src/pages/ChartList/ChartList.testHelpers.tsx
Normal file
@@ -0,0 +1,332 @@
|
||||
/**
|
||||
* 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,6 +338,22 @@ 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'),
|
||||
@@ -358,22 +374,6 @@ 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(
|
||||
|
||||
225
superset-frontend/src/utils/chartRegistry.test.ts
Normal file
225
superset-frontend/src/utils/chartRegistry.test.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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,4 +102,157 @@ 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,7 +14,6 @@
|
||||
# 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
|
||||
@@ -35,9 +34,7 @@ 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,9 +29,6 @@ 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):
|
||||
"""
|
||||
@@ -96,7 +93,7 @@ class AdvancedDataTypeRestApi(BaseSupersetApi):
|
||||
item = kwargs["rison"]
|
||||
advanced_data_type = item["type"]
|
||||
values = item["values"]
|
||||
addon = ADVANCED_DATA_TYPES.get(advanced_data_type)
|
||||
addon = app.config["ADVANCED_DATA_TYPES"].get(advanced_data_type)
|
||||
if not addon:
|
||||
return self.response(
|
||||
400,
|
||||
@@ -148,4 +145,4 @@ class AdvancedDataTypeRestApi(BaseSupersetApi):
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
return self.response(200, result=list(ADVANCED_DATA_TYPES.keys()))
|
||||
return self.response(200, result=list(app.config["ADVANCED_DATA_TYPES"].keys()))
|
||||
|
||||
@@ -117,9 +117,8 @@ class AsyncQueryManager:
|
||||
self._load_explore_json_into_cache_job: Any = None
|
||||
|
||||
def init_app(self, app: Flask) -> None:
|
||||
config = app.config
|
||||
cache_type = config.get("CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
data_cache_type = config.get("DATA_CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
cache_type = app.config.get("CACHE_CONFIG", {}).get("CACHE_TYPE")
|
||||
data_cache_type = app.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
|
||||
"""
|
||||
@@ -128,26 +127,28 @@ class AsyncQueryManager:
|
||||
"""
|
||||
)
|
||||
|
||||
self._cache = get_cache_backend(config)
|
||||
self._cache = get_cache_backend(app.config)
|
||||
logger.debug("Using GAQ Cache backend as %s", type(self._cache).__name__)
|
||||
|
||||
if len(config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]) < 32:
|
||||
if len(app.config["GLOBAL_ASYNC_QUERIES_JWT_SECRET"]) < 32:
|
||||
raise AsyncQueryTokenException(
|
||||
"Please provide a JWT secret at least 32 bytes long"
|
||||
)
|
||||
|
||||
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[
|
||||
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[
|
||||
"GLOBAL_ASYNC_QUERIES_REDIS_STREAM_LIMIT_FIREHOSE"
|
||||
]
|
||||
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"]
|
||||
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"]
|
||||
|
||||
if config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
if app.config["GLOBAL_ASYNC_QUERIES_REGISTER_REQUEST_HANDLERS"]:
|
||||
self.register_request_handlers(app)
|
||||
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -16,10 +16,9 @@
|
||||
# under the License.
|
||||
import logging
|
||||
|
||||
from flask import Response
|
||||
from flask import current_app as app, 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
|
||||
@@ -70,6 +69,6 @@ class AvailableDomainsRestApi(BaseSupersetApi):
|
||||
$ref: '#/components/responses/403'
|
||||
"""
|
||||
result = self.available_domains_schema.dump(
|
||||
{"domains": conf.get("SUPERSET_WEBSERVER_DOMAINS")}
|
||||
{"domains": app.config.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 app, is_feature_enabled
|
||||
from superset import is_feature_enabled
|
||||
from superset.charts.filters import (
|
||||
ChartAllTextFilter,
|
||||
ChartCertifiedFilter,
|
||||
@@ -101,7 +101,6 @@ 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, g, make_response, request, Response
|
||||
from flask import current_app as 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 = current_app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
encoding = 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,7 +40,25 @@ if TYPE_CHECKING:
|
||||
from superset.common.query_context import QueryContext
|
||||
from superset.common.query_context_factory import QueryContextFactory
|
||||
|
||||
config = app.config
|
||||
|
||||
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
|
||||
]
|
||||
|
||||
|
||||
#
|
||||
# RISON/JSON schemas for query parameters
|
||||
@@ -624,13 +642,7 @@ class ChartDataProphetOptionsSchema(ChartDataPostProcessingOperationOptionsSchem
|
||||
"[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
|
||||
"example": "P1D",
|
||||
},
|
||||
validate=validate.OneOf(
|
||||
choices=[
|
||||
i
|
||||
for i in {**builtin_time_grains, **config["TIME_GRAIN_ADDONS"]}.keys()
|
||||
if i
|
||||
]
|
||||
),
|
||||
validate=validate.OneOf(choices=get_time_grain_choices()),
|
||||
required=True,
|
||||
)
|
||||
periods = fields.Integer(
|
||||
@@ -989,13 +1001,7 @@ class ChartDataExtrasSchema(Schema):
|
||||
"[ISO 8601](https://en.wikipedia.org/wiki/ISO_8601#Durations) durations.",
|
||||
"example": "P1D",
|
||||
},
|
||||
validate=validate.OneOf(
|
||||
choices=[
|
||||
i
|
||||
for i in {**builtin_time_grains, **config["TIME_GRAIN_ADDONS"]}.keys()
|
||||
if i
|
||||
]
|
||||
),
|
||||
validate=validate.OneOf(choices=get_time_grain_choices()),
|
||||
allow_none=True,
|
||||
)
|
||||
instant_time_comparison_range = fields.String(
|
||||
|
||||
171
superset/cli/config.py
Normal file
171
superset/cli/config.py
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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.
|
||||
"""Configuration introspection CLI commands."""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import yaml
|
||||
from flask.cli import with_appcontext
|
||||
|
||||
from superset import app
|
||||
|
||||
|
||||
def serialize_config_value(value: Any) -> Any:
|
||||
"""Serialize config values for YAML output, handling callables and objects."""
|
||||
if callable(value):
|
||||
name = value.__name__ if hasattr(value, "__name__") else repr(value)
|
||||
return f"<callable: {name}>"
|
||||
elif hasattr(value, "__module__") and hasattr(value, "__name__"):
|
||||
return f"<object: {value.__module__}.{value.__name__}>"
|
||||
elif isinstance(value, type):
|
||||
return f"<class: {value.__module__}.{value.__name__}>"
|
||||
else:
|
||||
try:
|
||||
# Try to serialize with yaml to check if it's serializable
|
||||
yaml.safe_dump(value)
|
||||
return value
|
||||
except yaml.YAMLError:
|
||||
return repr(value)
|
||||
|
||||
|
||||
def get_config_source(key: str) -> str:
|
||||
"""Determine where a config value comes from."""
|
||||
import os
|
||||
|
||||
# Check if it's from environment variables (with double underscore prefix)
|
||||
env_key = f"SUPERSET__{key}"
|
||||
if env_key in os.environ:
|
||||
return f"environment ({env_key})"
|
||||
|
||||
# Check if it's from superset_config.py user override
|
||||
try:
|
||||
import superset_config
|
||||
|
||||
if hasattr(superset_config, key):
|
||||
return "superset_config.py"
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Otherwise it's from defaults
|
||||
return "config_defaults.py"
|
||||
|
||||
|
||||
@click.group()
|
||||
def config() -> None:
|
||||
"""Configuration introspection commands."""
|
||||
pass
|
||||
|
||||
|
||||
@config.command()
|
||||
@with_appcontext
|
||||
@click.option("--filter", "-f", help="Filter config keys (regex pattern)")
|
||||
@click.option(
|
||||
"--verbose", "-v", is_flag=True, help="Show source information for each key"
|
||||
)
|
||||
def show(filter: str, verbose: bool) -> None:
|
||||
"""Show effective configuration as YAML."""
|
||||
config_dict = {}
|
||||
|
||||
# Get actual config keys (not Flask Config methods)
|
||||
# Use app.config.keys() to get only the configuration values
|
||||
for key in app.config.keys():
|
||||
# Apply filter if provided
|
||||
if filter and not re.search(filter, key, re.IGNORECASE):
|
||||
continue
|
||||
|
||||
value = app.config[key]
|
||||
serialized_value = serialize_config_value(value)
|
||||
|
||||
if verbose:
|
||||
source = get_config_source(key)
|
||||
config_dict[key] = {"value": serialized_value, "source": source}
|
||||
else:
|
||||
config_dict[key] = serialized_value
|
||||
|
||||
# Output as YAML
|
||||
print(yaml.dump(config_dict, default_flow_style=False, sort_keys=True))
|
||||
|
||||
|
||||
@config.command()
|
||||
@with_appcontext
|
||||
@click.argument("key")
|
||||
def get(key: str) -> None:
|
||||
"""Get a specific configuration value."""
|
||||
if key not in app.config:
|
||||
click.echo(f"Configuration key '{key}' not found", err=True)
|
||||
return
|
||||
|
||||
value = app.config[key]
|
||||
serialized_value = serialize_config_value(value)
|
||||
source = get_config_source(key)
|
||||
|
||||
result = {key: {"value": serialized_value, "source": source}}
|
||||
|
||||
print(yaml.dump(result, default_flow_style=False))
|
||||
|
||||
|
||||
@config.command()
|
||||
@with_appcontext
|
||||
def env_examples() -> None:
|
||||
"""Show example environment variables for configuration."""
|
||||
from superset.config_extensions import SupersetConfig
|
||||
|
||||
examples = [
|
||||
"# Superset configuration via environment variables",
|
||||
"# All environment variables must start with SUPERSET__ prefix "
|
||||
"(note double underscore)",
|
||||
"",
|
||||
"# Basic settings",
|
||||
"export SUPERSET__ROW_LIMIT=100000",
|
||||
"export SUPERSET__SAMPLES_ROW_LIMIT=10000",
|
||||
"export SUPERSET__SQLLAB_TIMEOUT=60",
|
||||
"",
|
||||
"# Feature flags (JSON format)",
|
||||
'export SUPERSET__FEATURE_FLAGS=\'{"ENABLE_TEMPLATE_PROCESSING": true, '
|
||||
'"ENABLE_EXPLORE_DRAG_AND_DROP": true}\'',
|
||||
"",
|
||||
"# Or use triple underscore for nested values",
|
||||
"export SUPERSET__FEATURE_FLAGS__ENABLE_TEMPLATE_PROCESSING=true",
|
||||
"export SUPERSET__FEATURE_FLAGS__ENABLE_EXPLORE_DRAG_AND_DROP=true",
|
||||
"",
|
||||
"# Theme configuration",
|
||||
'export SUPERSET__THEME_DEFAULT=\'{"colors": '
|
||||
'{"primary": {"base": "#1985a1"}}}\'',
|
||||
"",
|
||||
"# Lists and complex types",
|
||||
'export SUPERSET__FAB_ROLES=\'["Admin", "Alpha", "Gamma"]\'',
|
||||
"",
|
||||
]
|
||||
|
||||
for line in examples:
|
||||
click.echo(line)
|
||||
|
||||
# Show documented settings if using SupersetConfig
|
||||
if isinstance(app.config, SupersetConfig):
|
||||
click.echo("\n# Documented settings with metadata:")
|
||||
for key, schema in app.config.DATABASE_SETTINGS_SCHEMA.items():
|
||||
click.echo(f"\n# {schema.get('title', key)}")
|
||||
click.echo(f"# {schema.get('description', '')}")
|
||||
click.echo(f"# Type: {schema.get('type', 'unknown')}")
|
||||
if "minimum" in schema or "maximum" in schema:
|
||||
click.echo(
|
||||
f"# Range: {schema.get('minimum', 'N/A')} - "
|
||||
"{schema.get('maximum', 'N/A')}"
|
||||
)
|
||||
click.echo(f"export SUPERSET__{key}={schema.get('default', '...')}")
|
||||
@@ -1,48 +0,0 @@
|
||||
#!/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,18 +22,39 @@ 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 app, appbuilder, cli, security_manager
|
||||
from superset.cli.lib import normalize_token
|
||||
from superset import appbuilder, cli, security_manager
|
||||
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
|
||||
@@ -41,10 +62,6 @@ 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
|
||||
@@ -73,8 +90,14 @@ 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"{app.config['VERSION_STRING']}")
|
||||
print(
|
||||
Fore.YELLOW
|
||||
+ "Superset "
|
||||
+ Fore.CYAN
|
||||
+ f"{current_app.config['VERSION_STRING']}"
|
||||
)
|
||||
print(Fore.BLUE + "-=" * 15)
|
||||
if verbose:
|
||||
print("[DB] : " + f"{db.engine}")
|
||||
|
||||
@@ -18,57 +18,66 @@
|
||||
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
|
||||
|
||||
if feature_flags.get("ENABLE_FACTORY_RESET_COMMAND"):
|
||||
@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"""
|
||||
|
||||
@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"""
|
||||
# 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)
|
||||
|
||||
# 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,10 +18,11 @@ 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 app, security_manager
|
||||
from superset import security_manager
|
||||
from superset.utils.decorators import transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -38,7 +39,7 @@ def load_test_users() -> None:
|
||||
"""
|
||||
print(Fore.GREEN + "Loading a set of users for unit tests")
|
||||
|
||||
if app.config["TESTING"]:
|
||||
if current_app.config["TESTING"]:
|
||||
sm = security_manager
|
||||
|
||||
examples_db = database_utils.get_example_database()
|
||||
|
||||
@@ -28,8 +28,9 @@ from superset.temporary_cache.utils import cache_key
|
||||
class GetFilterStateCommand(GetTemporaryCacheCommand):
|
||||
def __init__(self, cmd_params: CommandParameters) -> None:
|
||||
super().__init__(cmd_params)
|
||||
config = app.config["FILTER_STATE_CACHE_CONFIG"]
|
||||
self._refresh_timeout = config.get("REFRESH_TIMEOUT_ON_RETRIEVAL")
|
||||
self._refresh_timeout = app.config["FILTER_STATE_CACHE_CONFIG"].get(
|
||||
"REFRESH_TIMEOUT_ON_RETRIEVAL"
|
||||
)
|
||||
|
||||
def get(self, cmd_params: CommandParameters) -> Optional[str]:
|
||||
resource_id = cmd_params.resource_id
|
||||
|
||||
@@ -19,10 +19,11 @@ 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 app, db, security_manager
|
||||
from superset import db, security_manager
|
||||
from superset.commands.base import BaseCommand, UpdateMixin
|
||||
from superset.commands.dashboard.exceptions import (
|
||||
DashboardColorsConfigUpdateFailedError,
|
||||
@@ -175,7 +176,7 @@ class UpdateDashboardCommand(UpdateMixin, BaseCommand):
|
||||
to=email,
|
||||
subject=f"[Report: {report.name}] Deactivated",
|
||||
html_content=html_content,
|
||||
config=app.config,
|
||||
config=current_app.config,
|
||||
)
|
||||
|
||||
def deactivate_reports(reports_list: list[ReportSchedule]) -> None:
|
||||
|
||||
@@ -18,7 +18,7 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app
|
||||
from flask import current_app as app
|
||||
from flask_appbuilder.models.sqla import Model
|
||||
from marshmallow import ValidationError
|
||||
|
||||
@@ -48,7 +48,7 @@ from superset.models.core import Database
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
stats_logger = current_app.config["STATS_LOGGER"]
|
||||
stats_logger = app.config["STATS_LOGGER"]
|
||||
|
||||
|
||||
class CreateDatabaseCommand(BaseCommand):
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from superset import app, db, security_manager
|
||||
from flask import current_app as app
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.commands.database.utils import add_permissions
|
||||
from superset.commands.exceptions import ImportFailedError
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
|
||||
@@ -20,9 +20,9 @@ import logging
|
||||
from functools import partial
|
||||
from typing import Iterable
|
||||
|
||||
from flask import current_app, g
|
||||
from flask import current_app as app, g
|
||||
|
||||
from superset import app, security_manager
|
||||
from superset import security_manager
|
||||
from superset.commands.base import BaseCommand
|
||||
from superset.commands.database.exceptions import (
|
||||
DatabaseConnectionFailedError,
|
||||
@@ -320,7 +320,7 @@ def sync_database_permissions_task(
|
||||
"""
|
||||
Celery task that triggers the SyncPermissionsCommand in async mode.
|
||||
"""
|
||||
with current_app.test_request_context():
|
||||
with app.test_request_context():
|
||||
try:
|
||||
user = security_manager.get_user_by_username(username)
|
||||
if not user:
|
||||
|
||||
@@ -18,7 +18,7 @@ import logging
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from flask import current_app
|
||||
from flask import current_app as app
|
||||
from flask_babel import gettext as __
|
||||
|
||||
from superset.commands.base import BaseCommand
|
||||
@@ -63,7 +63,7 @@ class ValidateSQLCommand(BaseCommand):
|
||||
catalog = self._properties.get("catalog")
|
||||
schema = self._properties.get("schema")
|
||||
try:
|
||||
timeout = current_app.config["SQLLAB_VALIDATION_TIMEOUT"]
|
||||
timeout = app.config["SQLLAB_VALIDATION_TIMEOUT"]
|
||||
timeout_msg = f"The query exceeded the {timeout} seconds timeout."
|
||||
with utils.timeout(seconds=timeout, error_message=timeout_msg):
|
||||
errors = self._validator.validate(sql, catalog, schema, self._model)
|
||||
@@ -94,7 +94,7 @@ class ValidateSQLCommand(BaseCommand):
|
||||
raise DatabaseNotFoundError()
|
||||
|
||||
spec = self._model.db_engine_spec
|
||||
validators_by_engine = current_app.config["SQL_VALIDATORS_BY_ENGINE"]
|
||||
validators_by_engine = app.config["SQL_VALIDATORS_BY_ENGINE"]
|
||||
if not validators_by_engine or spec.engine not in validators_by_engine:
|
||||
raise NoValidatorConfigFoundError(
|
||||
SupersetError(
|
||||
|
||||
@@ -21,7 +21,7 @@ from typing import Any
|
||||
from urllib import request
|
||||
|
||||
import pandas as pd
|
||||
from flask import current_app
|
||||
from flask import current_app as app
|
||||
from sqlalchemy import BigInteger, Boolean, Date, DateTime, Float, String, Text
|
||||
from sqlalchemy.exc import MultipleResultsFound
|
||||
from sqlalchemy.sql.visitors import VisitableType
|
||||
@@ -88,7 +88,7 @@ def validate_data_uri(data_uri: str) -> None:
|
||||
:param data_uri:
|
||||
:return:
|
||||
"""
|
||||
allowed_urls = current_app.config["DATASET_IMPORT_ALLOWED_DATA_URLS"]
|
||||
allowed_urls = app.config["DATASET_IMPORT_ALLOWED_DATA_URLS"]
|
||||
for allowed_url in allowed_urls:
|
||||
try:
|
||||
match = re.match(allowed_url, data_uri)
|
||||
@@ -218,7 +218,7 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
df[column_name] = pd.to_datetime(df[column_name])
|
||||
|
||||
# reuse session when loading data if possible, to make import atomic
|
||||
if database.sqlalchemy_uri == current_app.config.get("SQLALCHEMY_DATABASE_URI"):
|
||||
if database.sqlalchemy_uri == app.config.get("SQLALCHEMY_DATABASE_URI"):
|
||||
logger.info("Loading data inside the import transaction")
|
||||
connection = db.session.connection()
|
||||
df.to_sql(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user