Compare commits

..
Author SHA1 Message Date
GitHub Action f3fb775a17 chore(🦾): bump python python-dotenv 1.1.0 -> 1.1.1 2025-07-28 19:12:51 +00:00
310 changed files with 8833 additions and 34273 deletions
-20
View File
@@ -1,20 +0,0 @@
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
FROM python:3.11.13-bookworm AS base
# Install system dependencies that Superset needs
# This layer will be cached across Codespace sessions
RUN apt-get update && apt-get install -y \
libsasl2-dev \
libldap2-dev \
libpq-dev \
tmux \
gh \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast Python package management
# This will also be cached in the image
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
# Set the cargo/bin directory in PATH for all users
ENV PATH="/root/.cargo/bin:${PATH}"
-16
View File
@@ -1,16 +0,0 @@
# Superset Development with GitHub Codespaces
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
View File
@@ -1,62 +0,0 @@
# Superset Codespaces environment setup
# This file is appended to ~/.bashrc during Codespace setup
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
# Check if virtual environment exists
if [ -d "$WORKSPACE_DIR/.venv" ]; then
# Activate the virtual environment
source "$WORKSPACE_DIR/.venv/bin/activate"
echo "✅ Python virtual environment activated"
# Verify pre-commit is installed and set up
if command -v pre-commit &> /dev/null; then
echo "✅ pre-commit is available ($(pre-commit --version))"
# Install git hooks if not already installed
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
echo "🪝 Installing pre-commit hooks..."
cd "$WORKSPACE_DIR" && pre-commit install
fi
else
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
fi
else
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
fi
# Always cd to the workspace directory for convenience
cd "$WORKSPACE_DIR"
fi
# Add helpful aliases for Superset development
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
# Show helpful message on login
echo ""
echo "🚀 Superset Codespaces Environment"
echo "=================================="
# Check if Superset is running
if docker ps 2>/dev/null | grep -q "superset"; then
echo "✅ Superset is running!"
echo " - Check the 'Ports' tab for your live Superset URL"
echo " - Initial startup takes 10-20 minutes"
echo " - Login: admin/admin"
else
echo "⚠️ Superset is not running. Use: start-superset"
# Check if there's a startup log
if [ -f "/tmp/superset-startup.log" ]; then
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
fi
fi
echo ""
echo "Quick commands:"
echo " start-superset - Start Superset with Docker Compose"
echo " setup-dev - Set up Python environment (if not already done)"
echo " pre-commit run - Run pre-commit checks on staged files"
echo ""
-20
View File
@@ -1,20 +0,0 @@
#!/bin/bash
# Script to build and push the devcontainer image to GitHub Container Registry
# This allows caching the image between Codespace sessions
# You'll need to run this with appropriate GitHub permissions
# gh auth login --scopes write:packages
REGISTRY="ghcr.io"
OWNER="apache"
REPO="superset"
TAG="devcontainer-base"
echo "Building devcontainer image..."
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
echo "Pushing to GitHub Container Registry..."
docker push $REGISTRY/$OWNER/$REPO:$TAG
echo "Done! Update .devcontainer/devcontainer.json to use:"
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""
-66
View File
@@ -1,66 +0,0 @@
{
"name": "Apache Superset Development",
// Option 1: Use pre-built image directly
// "image": "ghcr.io/apache/superset:devcontainer-base",
// Option 2: Build from Dockerfile with cache (current approach)
"build": {
"dockerfile": "Dockerfile",
"context": ".",
// Cache from the Apache registry image
"cacheFrom": ["ghcr.io/apache/superset:devcontainer-base"]
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"dockerDashComposeVersion": "v2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Forward ports for development
"forwardPorts": [9001],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
}
},
// Run commands after container is created
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
// 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": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}
-78
View File
@@ -1,78 +0,0 @@
#!/bin/bash
# Setup script for Superset Codespaces development environment
echo "🔧 Setting up Superset development environment..."
# System dependencies and uv are now pre-installed in the Docker image
# This speeds up Codespace creation significantly!
# Create virtual environment using uv
echo "🐍 Creating Python virtual environment..."
if ! uv venv; then
echo "❌ Failed to create virtual environment"
exit 1
fi
# 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..."
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 ""
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 ""
-108
View File
@@ -1,108 +0,0 @@
#!/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"
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
cd "$WORKSPACE_DIR"
echo "📁 Working in: $WORKSPACE_DIR"
else
echo "📁 Using current directory: $(pwd)"
fi
# Wait for Docker to be available
echo "⏳ Waiting for Docker to start..."
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
max_attempts=30
attempt=0
while ! docker info > /dev/null 2>&1; do
if [ $attempt -eq $max_attempts ]; then
echo "❌ Docker failed to start after $max_attempts attempts"
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
echo "🔄 Please restart the Codespace or run this script manually later"
exit 1
fi
echo " Attempt $((attempt + 1))/$max_attempts..."
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
sleep 2
attempt=$((attempt + 1))
done
echo "✅ Docker is ready!"
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
# Check if Superset containers are already running
if docker ps | grep -q "superset"; then
echo "✅ Superset containers are already running!"
echo ""
echo "🌐 To access Superset:"
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
echo " 2. Find port 9001 and click the globe icon to open"
echo " 3. Wait 10-20 minutes for initial startup"
echo ""
echo "📝 Login credentials: admin/admin"
exit 0
fi
# Clean up any existing containers
echo "🧹 Cleaning up existing containers..."
docker-compose -f docker-compose-light.yml down
# Start services
echo "🏗️ Starting Superset in background (daemon mode)..."
echo ""
# 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
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
echo ""
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
echo ""
echo "🔄 To restart Superset, run:"
echo " .devcontainer/start-superset.sh"
echo ""
echo "🔧 For troubleshooting:"
echo " # View logs:"
echo " docker-compose -f docker-compose-light.yml logs"
echo ""
echo " # Clean restart (removes volumes):"
echo " docker-compose -f docker-compose-light.yml down -v"
echo " .devcontainer/start-superset.sh"
echo ""
echo " # Common issues:"
echo " - Network timeouts: Just retry, often transient"
echo " - Port conflicts: Check 'docker ps'"
echo " - Database issues: Try clean restart with -v"
fi
+1 -3
View File
@@ -47,12 +47,10 @@ jobs:
java-version: '21'
- name: Install Graphviz
run: sudo apt-get install -y graphviz
- name: Generate documentation artifacts
- name: Compute Entity Relationship diagram (ERD)
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"
@@ -64,11 +64,6 @@ 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
View File
@@ -131,6 +131,3 @@ superset/static/stats/statistics.html
# LLM-related
CLAUDE.local.md
.aider*
# Temporary scratchpad for development
.scratchpad/
+3 -3
View File
@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
# as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that it's not possible to selectively COPY or mount using blobs.
# Note that's it's not possible selectively COPY of mount using blobs.
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \
@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
COPY superset-frontend /app/superset-frontend
######################################################################
# superset-node is used for compiling frontend assets
# superset-node used for compile frontend assets
######################################################################
FROM superset-node-ci AS superset-node
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
# Copy translation files
COPY superset/translations /app/superset/translations
# Build translations if enabled, then cleanup localization files
# Build the frontend if not in dev mode
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
npm run build-translation; \
fi; \
+6 -38
View File
@@ -23,57 +23,25 @@ MIN_MEM_FREE_GB=3
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
echo_mem_warn() {
# Check if running in Codespaces first
if [[ -n "${CODESPACES}" ]]; then
echo "Memory available: Codespaces managed"
return
fi
MEM_FREE_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_FREE_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
# Check platform and get memory accordingly
if [[ -f /proc/meminfo ]]; then
# Linux
if grep -q MemAvailable /proc/meminfo; then
MEM_AVAIL_KB=$(awk '/MemAvailable/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_AVAIL_GB=$(awk '/MemAvailable/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
else
MEM_AVAIL_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_AVAIL_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
fi
elif [[ "$(uname)" == "Darwin" ]]; then
# macOS - use vm_stat to get free memory
# vm_stat reports in pages, typically 4096 bytes per page
PAGE_SIZE=$(pagesize)
FREE_PAGES=$(vm_stat | awk '/Pages free:/ {print $3}' | tr -d '.')
INACTIVE_PAGES=$(vm_stat | awk '/Pages inactive:/ {print $3}' | tr -d '.')
# Free + inactive pages give us available memory (similar to MemAvailable on Linux)
AVAIL_PAGES=$((FREE_PAGES + INACTIVE_PAGES))
MEM_AVAIL_KB=$((AVAIL_PAGES * PAGE_SIZE / 1024))
MEM_AVAIL_GB=$(echo "scale=2; $MEM_AVAIL_KB / 1024 / 1024" | bc)
else
# Other platforms
echo "Memory available: Unable to determine"
return
fi
if [[ "${MEM_AVAIL_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
cat <<EOF
===============================================
======== Memory Insufficient Warning =========
===============================================
It looks like you only have ${MEM_AVAIL_GB}GB of
memory ${MEM_TYPE}. Please increase your Docker
It looks like you only have ${MEM_FREE_GB}GB of
memory free. Please increase your Docker
resources to at least ${MIN_MEM_FREE_GB}GB
Note: During builds, available memory may be
temporarily low due to caching and compilation.
===============================================
======== Memory Insufficient Warning =========
===============================================
EOF
else
echo "Memory available: ${MEM_AVAIL_GB} GB"
echo "Memory check Ok [${MEM_FREE_GB}GB free]"
fi
}
@@ -1,86 +0,0 @@
---
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,36 +1,19 @@
---
title: Configuring Superset
hide_title: true
sidebar_position: 2
sidebar_position: 1
version: 1
---
# Configuring Superset
## 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_config.py
Superset exposes hundreds of configurable parameters through its
[config_defaults.py module](https://github.com/apache/superset/blob/master/superset/config_defaults.py). The
[config.py module](https://github.com/apache/superset/blob/master/superset/config.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 structured metadata documentation.
parameters, sensible defaults, as well as rich documentation in the form of comments
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,
@@ -94,12 +77,12 @@ MAPBOX_API_KEY = ''
:::tip
Note that it is typical to copy and paste [only] the portions of the
core [superset/config_defaults.py](https://github.com/apache/superset/blob/master/superset/config_defaults.py) that
core [superset/config.py](https://github.com/apache/superset/blob/master/superset/config.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_defaults.py](https://github.com/apache/superset/blob/master/superset/config_defaults.py)
in [superset/config.py](https://github.com/apache/superset/blob/master/superset/config.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.
@@ -114,102 +97,6 @@ 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
@@ -659,93 +546,3 @@ 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
```
-58
View File
@@ -87,66 +87,8 @@ Restart Superset to apply changes.
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
## Custom Fonts
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Configuring Custom Fonts
Add font URLs to your `superset_config.py`:
```python
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
CUSTOM_FONT_URLS = [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
]
# Update CSP to allow font sources
TALISMAN_CONFIG = {
"content_security_policy": {
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
}
}
```
### Using Custom Fonts in Themes
Once configured, reference the fonts in your theme configuration:
```python
THEME_DEFAULT = {
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
```
Or in the CRUD interface theme JSON:
```json
{
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
}
}
```
### Font Sources
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
This feature works with the stock Docker image - no custom build required!
## Advanced Features
- **System Themes**: Superset includes built-in light and dark themes
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
- **JSON Editor**: Edit theme configurations directly within Superset's interface
- **Custom Fonts**: Load external fonts via configuration without rebuilding
+8 -72
View File
@@ -120,78 +120,6 @@ docker volume rm superset_db_home
docker-compose up
```
## GitHub Codespaces (Cloud Development)
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
- Quick contributions without local setup
- Consistent development environments across team members
- Working from devices that can't run Docker locally
- Safe experimentation in isolated environments
:::info
We're grateful to GitHub for providing this excellent cloud development service that makes
contributing to Apache Superset more accessible to developers worldwide.
:::
### Getting Started with Codespaces
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
:::caution
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
Smaller instances will not have sufficient resources to run Superset effectively.
:::
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
- Build the development container
- Install all dependencies
- Start all required services (PostgreSQL, Redis, etc.)
- Initialize the database with example data
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
Click the globe icon to open Superset in your browser.
- Default credentials: `admin` / `admin`
### Key Features
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
- **Multiple Instances**: Run multiple Codespaces for different branches/features
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
- **VS Code Integration**: Works seamlessly with VS Code desktop app
### Managing Codespaces
- **List active Codespaces**: `gh cs list`
- **SSH into a Codespace**: `gh cs ssh`
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
### Debugging and Logs
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
```bash
# Stream logs from all services
docker compose -f docker-compose-light.yml logs -f
# Stream logs from a specific service
docker compose -f docker-compose-light.yml logs -f superset
# View last 100 lines and follow
docker compose -f docker-compose-light.yml logs --tail=100 -f
# List all running services
docker compose -f docker-compose-light.yml ps
```
:::tip
Codespaces automatically stop after 30 minutes of inactivity to save resources.
Your work is preserved and you can restart anytime.
:::
## Installing Development Tools
:::note
@@ -421,6 +349,14 @@ Then make sure you run your WSGI server using the right worker type:
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
```
You can log anything to the browser console, including objects:
```python
from superset import app
app.logger.error('An exception occurred!')
app.logger.info(form_data)
```
### Frontend
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
+2 -6
View File
@@ -6,9 +6,8 @@
"scripts": {
"docusaurus": "docusaurus",
"_init": "cat src/intro_header.txt ../README.md > docs/intro.md",
"_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",
"start": "yarn run _init && docusaurus start",
"build": "yarn run _init && DEBUG=docusaurus:* docusaurus build",
"swizzle": "docusaurus swizzle",
"deploy": "docusaurus deploy",
"clear": "docusaurus clear",
@@ -27,8 +26,6 @@
"@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",
@@ -37,7 +34,6 @@
"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
View File
@@ -1,116 +0,0 @@
#!/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
View File
@@ -1,101 +0,0 @@
#!/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
View File
@@ -1,329 +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 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;
@@ -1,181 +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 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;
File diff suppressed because it is too large Load Diff
+58 -2835
View File
File diff suppressed because it is too large Load Diff
-42
View File
@@ -3993,26 +3993,6 @@ 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"
@@ -7240,11 +7220,6 @@ 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"
@@ -10912,23 +10887,6 @@ 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"
+1 -1
View File
@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.15.0",
"sqlalchemy-bigquery>=1.6.1",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
+12 -2
View File
@@ -11,7 +11,9 @@ apispec==6.6.1
apsw==3.50.1.0
# via shillelagh
async-timeout==4.0.3
# via -r requirements/base.in
# via
# -r requirements/base.in
# redis
attrs==25.3.0
# via
# cattrs
@@ -97,6 +99,11 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
exceptiongroup==1.3.0
# via
# cattrs
# trio
# trio-websocket
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -154,6 +161,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -310,7 +318,7 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.1.0
python-dotenv==1.1.1
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
@@ -395,9 +403,11 @@ typing-extensions==4.14.0
# apache-superset (pyproject.toml)
# alembic
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2
+22 -2
View File
@@ -20,6 +20,10 @@ apsw==3.50.1.0
# shillelagh
astroid==3.3.10
# via pylint
async-timeout==4.0.3
# via
# -c requirements/base.txt
# redis
attrs==25.3.0
# via
# -c requirements/base.txt
@@ -176,6 +180,13 @@ et-xmlfile==2.0.0
# via
# -c requirements/base.txt
# openpyxl
exceptiongroup==1.3.0
# via
# -c requirements/base.txt
# cattrs
# pytest
# trio
# trio-websocket
filelock==3.12.2
# via virtualenv
flask==2.3.3
@@ -313,6 +324,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -670,7 +682,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.1.0
python-dotenv==1.1.1
# via
# -c requirements/base.txt
# apache-superset
@@ -795,7 +807,7 @@ sqlalchemy==1.4.54
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-utils
sqlalchemy-bigquery==1.15.0
sqlalchemy-bigquery==1.12.0
# via apache-superset
sqlalchemy-utils==0.38.3
# via
@@ -818,6 +830,11 @@ tabulate==0.9.0
# via
# -c requirements/base.txt
# apache-superset
tomli==2.2.1
# via
# coverage
# pylint
# pytest
tomlkit==0.13.3
# via pylint
tqdm==4.67.1
@@ -840,10 +857,13 @@ typing-extensions==4.14.0
# -c requirements/base.txt
# alembic
# apache-superset
# astroid
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2
-291
View File
@@ -1,291 +0,0 @@
#!/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
View File
@@ -1,324 +0,0 @@
#!/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()
+1 -1
View File
@@ -33,4 +33,4 @@ superset load-test-users
echo "Running tests"
pytest --durations-min=2 --cov-report= --cov=superset ./tests/integration_tests "$@"
pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset ./tests/integration_tests "$@"
File diff suppressed because it is too large Load Diff
@@ -94,12 +94,67 @@ describe('Charts list', () => {
});
});
describe('list mode', () => {
before(() => {
visitChartList();
setGridMode('list');
});
it('should load rows in list mode', () => {
cy.getBySel('listview-table').should('be.visible');
cy.getBySel('sort-header').eq(1).contains('Name');
cy.getBySel('sort-header').eq(2).contains('Type');
cy.getBySel('sort-header').eq(3).contains('Dataset');
cy.getBySel('sort-header').eq(4).contains('On dashboards');
cy.getBySel('sort-header').eq(5).contains('Owners');
cy.getBySel('sort-header').eq(6).contains('Last modified');
cy.getBySel('sort-header').eq(7).contains('Actions');
});
it('should bulk select in list mode', () => {
toggleBulkSelect();
cy.get('[aria-label="Select all"]').click();
cy.get('input[type="checkbox"]:checked').should('have.length', 26);
cy.getBySel('bulk-select-copy').contains('25 Selected');
cy.getBySel('bulk-select-action')
.should('have.length', 2)
.then($btns => {
expect($btns).to.contain('Delete');
expect($btns).to.contain('Export');
});
cy.getBySel('bulk-select-deselect-all').click();
cy.get('input[type="checkbox"]:checked').should('have.length', 0);
cy.getBySel('bulk-select-copy').contains('0 Selected');
cy.getBySel('bulk-select-action').should('not.exist');
});
});
describe('card mode', () => {
before(() => {
visitChartList();
setGridMode('card');
});
it('should load rows in card mode', () => {
cy.getBySel('listview-table').should('not.exist');
cy.getBySel('styled-card').should('have.length', 25);
});
it('should bulk select in card mode', () => {
toggleBulkSelect();
cy.getBySel('styled-card').click({ multiple: true });
cy.getBySel('bulk-select-copy').contains('25 Selected');
cy.getBySel('bulk-select-action')
.should('have.length', 2)
.then($btns => {
expect($btns).to.contain('Delete');
expect($btns).to.contain('Export');
});
cy.getBySel('bulk-select-deselect-all').click();
cy.getBySel('bulk-select-copy').contains('0 Selected');
cy.getBySel('bulk-select-action').should('not.exist');
});
it('should preserve other filters when sorting', () => {
cy.getBySel('styled-card').should('have.length', 25);
setFilter('Type', 'Big Number');
@@ -65,16 +65,11 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
.then($el => {
cy.wrap($el)
.contains(new RegExp(`^${targetDrillByColumn}$`))
.trigger('keydown', { keyCode: 13, which: 13, force: true });
});
if (isLegacy) {
return cy.wait('@legacyData');
@@ -245,7 +240,7 @@ describe('Drill by modal', () => {
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
it('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
@@ -22,7 +22,6 @@ import {
dataTestChartName,
} from 'cypress/support/directories';
import { waitForChartLoad } from 'cypress/utils';
import {
addParentFilterWithValue,
applyNativeFilterValueWithIndex,
@@ -161,74 +160,6 @@ describe('Native filters', () => {
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
// cannot use saveNativeFilterSettings because there is a bug which
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
// to be saved when using dependent filters so,
// you reload the window.
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
cy.get(nativeFilters.modal.container).should('not.exist');
cy.reload();
applyNativeFilterValueWithIndex(0, 'North America');
// Check that dependent filter auto-selects the first item
cy.get(nativeFilters.filterFromDashboardView.filterContent)
.eq(1)
.should('contain.text', 'Bermuda');
});
it('User can create filter depend on 2 other filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },
@@ -68,13 +68,11 @@ function verifyDashboardSearch() {
function verifyDashboardLink() {
interceptDashboardGet();
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
force: true,
});
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a')
.first()
.invoke('removeAttr', 'target')
.click({ force: true });
.click();
cy.wait('@get');
}
+30 -1603
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -121,8 +121,8 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
@@ -36,7 +36,7 @@
"devDependencies": {
"cross-env": "^7.0.3",
"fs-extra": "^11.3.0",
"jest": "^30.0.4",
"jest": "^30.0.2",
"yeoman-test": "^10.1.1"
},
"engines": {
@@ -41,53 +41,6 @@ import {
import { checkColumnType } from '../utils/checkColumnType';
import { isSortable } from '../utils/isSortable';
// Aggregation choices with computation methods for plugins and controls
export const aggregationChoices = {
raw: {
label: 'Overall value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) : null,
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) / data.length : null,
},
min: {
label: 'Minimum',
compute: (data: number[]) => (data.length ? Math.min(...data) : null),
},
max: {
label: 'Maximum',
compute: (data: number[]) => (data.length ? Math.max(...data) : null),
},
median: {
label: 'Median',
compute: (data: number[]) => {
if (!data.length) return null;
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
} as const;
export const contributionModeControl = {
name: 'contributionMode',
config: {
@@ -116,12 +69,17 @@ export const aggregationControl = {
default: 'LAST_VALUE',
clearable: false,
renderTrigger: false,
choices: Object.entries(aggregationChoices).map(([value, { label }]) => [
value,
t(label),
]),
choices: [
['raw', t('None')],
['LAST_VALUE', t('Last Value')],
['sum', t('Total (Sum)')],
['mean', t('Average (Mean)')],
['min', t('Minimum')],
['max', t('Maximum')],
['median', t('Median')],
],
description: t(
'Method to compute the displayed value. "Overall value" calculates a single metric across the entire filtered time period, ideal for non-additive metrics like ratios, averages, or distinct counts. Other methods operate over the time series data points.',
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
),
provideFormDataToProps: true,
mapStateToProps: ({ form_data }: ControlPanelState) => ({
@@ -177,7 +177,6 @@ const granularity: SharedControlConfig<'SelectControl'> = {
'can type and use simple natural language as in `10 seconds`, ' +
'`1 day` or `56 weeks`',
),
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
};
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
@@ -205,7 +204,6 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
choices: (datasource as Dataset)?.time_grain_sqla || [],
}),
visibility: displayTimeRelatedControls,
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
};
const time_range: SharedControlConfig<'DateFilterControl'> = {
@@ -37,7 +37,7 @@
"d3-format": "^1.3.2",
"dayjs": "^1.11.13",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale": "^3.0.0",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dompurify": "^3.2.4",
@@ -59,7 +59,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.1.1",
"reselect": "^4.0.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",
@@ -17,8 +17,11 @@
* under the License.
*/
/** Type checking is disabled for this file due to reselect only supporting
* TS declarations for selectors with up to 12 arguments. */
// @ts-nocheck
import { RefObject } from 'react';
import { createSelector, lruMemoize } from 'reselect';
import { createSelector } from 'reselect';
import {
AppSection,
Behavior,
@@ -34,7 +37,7 @@ import {
SetDataMaskHook,
} from '../types/Base';
import { QueryData, DataRecordFilters } from '..';
import { supersetTheme, SupersetTheme } from '../../theme';
import { SupersetTheme } from '../../theme';
// TODO: more specific typing for these fields of ChartProps
type AnnotationData = PlainObject;
@@ -106,8 +109,6 @@ export interface ChartPropsConfig {
theme: SupersetTheme;
/* legend index */
legendIndex?: number;
inContextMenu?: boolean;
emitCrossFilters?: boolean;
}
const DEFAULT_WIDTH = 800;
@@ -160,11 +161,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
theme: SupersetTheme;
constructor(
config: ChartPropsConfig & { formData?: FormData } = {
theme: supersetTheme,
},
) {
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
const {
annotationData = {},
datasource = {},
@@ -279,16 +276,5 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
emitCrossFilters,
theme,
}),
// Below config is to retain usage of 1-sized `lruMemoize` object in Reselect v4
// Reselect v5 introduces `weakMapMemoize` which is more performant but potentially memory-leaky
// due to infinite cache size.
// Source: https://github.com/reduxjs/reselect/releases/tag/v5.0.1
{
memoize: lruMemoize,
argsMemoize: lruMemoize,
memoizeOptions: {
maxSize: 10,
},
},
);
};
@@ -17,7 +17,7 @@
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import { TelemetryPixel } from '.';
import TelemetryPixel from '.';
const OLD_ENV = process.env;
@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
export const TelemetryPixel = ({
const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
build = 'unknownBuild',
@@ -56,3 +56,4 @@ export const TelemetryPixel = ({
/>
);
};
export default TelemetryPixel;
@@ -0,0 +1,116 @@
/**
* 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 { Dropdown, Icons } from '@superset-ui/core/components';
import type { MenuItem } from '@superset-ui/core/components/Menu';
import { t, useTheme } from '@superset-ui/core';
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
export interface ThemeSelectProps {
setThemeMode: (newMode: ThemeMode) => void;
tooltipTitle?: string;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({
setThemeMode,
tooltipTitle = 'Select theme',
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
};
// Use different icon when local theme is active
const triggerIcon = hasLocalOverride ? (
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
) : (
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
);
const menuItems: MenuItem[] = [
{
type: 'group',
label: t('Theme'),
},
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
if (onClearLocalSettings && hasLocalOverride) {
menuItems.push(
{ type: 'divider' } as MenuItem,
{
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
} as MenuItem,
);
}
return (
<Dropdown
menu={{
items: menuItems,
selectedKeys: [themeMode],
}}
trigger={['hover']}
>
{triggerIcon}
</Dropdown>
);
};
export default ThemeSelect;
@@ -1,273 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { ThemeMode } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components';
import { ThemeSubMenu } from '.';
// Mock the translation function
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
t: (key: string) => key,
}));
describe('ThemeSubMenu', () => {
const defaultProps = {
allowOSPreference: true,
setThemeMode: jest.fn(),
themeMode: ThemeMode.DEFAULT,
hasLocalOverride: false,
onClearLocalSettings: jest.fn(),
};
const renderThemeSubMenu = (props = defaultProps) =>
render(
<Menu>
<ThemeSubMenu {...props} />
</Menu>,
);
const findMenuWithText = async (text: string) => {
await waitFor(() => {
const found = screen
.getAllByRole('menu')
.some(m => within(m).queryByText(text));
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
});
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders Light and Dark theme options by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
expect(within(menu!).getByText('Light')).toBeInTheDocument();
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
});
it('does not render Match system option when allowOSPreference is false', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
});
});
it('renders with allowOSPreference as true by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
expect(within(menu).getByText('Match system')).toBeInTheDocument();
});
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
});
it('does not render clear option when hasLocalOverride is false', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: false,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
});
});
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
userEvent.click(within(menu).getByText('Light'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
});
it('calls setThemeMode with DARK when Dark is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
userEvent.click(within(menu).getByText('Dark'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
});
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
userEvent.click(within(menu).getByText('Match system'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
});
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
userEvent.click(within(menu).getByText('Clear local theme'));
expect(mockClear).toHaveBeenCalledTimes(1);
});
it('displays sun icon for DEFAULT theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
expect(screen.getByTestId('sun')).toBeInTheDocument();
});
it('displays moon icon for DARK theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
expect(screen.getByTestId('moon')).toBeInTheDocument();
});
it('displays format-painter icon for SYSTEM theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('displays override icon when hasLocalOverride is true', () => {
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('renders Theme group header', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Theme');
expect(within(menu).getByText('Theme')).toBeInTheDocument();
});
it('renders sun icon for Light theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
const lightOption = within(menu).getByText('Light').closest('li');
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
});
it('renders moon icon for Dark theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
const darkOption = within(menu).getByText('Dark').closest('li');
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
});
it('renders format-painter icon for Match system option', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
const matchOption = within(menu).getByText('Match system').closest('li');
expect(
within(matchOption!).getByTestId('format-painter'),
).toBeInTheDocument();
});
it('renders clear icon for Clear local theme option', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const clearOption = within(menu)
.getByText('Clear local theme')
.closest('li');
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
});
it('renders divider before clear option when clear option is present', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const divider = within(menu).queryByRole('separator');
expect(divider).toBeInTheDocument();
});
it('does not render divider when clear option is not present', async () => {
renderThemeSubMenu({ ...defaultProps });
userEvent.hover(await screen.findByRole('menuitem'));
const divider = document.querySelector('.ant-menu-item-divider');
expect(divider).toBeNull();
});
});
@@ -1,170 +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 { useMemo } from 'react';
import { Icons, Menu } from '@superset-ui/core/components';
import {
css,
styled,
t,
ThemeMode,
useTheme,
ThemeAlgorithm,
} from '@superset-ui/core';
const StyledThemeSubMenu = styled(Menu.SubMenu)`
${({ theme }) => css`
[data-icon='caret-down'] {
color: ${theme.colorIcon};
font-size: ${theme.fontSizeXS}px;
margin-left: ${theme.sizeUnit}px;
}
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
`}
`;
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
${({ theme, selected }) => css`
&:hover {
color: ${theme.colorPrimary} !important;
cursor: pointer !important;
}
${selected &&
css`
background-color: ${theme.colors.primary.light4} !important;
color: ${theme.colors.primary.dark1} !important;
`}
`}
`;
export interface ThemeSubMenuOption {
key: ThemeMode;
label: string;
icon: React.ReactNode;
onClick: () => void;
}
export interface ThemeSubMenuProps {
setThemeMode: (newMode: ThemeMode) => void;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
setThemeMode,
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}: ThemeSubMenuProps) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
useMemo(
() => ({
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
}),
[],
);
const selectedThemeModeIcon = useMemo(
() =>
hasLocalOverride ? (
<Icons.FormatPainterOutlined
style={{ color: theme.colors.error.base }}
/>
) : (
themeIconMap[themeMode]
),
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
);
const themeOptions: ThemeSubMenuOption[] = [
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
const clearOption =
onClearLocalSettings && hasLocalOverride
? {
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
}
: null;
return (
<StyledThemeSubMenu
key="theme-sub-menu"
title={selectedThemeModeIcon}
icon={<Icons.CaretDownOutlined iconSize="xs" />}
>
<Menu.ItemGroup title={t('Theme')} />
{themeOptions.map(option => (
<StyledThemeSubMenuItem
key={option.key}
onClick={option.onClick}
selected={option.key === themeMode}
>
{option.icon} {option.label}
</StyledThemeSubMenuItem>
))}
{clearOption && [
<Menu.Divider key="theme-divider" />,
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
{clearOption.icon} {clearOption.label}
</Menu.Item>,
]}
</StyledThemeSubMenu>
);
};
@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import { t, css, useTheme } from '@superset-ui/core';
import {
Icons,
Modal,
Typography,
Button,
Flex,
} from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
export type UnsavedChangesModalProps = {
@@ -36,30 +42,66 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
{title}
</>
}
footer={
<>
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
{t('Discard')}
</Button>
<Button buttonStyle="primary" onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
}): ReactElement => {
const theme = useTheme();
return (
<Modal
name={title}
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<Flex>
<Icons.WarningOutlined
iconColor={theme.colorWarning}
css={css`
margin-right: ${theme.sizeUnit * 2}px;
`}
iconSize="l"
/>
<Typography.Title
css={css`
&& {
margin: 0;
margin-bottom: 0;
}
`}
level={5}
>
{title}
</Typography.Title>
</Flex>
}
footer={
<Flex
justify="flex-end"
css={css`
width: 100%;
`}
>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="secondary"
onClick={onConfirmNavigation}
>
{t('Discard')}
</Button>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="primary"
onClick={handleSave}
>
{t('Save')}
</Button>
</Flex>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
};
@@ -164,8 +164,6 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';
@@ -26,9 +26,7 @@ import {
type ThemeStorage,
type ThemeControllerOptions,
type ThemeContextType,
type SupersetThemeConfig,
ThemeAlgorithm,
ThemeMode,
} from './types';
export {
@@ -68,16 +66,7 @@ const themeObject: Theme = Theme.fromConfig({
const { theme } = themeObject;
const supersetTheme = theme;
export {
Theme,
ThemeAlgorithm,
ThemeMode,
themeObject,
styled,
theme,
supersetTheme,
};
export { Theme, themeObject, styled, theme, supersetTheme };
export type {
SupersetTheme,
SerializableThemeConfig,
@@ -85,7 +74,6 @@ export type {
ThemeStorage,
ThemeControllerOptions,
ThemeContextType,
SupersetThemeConfig,
};
// Export theme utility functions
@@ -429,16 +429,3 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
}
/**
* Configuration object for complete theme setup including default, dark themes and settings
*/
export interface SupersetThemeConfig {
theme_default: AnyThemeConfig;
theme_dark?: AnyThemeConfig;
theme_settings?: {
enforced?: boolean;
allowSwitching?: boolean;
allowOSPreference?: boolean;
};
}
@@ -119,7 +119,7 @@ describe('ChartProps', () => {
});
expect(props1).not.toBe(props2);
});
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
it('selector returns a new chartProps if some input fields change', () => {
const props1 = selector({
width: 800,
height: 600,
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
theme: supersetTheme,
});
expect(props1).not.toBe(props2);
expect(props1).toBe(props3);
expect(props1).not.toBe(props3);
});
});
});
@@ -1385,7 +1385,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
((dims = dimensionsForPoint(p)),
(dims = dimensionsForPoint(p)),
(strum = {
p1: p,
dims: dims,
@@ -1393,7 +1393,7 @@ export default function (config) {
maxX: xscale(dims.right),
minY: 0,
maxY: h(),
}));
});
strums[dims.i] = strum;
strums.active = dims.i;
@@ -1942,7 +1942,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
((dims = dimensionsForPoint(p)),
(dims = dimensionsForPoint(p)),
(arc = {
p1: p,
dims: dims,
@@ -1953,7 +1953,7 @@ export default function (config) {
startAngle: undefined,
endAngle: undefined,
arc: d3.svg.arc().innerRadius(0),
}));
});
arcs[dims.i] = arc;
arcs.active = dims.i;
@@ -27,8 +27,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",
@@ -18,7 +18,7 @@
*/
/* eslint-disable import/no-extraneous-dependencies */
import { useState } from 'react';
import { Dropdown } from 'antd';
import { Dropdown, Menu } from 'antd';
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
import { t } from '@superset-ui/core';
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
@@ -69,42 +69,34 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
return (
<Dropdown
placement="bottomRight"
open={showComparisonDropdown}
onOpenChange={(flag: boolean) => {
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
menu={{
multiple: true,
onClick: handleOnClick,
onBlur: handleOnBlur,
selectedKeys: selectedComparisonColumns,
items: [
{
key: 'all',
label: (
<InfoText>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<InfoText>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</InfoText>
{comparisonColumns.map((column: ComparisonColumn) => (
<Menu.Item key={column.key}>
<ColumnLabel>{column.label}</ColumnLabel>
<CheckIconWrapper>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</InfoText>
),
type: 'group',
children: comparisonColumns.map((column: ComparisonColumn) => ({
key: column.key,
label: (
<>
<ColumnLabel>{column.label}</ColumnLabel>
<CheckIconWrapper>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</CheckIconWrapper>
</>
),
})),
},
],
}}
</CheckIconWrapper>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>
@@ -49,53 +49,38 @@ describe('BigNumberWithTrendline buildQuery', () => {
aggregation: null,
};
it('creates raw metric query when aggregation is "raw"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
it('creates raw metric query when aggregation is null', () => {
const queryContext = buildQuery({ ...baseFormData });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([]);
expect(bigNumberQuery.is_timeseries).toBe(false);
expect(bigNumberQuery.columns).toEqual([]);
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns single query for aggregation methods that can be computed client-side', () => {
it('adds aggregation operator when aggregation is "sum"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
const bigNumberQuery = queryContext.queries[1];
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
expect(bigNumberQuery.post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
{ operation: 'aggregation', options: { operator: 'sum' } },
]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns single query for LAST_VALUE aggregation', () => {
it('skips aggregation when aggregation is LAST_VALUE', () => {
const queryContext = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
const bigNumberQuery = queryContext.queries[1];
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
]);
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns two queries only for raw aggregation', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
it('always returns two queries', () => {
const queryContext = buildQuery({ ...baseFormData });
expect(queryContext.queries.length).toBe(2);
const queryContextLastValue = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
expect(queryContextLastValue.queries.length).toBe(1);
const queryContextSum = buildQuery({ ...baseFormData, aggregation: 'sum' });
expect(queryContextSum.queries.length).toBe(1);
});
});
@@ -39,37 +39,28 @@ export default function buildQuery(formData: QueryFormData) {
? ensureIsArray(getXAxisColumn(formData))
: [];
return buildQueryContext(formData, baseQueryObject => {
const queries = [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
].filter(Boolean),
},
];
// Only add second query for raw metrics which need different query structure
// All other aggregations (sum, mean, min, max, median, LAST_VALUE) can be computed client-side from trendline data
if (formData.aggregation === 'raw') {
queries.push({
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: ([
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
].filter(Boolean) as any[]),
});
}
return queries;
});
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
],
},
{
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
},
]);
}
@@ -20,41 +20,6 @@ import { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
// Mock chart-controls to avoid styled-components issues in Jest
jest.mock('@superset-ui/chart-controls', () => ({
aggregationChoices: {
raw: {
label: 'Force server-side aggregation',
compute: (data: number[]) => data[0] ?? null,
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => data[0] ?? null,
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) => data.reduce((a, b) => a + b, 0),
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.reduce((a, b) => a + b, 0) / data.length,
},
min: { label: 'Minimum', compute: (data: number[]) => Math.min(...data) },
max: { label: 'Maximum', compute: (data: number[]) => Math.max(...data) },
median: {
label: 'Median',
compute: (data: number[]) => {
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
},
}));
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
@@ -253,7 +218,7 @@ describe('BigNumberWithTrendline transformProps', () => {
coltypes: ['NUMERIC'],
},
],
formData: { ...baseFormData, aggregation: 'sum' },
formData: { ...baseFormData, aggregation: 'SUM' },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
@@ -29,7 +29,6 @@ import {
tooltipHtml,
} from '@superset-ui/core';
import { EChartsCoreOption, graphic } from 'echarts/core';
import { aggregationChoices } from '@superset-ui/chart-controls';
import {
BigNumberVizProps,
BigNumberDatum,
@@ -44,31 +43,6 @@ const formatPercentChange = getNumberFormatter(
NumberFormats.PERCENT_SIGNED_1_POINT,
);
// Client-side aggregation function using shared aggregationChoices
function computeClientSideAggregation(
data: [number | null, number | null][],
aggregation: string | undefined | null,
): number | null {
if (!data.length) return null;
// Find the aggregation method, handling case variations
const methodKey = Object.keys(aggregationChoices).find(
key => key.toLowerCase() === (aggregation || '').toLowerCase(),
);
// Use the compute method from aggregationChoices, fallback to LAST_VALUE
const selectedMethod = methodKey
? aggregationChoices[methodKey as keyof typeof aggregationChoices]
: aggregationChoices.LAST_VALUE;
// Extract values from tuple array and filter out nulls
const values = data
.map(([, value]) => value)
.filter((v): v is number => v !== null);
return selectedMethod.compute(values);
}
export default function transformProps(
chartProps: BigNumberWithTrendlineChartProps,
): BigNumberVizProps {
@@ -152,33 +126,27 @@ export default function transformProps(
// sort in time descending order
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
}
if (sortedData.length > 0) {
timestamp = sortedData[0][0];
// Raw aggregation uses server-side data, all others use client-side
if (aggregation === 'raw' && hasAggregatedData && aggregatedData) {
// Use server-side aggregation for raw
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber =
metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
if (hasAggregatedData && aggregatedData) {
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
// Use client-side aggregation for all other methods
bigNumber = computeClientSideAggregation(sortedData, aggregation);
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
// Handle null bigNumber case
timestamp = sortedData.length > 0 ? sortedData[0][0] : null;
} else if (sortedData.length > 0) {
bigNumber = sortedData[0][1];
timestamp = sortedData[0][0];
if (bigNumber === null) {
bigNumberFallback = sortedData.find(d => d[1] !== null);
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;
@@ -358,22 +358,7 @@ const config: ControlPanelConfig = {
['x_axis_time_format'],
[xAxisLabelRotation],
[xAxisLabelInterval],
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[
{
name: 'show_query_identifiers',
config: {
type: 'CheckboxControl',
label: t('Show query identifiers'),
description: t(
'Add Query A and Query B identifiers to tooltips to help differentiate series',
),
default: false,
renderTrigger: true,
},
},
],
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
...richTooltipSection,
// eslint-disable-next-line react/jsx-key
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[
@@ -212,7 +212,6 @@ export default function transformProps(
sortSeriesAscendingB,
timeGrainSqla,
percentageThreshold,
showQueryIdentifiers = false,
metrics = [],
metricsB = [],
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
@@ -396,17 +395,10 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let displayName: string;
let displayName = `${entryName} (Query A)`;
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
}
const seriesFormatter = getFormatter(
@@ -461,17 +453,10 @@ export default function transformProps(
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName: string;
let displayName = `${entryName} (Query B)`;
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
}
const seriesFormatter = getFormatter(
@@ -711,13 +696,14 @@ export default function transformProps(
zoomable,
),
// @ts-ignore
data: series
data: rawSeriesA
.concat(rawSeriesB)
.filter(
entry =>
extractForecastSeriesContext((entry.name || '') as string).type ===
ForecastSeriesEnum.Observation,
)
.map(entry => entry.id || entry.name || '')
.map(entry => entry.name || '')
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
@@ -60,7 +60,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
tooltipTimeFormat?: string;
zoomable: boolean;
richTooltip: boolean;
showQueryIdentifiers?: boolean;
xAxisLabelRotation: number;
xAxisLabelInterval?: number | string;
colorScheme?: string;
@@ -134,7 +133,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
groupbyB: [],
zoomable: TIMESERIES_DEFAULTS.zoomable,
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
showQueryIdentifiers: false,
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
...DEFAULT_TITLE_FORM_DATA,
@@ -39,6 +39,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
width,
echartOptions,
setDataMask,
labelMap,
selectedValues,
formData,
onContextMenu,
@@ -51,47 +52,45 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
const getCrossFilterDataMask = useCallback(
(treePathInfo: TreePathInfo[]) => {
const treePath = extractTreePathInfo(treePathInfo);
const joinedTreePath = treePath.join(',');
const value = treePath[treePath.length - 1];
const isCurrentValueSelected =
Object.values(selectedValues).includes(joinedTreePath);
if (!columns?.length || isCurrentValueSelected) {
return {
dataMask: {
extraFormData: {
filters: [],
},
filterState: {
value: null,
selectedValues: [],
},
},
isCurrentValueSelected,
};
const name = treePath.join(',');
const selected = Object.values(selectedValues);
let values: string[];
if (selected.includes(name)) {
values = selected.filter(v => v !== name);
} else {
values = [name];
}
const labels = values.map(value => labelMap[value]);
return {
dataMask: {
extraFormData: {
filters: [
{
col: columns[treePath.length - 1],
op: '==' as const,
val: value,
},
],
filters:
values.length === 0 || !columns
? []
: columns.slice(0, treePath.length).map((col, idx) => {
const val = labels.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value,
selectedValues: [joinedTreePath],
value: labels.length ? labels : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected,
isCurrentValueSelected: selected.includes(name),
};
},
[columns, selectedValues],
[columns, labelMap, selectedValues],
);
const handleChange = useCallback(
@@ -102,7 +101,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
},
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {
@@ -128,10 +128,9 @@ describe('BigNumberWithTrendline', () => {
expect(lastDatum?.[0]).toStrictEqual(100);
expect(lastDatum?.[1]).toBeNull();
// should get the last non-null value
// should note this is a fallback
expect(transformed.bigNumber).toStrictEqual(1.2345);
// bigNumberFallback is only set when bigNumber is null after aggregation
expect(transformed.bigNumberFallback).toBeNull();
expect(transformed.bigNumberFallback).not.toBeNull();
// should successfully formatTime by granularity
// @ts-ignore
@@ -116,48 +116,49 @@ const chartPropsConfig = {
theme: supersetTheme,
};
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
...formData,
showQueryIdentifiers: false,
},
};
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
it('should transform chart props for viz', () => {
const chartProps = new ChartProps(chartPropsConfig);
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
// Check that series IDs don't include query identifiers
const seriesIds = (transformed.echartOptions.series as any[]).map(
(s: any) => s.id,
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query A), boy',
stack: 'obs\na',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query A), girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query B), boy',
stack: 'obs\nb',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query B), girl',
stack: 'obs\nb',
}),
]),
}),
}),
);
expect(seriesIds).toContain('sum__num, girl');
expect(seriesIds).toContain('sum__num, boy');
expect(seriesIds).not.toContain('sum__num (Query A), girl');
expect(seriesIds).not.toContain('sum__num (Query A), boy');
expect(seriesIds).not.toContain('sum__num (Query B), girl');
expect(seriesIds).not.toContain('sum__num (Query B), boy');
});
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
const chartPropsConfigWithIdentifiers = {
...chartPropsConfig,
formData: {
...formData,
showQueryIdentifiers: true,
},
};
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
// Check that series IDs include query identifiers
const seriesIds = (transformed.echartOptions.series as any[]).map(
(s: any) => s.id,
);
expect(seriesIds).toContain('sum__num (Query A), girl');
expect(seriesIds).toContain('sum__num (Query A), boy');
expect(seriesIds).toContain('sum__num (Query B), girl');
expect(seriesIds).toContain('sum__num (Query B), boy');
expect(seriesIds).not.toContain('sum__num, girl');
expect(seriesIds).not.toContain('sum__num, boy');
});
@@ -34,12 +34,6 @@ const parseLabel = value => {
return String(value);
};
function displayCell(value, allowRenderHtml) {
if (allowRenderHtml && typeof value === 'string') {
return safeHtmlSpan(value);
}
return parseLabel(value);
}
function displayHeaderCell(
needToggle,
ArrowIcon,
@@ -748,7 +742,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -765,7 +759,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
}
@@ -829,7 +823,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -846,7 +840,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
}
@@ -59,6 +59,7 @@ import {
Space,
RawAntdSelect as Select,
Dropdown,
Menu,
Tooltip,
} from '@superset-ui/core/components';
import {
@@ -563,62 +564,52 @@ export default function TableChart<D extends DataRecord = DataRecord>(
return (
<Dropdown
placement="bottomRight"
open={showComparisonDropdown}
onOpenChange={(flag: boolean) => {
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
menu={{
multiple: true,
onClick: handleOnClick,
onBlur: handleOnBlur,
selectedKeys: selectedComparisonColumns,
items: [
{
key: 'all',
label: (
<div
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<div
css={css`
max-width: 242px;
padding: 0 ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</div>
{comparisonColumns.map(column => (
<Menu.Item key={column.key}>
<span
css={css`
max-width: 242px;
padding: 0 ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.fontSizeSM}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</div>
),
type: 'group',
children: comparisonColumns.map(
(column: { key: string; label: string }) => ({
key: column.key,
label: (
<>
<span
css={css`
color: ${theme.colorText};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.fontSizeSM}px;
`}
>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</span>
</>
),
}),
),
},
],
}}
</span>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>
@@ -63,7 +63,7 @@ export enum ContextMenuItem {
export interface ChartContextMenuProps {
id: number;
formData: QueryFormData;
onSelection: (args?: any) => void;
onSelection: () => void;
onClose: () => void;
additionalConfig?: {
crossFilter?: Record<string, any>;
@@ -123,12 +123,6 @@ const ChartContextMenu = (
const [dataset, setDataset] = useState<Dataset>();
const verboseMap = useVerboseMap(dataset);
const closeContextMenu = useCallback(() => {
setVisible(false);
setOpenKeys([]);
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
@@ -270,7 +264,6 @@ const ChartContextMenu = (
<DrillByMenuItems
drillByConfig={filters?.drillBy}
onSelection={onSelection}
onCloseMenu={closeContextMenu}
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
@@ -318,7 +311,6 @@ const ChartContextMenu = (
onOpenChange={setOpenKeys}
onClick={() => {
setVisible(false);
setOpenKeys([]);
onClose();
}}
>
@@ -166,12 +166,8 @@ test('render menu item with submenu without searchbox', async () => {
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Check that each column appears in the drill-by submenu
slicedColumns.forEach(column => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0]; // Use the first submenu
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
@@ -190,19 +186,15 @@ test('render menu item with submenu and searchbox', async () => {
// Wait for all columns to be visible
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const searchbox = await waitFor(
() => screen.getAllByPlaceholderText('Search columns')[0],
() => screen.getAllByPlaceholderText('Search columns')[1],
);
expect(searchbox).toBeInTheDocument();
@@ -212,26 +204,19 @@ test('render menu item with submenu and searchbox', async () => {
// Wait for filtered results
await waitFor(() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
.forEach(col => {
expect(
within(submenu).queryByText(col.column_name),
).not.toBeInTheDocument();
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
});
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
@@ -253,23 +238,17 @@ test('Do not display excluded column in the menu', async () => {
// Wait for menu items to be loaded
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
excludedColNames.forEach(colName => {
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
expect(screen.queryByText(colName)).not.toBeInTheDocument();
});
});
@@ -290,11 +269,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking
const col1Element = await waitFor(() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
return within(submenu).getByText('col1');
});
const col1Element = await waitFor(() => screen.getByText('col1'));
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(
@@ -54,7 +54,7 @@ import {
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
@@ -68,7 +68,6 @@ export interface DrillByMenuItemsProps {
submenuIndex?: number;
onSelection?: (...args: any) => void;
onClick?: (event: MouseEvent) => void;
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
open: boolean;
@@ -101,7 +100,6 @@ export const DrillByMenuItems = ({
submenuIndex = 0,
onSelection = () => {},
onClick = () => {},
onCloseMenu = () => {},
excludedColumns,
openNewModal = true,
open,
@@ -126,7 +124,6 @@ export const DrillByMenuItems = ({
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
}
onCloseMenu();
},
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
);
@@ -267,14 +264,15 @@ export const DrillByMenuItems = ({
const { columns, ...rest } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
<MenuItemWithTruncation
menuKey={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
{...rest}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
</MenuItemWithTruncation>
);
};
@@ -18,4 +18,3 @@
*/
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
@@ -1,269 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useMemo,
} from 'react';
import { isEmpty } from 'lodash';
import {
Behavior,
BinaryQueryObjectFilterClause,
css,
extractQueryFields,
getChartMetadataRegistry,
QueryFormData,
removeHTMLTags,
styled,
t,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
NO_FILTERS: t(
'Right-click on a dimension value to drill to detail by that value.',
),
NOT_SUPPORTED: t(
'Drill to detail by value is not yet supported for this chart type.',
),
};
function getDisabledMenuItem(
children: ReactNode,
menuKey: string,
...rest: unknown[]
): MenuItem {
return {
disabled: true,
key: menuKey,
label: (
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{children}
</div>
),
...rest,
};
}
const Filter = ({
children,
stripHTML = false,
}: {
children: ReactNode;
stripHTML: boolean;
}) => {
const content =
stripHTML && typeof children === 'string'
? removeHTMLTags(children)
: children;
return <span>{content}</span>;
};
const StyledFilter = styled(Filter)`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorPrimary};
`}
`;
export type DrillDetailMenuItemsArgs = {
formData: QueryFormData;
filters?: BinaryQueryObjectFilterClause[];
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
isContextMenu?: boolean;
contextMenuY?: number;
onSelection?: () => void;
onClick?: (event: MouseEvent) => void;
submenuIndex?: number;
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
};
export const useDrillDetailMenuItems = ({
formData,
filters = [],
isContextMenu = false,
contextMenuY = 0,
onSelection = () => null,
onClick = () => null,
submenuIndex = 0,
setFilters,
setShowModal,
key,
...props
}: DrillDetailMenuItemsArgs) => {
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
({ datasources }) =>
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
const openModal = useCallback(
(filters, event) => {
onClick(event);
onSelection();
setFilters(filters);
setShowModal(true);
},
[onClick, onSelection],
);
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
// event for dimensions. If it doesn't, tell the user that drill to detail by
// dimension is not supported. If it does, and the `contextmenu` handler didn't
// pass any filters, tell the user that they didn't select a dimension.
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
[formData.viz_type],
);
// Check metrics to see if chart's current configuration lacks
// aggregations, in which case Drill to Detail should be disabled.
const noAggregations = useMemo(() => {
const { metrics } = extractQueryFields(formData);
return isEmpty(metrics);
}, [formData]);
// Ensure submenu doesn't appear offscreen
const submenuYOffset = useMemo(
() =>
getSubmenuYOffset(
contextMenuY,
filters.length > 1 ? filters.length + 1 : filters.length,
submenuIndex,
),
[contextMenuY, filters.length, submenuIndex],
);
let drillDisabled;
let drillByDisabled;
if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {
if (noAggregations) {
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
} else if (!filters?.length) {
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
}
} else {
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
}
const drillToDetailMenuItem: MenuItem = drillDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</>,
'drill-to-detail-disabled',
props,
)
: {
key: 'drill-to-detail',
label: DRILL_TO_DETAIL,
onClick: openModal.bind(null, []),
...props,
};
const getMenuItemWithTruncation = useMenuItemWithTruncation();
const drillToDetailByMenuItem: MenuItem = drillByDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</>,
'drill-to-detail-by-disabled',
props,
)
: {
key: key || 'drill-to-detail-by',
label: DRILL_TO_DETAIL_BY,
children: [
...filters.map((filter, i) => ({
key: `drill-detail-filter-${i}`,
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
onClick: openModal.bind(null, [filter]),
key: `drill-detail-filter-${i}`,
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
</>
),
}),
})),
filters.length > 1 && {
key: 'drill-detail-filter-all',
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
onClick: openModal.bind(null, filters),
key: 'drill-detail-filter-all',
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
</>
),
}),
},
].filter(Boolean) as MenuItem[],
onClick: openModal.bind(null, filters),
forceSubmenuRender: true,
popupOffset: [0, submenuYOffset],
popupClassName: 'chart-context-submenu',
...props,
};
if (isContextMenu) {
return {
drillToDetailMenuItem,
drillToDetailByMenuItem,
};
}
return {
drillToDetailMenuItem,
};
};
@@ -18,14 +18,9 @@
*/
import { ReactNode, CSSProperties, useCallback } from 'react';
import {
css,
truncationCSS,
useCSSTextTruncation,
useTheme,
} from '@superset-ui/core';
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
import { Flex, Tooltip } from '@superset-ui/core/components';
import { Tooltip } from '@superset-ui/core/components';
import { MenuItemProps } from 'antd';
export type MenuItemWithTruncationProps = {
@@ -118,12 +113,7 @@ export const MenuItemWithTruncation = ({
onClick={onClick}
style={style}
>
<Tooltip
title={itemIsTruncated ? tooltipText : null}
css={css`
max-width: 200px;
`}
>
<Tooltip title={itemIsTruncated ? tooltipText : null}>
<div
ref={itemRef}
css={css`
@@ -137,50 +127,3 @@ export const MenuItemWithTruncation = ({
</Menu.Item>
);
};
export const VirtualizedMenuItem = ({
tooltipText,
children,
onClick,
style,
}: {
tooltipText: ReactNode;
children: ReactNode;
onClick?: (e: React.MouseEvent) => void;
style?: CSSProperties;
}) => {
const theme = useTheme();
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
return (
<Flex
role="menuitem"
tabIndex={0}
onClick={onClick}
align="center"
style={style}
css={css`
cursor: pointer;
padding-left: ${theme.paddingXS}px;
&:hover {
background-color: ${theme.colorBgTextHover};
}
&:active {
background-color: ${theme.colorBgTextActive};
}
`}
>
<Tooltip title={itemIsTruncated ? tooltipText : null}>
<div
ref={itemRef}
css={css`
max-width: 100%;
${truncationCSS};
`}
>
{children}
</div>
</Tooltip>
</Flex>
);
};
@@ -164,7 +164,7 @@ const v1ChartDataRequest = async (
ownState,
parseMethod,
) => {
const payload = await buildV1ChartDataPayload({
const payload = buildV1ChartDataPayload({
formData,
resultType,
resultFormat,
@@ -255,7 +255,7 @@ export function runAnnotationQuery({
isDashboardRequest = false,
force = false,
}) {
return async function (dispatch, getState) {
return function (dispatch, getState) {
const { charts, common } = getState();
const sliceKey = key || Object.keys(charts)[0];
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
@@ -310,19 +310,17 @@ export function runAnnotationQuery({
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
}
const payload = await buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
});
return SupersetClient.post({
url,
signal,
timeout: queryTimeout * 1000,
headers: { 'Content-Type': 'application/json' },
jsonPayload: payload,
jsonPayload: buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
}),
})
.then(({ json }) => {
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
@@ -422,8 +420,6 @@ export function exploreJSON(
const setDataMask = dataMask => {
dispatch(updateDataMask(formData.slice_id, dataMask));
};
dispatch(chartUpdateStarted(controller, formData, key));
const chartDataRequest = getChartDataRequest({
setDataMask,
formData,
@@ -435,6 +431,8 @@ export function exploreJSON(
ownState,
});
dispatch(chartUpdateStarted(controller, formData, key));
const [useLegacyApi] = getQuerySettings(formData);
const chartDataRequestCaught = chartDataRequest
.then(({ response, json }) =>
@@ -64,7 +64,6 @@ describe('chart actions', () => {
let dispatch;
let getExploreUrlStub;
let getChartDataUriStub;
let buildV1ChartDataPayloadStub;
let waitForAsyncDataStub;
let fakeMetadata;
@@ -86,13 +85,6 @@ describe('chart actions', () => {
getChartDataUriStub = sinon
.stub(exploreUtils, 'getChartDataUri')
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
buildV1ChartDataPayloadStub = sinon
.stub(exploreUtils, 'buildV1ChartDataPayload')
.resolves({
some_param: 'fake query!',
result_type: 'full',
result_format: 'json',
});
fakeMetadata = { useLegacyApi: true };
getChartMetadataRegistry.mockImplementation(() => ({
get: () => fakeMetadata,
@@ -112,7 +104,6 @@ describe('chart actions', () => {
afterEach(() => {
getExploreUrlStub.restore();
getChartDataUriStub.restore();
buildV1ChartDataPayloadStub.restore();
fetchMock.resetHistory();
waitForAsyncDataStub.restore();
@@ -371,7 +362,7 @@ describe('chart actions timeout', () => {
jest.clearAllMocks();
});
it('should use the timeout from arguments when given', async () => {
it('should use the timeout from arguments when given', () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const timeout = 10; // Set the timeout value here
@@ -379,7 +370,7 @@ describe('chart actions timeout', () => {
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
await store.dispatch(
store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',
@@ -403,14 +394,14 @@ describe('chart actions timeout', () => {
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
});
it('should use the timeout from common.conf when not passed as an argument', async () => {
it('should use the timeout from common.conf when not passed as an argument', () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const formData = { datasource: 'table__1' }; // Set the formData here
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
await store.dispatch(
store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',
@@ -16,29 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
act,
fireEvent,
render,
screen,
within,
cleanup,
} from 'spec/helpers/testing-library';
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import { store } from 'src/views/store';
import { isFeatureEnabled } from '@superset-ui/core';
import { FacePile } from '.';
import { getRandomColor } from './utils';
// Mock the feature flag
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;
const users = [...new Array(10)].map((_, i) => ({
first_name: 'user',
last_name: `${i}`,
@@ -47,99 +29,37 @@ const users = [...new Array(10)].map((_, i) => ({
beforeEach(() => {
jest.useFakeTimers();
// Default to Slack avatars disabled
mockIsFeatureEnabled.mockImplementation(() => false);
});
afterEach(() => {
jest.useRealTimers();
mockIsFeatureEnabled.mockReset();
cleanup();
});
describe('FacePile', () => {
it('renders empty state with no users', () => {
const { container } = render(<FacePile users={[]} />, { store });
let container: HTMLElement;
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
beforeEach(() => {
({ container } = render(<FacePile users={users} />, { store }));
});
it('renders single user without truncation', () => {
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
store,
});
it('is a valid element', () => {
const exposedFaces = screen.getAllByText(/U/);
expect(exposedFaces).toHaveLength(4);
const overflownFaces = screen.getByText('+6');
expect(overflownFaces).toBeVisible();
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(1);
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
});
it('renders multiple users no truncation', () => {
const { container } = render(<FacePile users={users.slice(0, 4)} />, {
store,
});
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(4);
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).getByText('U1')).toBeInTheDocument();
expect(within(container).getByText('U2')).toBeInTheDocument();
expect(within(container).getByText('U3')).toBeInTheDocument();
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
});
it('renders multiple users with truncation', () => {
const { container } = render(<FacePile users={users} />, { store });
// Should show 4 avatars + 1 overflow indicator = 5 total elements
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(5);
// Should show first 4 users
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).getByText('U1')).toBeInTheDocument();
expect(within(container).getByText('U2')).toBeInTheDocument();
expect(within(container).getByText('U3')).toBeInTheDocument();
// Should show overflow count (+6 because 10 total - 4 shown)
expect(within(container).getByText('+6')).toBeInTheDocument();
});
it('displays user tooltip on hover', () => {
const { container } = render(<FacePile users={users.slice(0, 2)} />, {
store,
});
const firstAvatar = within(container).getByText('U0');
fireEvent.mouseEnter(firstAvatar);
// Display user info when hovering over one of exposed face in the pile.
fireEvent.mouseEnter(exposedFaces[0]);
act(() => jest.runAllTimers());
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
});
it('displays avatar images when Slack avatars are enabled', () => {
// Enable Slack avatars feature flag
mockIsFeatureEnabled.mockImplementation(
feature => feature === 'SLACK_ENABLE_AVATARS',
);
it('renders an Avatar', () => {
expect(container.querySelector('.ant-avatar')).toBeVisible();
});
const { container: testContainer } = render(
<FacePile users={users.slice(0, 2)} />,
{
store,
},
);
const avatars = testContainer.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(2);
// Should have img elements with correct src attributes
const imgs = testContainer.querySelectorAll('.ant-avatar img');
expect(imgs).toHaveLength(2);
expect(imgs[0]).toHaveAttribute('src', '/api/v1/user/0/avatar.png');
expect(imgs[1]).toHaveAttribute('src', '/api/v1/user/1/avatar.png');
it('hides overflow', () => {
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
});
});
@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
import { tagToSelectOption } from 'src/components/Tag/utils';
describe('tagToSelectOption', () => {
test('converts a Tag object with table_name to a SelectTagsValue', () => {
@@ -37,166 +35,3 @@ describe('tagToSelectOption', () => {
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
});
});
describe('loadTags', () => {
beforeEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.restore();
});
test('constructs correct API query with custom tag filter', async () => {
const mockTags = [
{ id: 1, name: 'analytics', type: 1 },
{ id: 2, name: 'finance', type: 1 },
];
fetchMock.get('glob:*/api/v1/tag/*', {
result: mockTags,
count: 2,
});
await loadTags('analytics', 0, 25);
// Verify the API was called with correct parameters
const calls = fetchMock.calls();
expect(calls).toHaveLength(1);
const [url] = calls[0];
expect(url).toContain('/api/v1/tag/?q=');
// Extract and decode the query parameter
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Verify the query structure
expect(decodedQuery).toEqual({
filters: [
{ col: 'name', opr: 'ct', value: 'analytics' },
{ col: 'type', opr: 'custom_tag', value: true },
],
page: 0,
page_size: 25,
order_column: 'name',
order_direction: 'asc',
});
});
test('returns correctly transformed data', async () => {
const mockTags = [
{ id: 1, name: 'analytics', type: 1 },
{ id: 2, name: 'finance', type: 1 },
];
fetchMock.get('glob:*/api/v1/tag/*', {
result: mockTags,
count: 2,
});
const result = await loadTags('', 0, 25);
expect(result).toEqual({
data: [
{ value: 1, label: 'analytics', key: 1 },
{ value: 2, label: 'finance', key: 2 },
],
totalCount: 2,
});
});
test('handles search parameter correctly', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('financial-data', 0, 25);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Should include the search term in the name filter
expect(decodedQuery.filters[0]).toEqual({
col: 'name',
opr: 'ct',
value: 'financial-data',
});
});
test('handles pagination parameters correctly', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('', 2, 10);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
expect(decodedQuery.page).toBe(2);
expect(decodedQuery.page_size).toBe(10);
});
test('always includes custom tag filter regardless of other parameters', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
// Test with different combinations of parameters
await loadTags('', 0, 25);
await loadTags('search-term', 1, 50);
await loadTags('another-search', 5, 100);
const calls = fetchMock.calls();
// Verify all calls include the custom tag filter
calls.forEach(call => {
const [url] = call;
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Every call should have the custom tag filter
expect(decodedQuery.filters).toContainEqual({
col: 'type',
opr: 'custom_tag',
value: true,
});
});
});
test('maintains correct order specification', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('test', 0, 25);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Should always order by name ascending
expect(decodedQuery.order_column).toBe('name');
expect(decodedQuery.order_direction).toBe('asc');
});
});
@@ -78,129 +78,3 @@ test('should render 3 elements when maxTags is set to 3', async () => {
expect(tagsListItems).toHaveLength(3);
expect(tagsListItems[2]).toHaveTextContent('+3...');
});
describe('Tag type filtering', () => {
test('should render only custom type tags (type: 1)', async () => {
const mixedTypeTags = [
{ name: 'custom-tag', type: 1, id: 1 }, // Custom - should show
{ name: 'type:chart', type: 2, id: 2 }, // Type - should be filtered out
{ name: 'owner:admin', type: 3, id: 3 }, // Owner - should be filtered out
{ name: 'another-custom', type: 1, id: 4 }, // Custom - should show
];
// Filter tags like ChartList does - only custom types
const filteredTags = mixedTypeTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should only show 2 custom tags, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('another-custom');
expect(tagsListItems[1]).toHaveTextContent('custom-tag');
});
test('should show tags when type is undefined (fallback case)', async () => {
const undefinedTypeTags = [
{ name: 'legacy-tag', id: 1 }, // No type property - should show due to fallback
{ name: 'custom-tag', type: 1, id: 2 }, // Custom - should show
{ name: 'system-tag', type: 2, id: 3 }, // System - should be filtered out
];
// Apply ChartList filtering logic - undefined type defaults to true
const filteredTags = undefinedTypeTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should show both tags, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('custom-tag');
expect(tagsListItems[1]).toHaveTextContent('legacy-tag');
});
test('should handle legacy TagTypes.custom string format', async () => {
const legacyFormatTags = [
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 1 }, // Legacy string format - should show
{ name: 'modern-custom', type: 1, id: 2 }, // Modern enum - should show
{ name: 'other-type', type: 'TagTypes.other', id: 3 }, // Other legacy type - should be filtered out
];
// Apply ChartList filtering logic - supports both numeric and legacy string
const filteredTags = legacyFormatTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should show both custom formats, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('legacy-custom');
expect(tagsListItems[1]).toHaveTextContent('modern-custom');
});
test('should show empty list when all tags are filtered out', async () => {
const nonCustomTags = [
{ name: 'type:chart', type: 2, id: 1 }, // Type tag
{ name: 'owner:admin', type: 3, id: 2 }, // Owner tag
{ name: 'favoritedBy:user', type: 4, id: 3 }, // FavoritedBy tag
];
// Apply ChartList filtering - all should be filtered out
const filteredTags = nonCustomTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
// Should render container but with no tags
const container = document.querySelector('.tag-list');
expect(container).toBeInTheDocument();
// No tags should be rendered
const tagsListItems = document.querySelectorAll('.ant-tag');
expect(tagsListItems).toHaveLength(0);
});
test('should handle mixed scenarios with truncation', async () => {
const largeMixedTagSet = [
{ name: 'custom-1', type: 1, id: 1 }, // Custom - should show
{ name: 'system-1', type: 2, id: 2 }, // System - filtered out
{ name: 'custom-2', type: 1, id: 3 }, // Custom - should show
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 4 }, // Legacy custom - should show
{ name: 'custom-3', type: 1, id: 5 }, // Custom - should show
{ name: 'owner-tag', type: 3, id: 6 }, // Owner - filtered out
{ name: 'custom-4', type: 1, id: 7 }, // Custom - should show (but truncated)
];
// Apply ChartList filtering - should get 5 custom tags
const filteredTags = largeMixedTagSet.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
// Set maxTags to 3 to test truncation of filtered results
setup({ tags: filteredTags, maxTags: 3 });
const tagsListItems = await findAllTags();
// Should show 3 tags: 2 custom tags (alphabetically sorted) + 1 "+3..." truncation indicator
expect(tagsListItems).toHaveLength(3);
expect(tagsListItems[0]).toHaveTextContent('custom-1');
expect(tagsListItems[1]).toHaveTextContent('custom-2');
expect(tagsListItems[2]).toHaveTextContent('+3...');
});
});
@@ -18,16 +18,16 @@
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import { t } from '@superset-ui/core';
import { isEmpty } from 'lodash';
import { URL_PARAMS } from 'src/constants';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import { useDownloadMenuItems } from 'src/dashboard/components/menu/DownloadMenuItems';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
import CssEditor from 'src/dashboard/components/CssEditor';
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
import SaveModal from 'src/dashboard/components/SaveModal';
import HeaderReportDropdown from 'src/features/reports/ReportModal/HeaderReportDropdown';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
@@ -74,6 +74,9 @@ export const useHeaderActionsMenu = ({
}: HeaderDropdownProps) => {
const dispatch = useDispatch();
const [css, setCss] = useState(customCss || '');
const [showReportSubMenu, setShowReportSubMenu] = useState<boolean | null>(
null,
);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
@@ -169,220 +172,163 @@ export const useHeaderActionsMenu = ({
[directPathToChild],
);
const shareMenuItems = useShareMenuItems({
title: t('Share'),
disabled: isLoading,
url,
dashboardId,
dashboardComponentId,
copyMenuItemTitle: t('Copy permalink to clipboard'),
emailMenuItemTitle: t('Share permalink by email'),
emailSubject,
emailBody: t('Check out this dashboard: '),
addSuccessToast,
addDangerToast,
});
const downloadMenuItem = useDownloadMenuItems({
pdfMenuItemTitle: t('Export to PDF'),
imageMenuItemTitle: t('Download as Image'),
dashboardTitle,
dashboardId,
title: t('Download'),
disabled: isLoading,
logEvent,
});
const reportMenuItem = useHeaderReportMenuItems({
dashboardId: dashboardInfo?.id,
showReportModal,
setCurrentReportDeleting,
});
// Helper function to create menu items for components with triggerNode
const createModalMenuItem = (
key: string,
modalComponent: React.ReactElement,
): MenuItem => ({
key,
label: modalComponent,
});
const menu = useMemo(() => {
const isEmbedded = !dashboardInfo?.userId;
const refreshIntervalOptions =
dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
const menuItems: MenuItem[] = [];
// Refresh dashboard
if (!editMode) {
menuItems.push({
key: MenuKeys.RefreshDashboard,
label: t('Refresh dashboard'),
disabled: isLoading,
});
}
// Toggle fullscreen
if (!editMode && !isEmbedded) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
? t('Exit fullscreen')
: t('Enter fullscreen'),
});
}
// Edit properties
if (editMode) {
menuItems.push({
key: MenuKeys.EditProperties,
label: t('Edit properties'),
});
}
// Edit CSS
if (editMode) {
menuItems.push(
createModalMenuItem(
MenuKeys.EditCss,
<CssEditor
triggerNode={<div>{t('Theme & CSS')}</div>}
initialCss={css}
onChange={changeCss}
addDangerToast={addDangerToast}
currentThemeId={dashboardInfo.theme?.id || null}
onThemeChange={handleThemeChange}
/>,
),
);
}
// Divider
menuItems.push({ type: 'divider' });
// Save as
if (userCanSave) {
menuItems.push(
createModalMenuItem(
MenuKeys.SaveModal,
<SaveModal
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
saveType={SAVE_TYPE_NEWDASHBOARD}
layout={layout}
expandedSlices={expandedSlices}
refreshFrequency={refreshFrequency}
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
lastModifiedTime={lastModifiedTime}
customCss={customCss}
colorNamespace={colorNamespace}
colorScheme={colorScheme}
onSave={onSave}
triggerNode={
<div data-test="save-as-menu-item">{t('Save as')}</div>
}
canOverwrite={userCanEdit}
/>,
),
);
}
// Download submenu
menuItems.push(downloadMenuItem);
// Share submenu
if (userCanShare) {
menuItems.push(shareMenuItems);
}
// Embed dashboard
if (!editMode && userCanCurate) {
menuItems.push({
key: MenuKeys.ManageEmbedded,
label: t('Embed dashboard'),
});
}
// Divider
menuItems.push({ type: 'divider' });
// Report dropdown
if (!editMode && reportMenuItem) {
menuItems.push(reportMenuItem);
}
// Set filter mapping
if (editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes)) {
menuItems.push(
createModalMenuItem(
MenuKeys.SetFilterMapping,
<FilterScopeModal
triggerNode={<div>{t('Set filter mapping')}</div>}
/>,
),
);
}
// Auto-refresh interval
menuItems.push(
createModalMenuItem(
MenuKeys.AutorefreshModal,
<RefreshIntervalModal
addSuccessToast={addSuccessToast}
refreshFrequency={refreshFrequency}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
onChange={changeRefreshInterval}
editMode={editMode}
refreshIntervalOptions={refreshIntervalOptions}
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
/>,
),
);
dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
return (
<Menu
selectable={false}
data-test="header-actions-menu"
onClick={handleMenuClick}
items={menuItems}
/>
>
{!editMode && (
<Menu.Item
key={MenuKeys.RefreshDashboard}
data-test="refresh-dashboard-menu-item"
disabled={isLoading}
>
{t('Refresh dashboard')}
</Menu.Item>
)}
{!editMode && !isEmbedded && (
<Menu.Item key={MenuKeys.ToggleFullscreen}>
{getUrlParam(URL_PARAMS.standalone)
? t('Exit fullscreen')
: t('Enter fullscreen')}
</Menu.Item>
)}
{editMode && (
<Menu.Item key={MenuKeys.EditProperties}>
{t('Edit properties')}
</Menu.Item>
)}
{editMode && (
<Menu.Item key={MenuKeys.EditCss}>
<CssEditor
triggerNode={<div>{t('Theme & CSS')}</div>}
initialCss={css}
onChange={changeCss}
addDangerToast={addDangerToast}
currentThemeId={dashboardInfo.theme?.id || null}
onThemeChange={handleThemeChange}
/>
</Menu.Item>
)}
<Menu.Divider />
{userCanSave && (
<Menu.Item key={MenuKeys.SaveModal}>
<SaveModal
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
saveType={SAVE_TYPE_NEWDASHBOARD}
layout={layout}
expandedSlices={expandedSlices}
refreshFrequency={refreshFrequency}
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
lastModifiedTime={lastModifiedTime}
customCss={customCss}
colorNamespace={colorNamespace}
colorScheme={colorScheme}
onSave={onSave}
triggerNode={
<div data-test="save-as-menu-item">{t('Save as')}</div>
}
canOverwrite={userCanEdit}
/>
</Menu.Item>
)}
<DownloadMenuItems
submenuKey={MenuKeys.Download}
disabled={isLoading}
title={t('Download')}
pdfMenuItemTitle={t('Export to PDF')}
imageMenuItemTitle={t('Download as Image')}
dashboardTitle={dashboardTitle}
dashboardId={dashboardId}
logEvent={logEvent}
/>
{userCanShare && (
<ShareMenuItems
disabled={isLoading}
data-test="share-dashboard-menu-item"
title={t('Share')}
url={url}
copyMenuItemTitle={t('Copy permalink to clipboard')}
emailMenuItemTitle={t('Share permalink by email')}
emailSubject={emailSubject}
emailBody={t('Check out this dashboard: ')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardComponentId={dashboardComponentId}
/>
)}
{!editMode && userCanCurate && (
<Menu.Item key={MenuKeys.ManageEmbedded}>
{t('Embed dashboard')}
</Menu.Item>
)}
<Menu.Divider />
{!editMode ? (
showReportSubMenu ? (
<>
<HeaderReportDropdown
submenuTitle={t('Manage email report')}
dashboardId={dashboardInfo.id}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
showReportSubMenu={showReportSubMenu}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
<Menu.Divider />
</>
) : (
<HeaderReportDropdown
dashboardId={dashboardInfo.id}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
)
) : null}
{editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && (
<Menu.Item key={MenuKeys.SetFilterMapping}>
<FilterScopeModal
triggerNode={<div>{t('Set filter mapping')}</div>}
/>
</Menu.Item>
)}
<Menu.Item key={MenuKeys.AutorefreshModal}>
<RefreshIntervalModal
addSuccessToast={addSuccessToast}
refreshFrequency={refreshFrequency}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
onChange={changeRefreshInterval}
editMode={editMode}
refreshIntervalOptions={refreshIntervalOptions}
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
/>
</Menu.Item>
</Menu>
);
}, [
addDangerToast,
addSuccessToast,
changeRefreshInterval,
changeCss,
colorNamespace,
colorScheme,
css,
customCss,
dashboardId,
dashboardInfo,
dashboardTitle,
downloadMenuItem,
editMode,
expandedSlices,
showReportSubMenu,
isDropdownVisible,
directPathToChild,
handleMenuClick,
isLoading,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
refreshLimit,
refreshWarning,
reportMenuItem,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,
userCanEdit,
userCanSave,
userCanShare,
changeCss,
changeRefreshInterval,
emailSubject,
url,
dashboardComponentId,
]);
return [menu, isDropdownVisible, setIsDropdownVisible];
@@ -438,9 +438,10 @@ describe('PropertiesModal', () => {
const props = createProps();
const propsWithDashboardInfo = { ...props, dashboardInfo };
const open = () => waitFor(() => userEvent.click(getSelect()));
const getSelect = () =>
screen.getByRole('combobox', { name: SupersetCore.t('Owners') });
const open = () => waitFor(() => userEvent.click(getSelect()));
const getElementsByClassName = (className: string) =>
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
@@ -41,20 +41,20 @@ import {
QueryFormData,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import {
NoAnimationDropdown,
Tooltip,
Button,
ModalTrigger,
} from '@superset-ui/core/components';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { Icons } from '@superset-ui/core/components/Icons';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
@@ -334,199 +334,183 @@ const SliceHeaderControls = (
animationDuration: '0s',
};
const newMenuItems: MenuItem[] = [
{
key: MenuKeys.ForceRefresh,
label: (
<>
{t('Force refresh')}
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
{refreshTooltip}
</RefreshTooltip>
</>
),
disabled: props.chartStatus === 'loading',
style: { height: 'auto', lineHeight: 'initial' },
...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type
},
{
key: MenuKeys.Fullscreen,
label: fullscreenLabel,
},
{
type: 'divider',
},
];
const menu = (
<Menu
onClick={handleMenuClick}
data-test={`slice_${slice.slice_id}-menu`}
id={`slice_${slice.slice_id}-menu`}
selectable={false}
>
<Menu.Item
key={MenuKeys.ForceRefresh}
disabled={props.chartStatus === 'loading'}
style={{ height: 'auto', lineHeight: 'initial' }}
data-test="refresh-chart-menu-item"
>
{t('Force refresh')}
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
{refreshTooltip}
</RefreshTooltip>
</Menu.Item>
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
label: props.isDescriptionExpanded
? t('Hide chart description')
: t('Show chart description'),
});
}
<Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item>
if (canExplore) {
newMenuItems.push({
key: MenuKeys.ExploreChart,
label: (
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
{t('Edit chart')}
</Tooltip>
),
...{ 'data-test-edit-chart-name': slice.slice_name },
});
}
<Menu.Divider />
if (canEditCrossFilters) {
newMenuItems.push({
key: MenuKeys.CrossFilterScoping,
label: t('Cross-filtering scoping'),
});
}
{slice.description && (
<Menu.Item key={MenuKeys.ToggleChartDescription}>
{props.isDescriptionExpanded
? t('Hide chart description')
: t('Show chart description')}
</Menu.Item>
)}
if (canExplore || canEditCrossFilters) {
newMenuItems.push({ type: 'divider' });
}
{canExplore && (
<Menu.Item
key={MenuKeys.ExploreChart}
data-test-edit-chart-name={slice.slice_name}
>
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
{t('Edit chart')}
</Tooltip>
</Menu.Item>
)}
if (canExplore || canViewQuery) {
newMenuItems.push({
key: MenuKeys.ViewQuery,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
draggable
resizable
responsive
ref={queryMenuRef}
{canEditCrossFilters && (
<Menu.Item key={MenuKeys.CrossFilterScoping}>
{t('Cross-filtering scoping')}
</Menu.Item>
)}
{(canExplore || canEditCrossFilters) && <Menu.Divider />}
{(canExplore || canViewQuery) && (
<Menu.Item key={MenuKeys.ViewQuery}>
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
draggable
resizable
responsive
ref={queryMenuRef}
/>
</Menu.Item>
)}
{(canExplore || canViewTable) && (
<Menu.Item key={MenuKeys.ViewResults}>
<ViewResultsModalTrigger
canExplore={props.supersetCanExplore}
exploreUrl={props.exploreUrl}
triggerNode={
<div data-test="view-query-menu-item">{t('View as table')}</div>
}
modalRef={resultsMenuRef}
modalTitle={t('Chart Data: %s', slice.slice_name)}
modalBody={
<ResultsPaneOnDashboard
queryFormData={props.formData}
queryForce={false}
dataSize={20}
isRequest
isVisible
canDownload={!!props.supersetCanCSV}
/>
}
/>
</Menu.Item>
)}
{isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && (
<DrillDetailMenuItems
setFilters={setFilters}
filters={modalFilters}
formData={props.formData}
key={MenuKeys.DrillToDetail}
setShowModal={setDrillModalIsOpen}
/>
),
});
}
)}
if (canExplore || canViewTable) {
newMenuItems.push({
key: MenuKeys.ViewResults,
label: (
<ViewResultsModalTrigger
canExplore={props.supersetCanExplore}
exploreUrl={props.exploreUrl}
triggerNode={
<div data-test="view-query-menu-item">{t('View as table')}</div>
}
modalRef={resultsMenuRef}
modalTitle={t('Chart Data: %s', slice.slice_name)}
modalBody={
<ResultsPaneOnDashboard
queryFormData={props.formData}
queryForce={false}
dataSize={20}
isRequest
isVisible
canDownload={!!props.supersetCanCSV}
/>
}
{(slice.description || canExplore) && <Menu.Divider />}
{supersetCanShare && (
<ShareMenuItems
dashboardId={dashboardId}
dashboardComponentId={componentId}
copyMenuItemTitle={t('Copy permalink to clipboard')}
emailMenuItemTitle={t('Share chart by email')}
emailSubject={t('Superset chart')}
emailBody={t('Check out this chart: ')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
title={t('Share')}
/>
),
});
}
)}
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
useDrillDetailMenuItems({
formData: props.formData,
filters: modalFilters,
setFilters,
setShowModal: setDrillModalIsOpen,
key: MenuKeys.DrillToDetail,
});
{props.supersetCanCSV && (
<Menu.SubMenu title={t('Download')} key={MenuKeys.Download}>
<Menu.Item
key={MenuKeys.ExportCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to .CSV')}
</Menu.Item>
{isPivotTable && (
<Menu.Item
key={MenuKeys.ExportPivotCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Pivoted .CSV')}
</Menu.Item>
)}
<Menu.Item
key={MenuKeys.ExportXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Excel')}
</Menu.Item>
const shareMenuItems = useShareMenuItems({
dashboardId,
dashboardComponentId: componentId,
copyMenuItemTitle: t('Copy permalink to clipboard'),
emailMenuItemTitle: t('Share chart by email'),
emailSubject: t('Superset chart'),
emailBody: t('Check out this chart: '),
addSuccessToast,
addDangerToast,
title: t('Share'),
});
{isPivotTable && (
<Menu.Item
key={MenuKeys.ExportPivotXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
)}
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
newMenuItems.push(drillToDetailMenuItem);
if (drillToDetailByMenuItem) {
newMenuItems.push(drillToDetailByMenuItem);
}
}
if (slice.description || canExplore) {
newMenuItems.push({ type: 'divider' });
}
if (supersetCanShare) {
newMenuItems.push(shareMenuItems);
}
if (props.supersetCanCSV) {
newMenuItems.push({
type: 'submenu',
key: MenuKeys.Download,
label: t('Download'),
children: [
{
key: MenuKeys.ExportCsv,
label: t('Export to .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
...(isPivotTable
? [
{
key: MenuKeys.ExportPivotCsv,
label: t('Export to Pivoted .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
{
key: MenuKeys.ExportPivotXlsx,
label: t('Export to Pivoted Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
]
: []),
{
key: MenuKeys.ExportXlsx,
label: t('Export to Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
props.supersetCanCSV &&
isTable
? [
{
key: MenuKeys.ExportFullCsv,
label: t('Export to full .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
{
key: MenuKeys.ExportFullXlsx,
label: t('Export to full Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
]
: []),
{
key: MenuKeys.DownloadAsImage,
label: t('Download as image'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
},
],
});
}
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
props.supersetCanCSV &&
isTable && (
<>
<Menu.Item
key={MenuKeys.ExportFullCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to full .CSV')}
</Menu.Item>
<Menu.Item
key={MenuKeys.ExportFullXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to full Excel')}
</Menu.Item>
</>
)}
<Menu.Item
key={MenuKeys.DownloadAsImage}
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
>
{t('Download as image')}
</Menu.Item>
</Menu.SubMenu>
)}
</Menu>
);
return (
<>
{isFullSize && (
@@ -538,15 +522,7 @@ const SliceHeaderControls = (
/>
)}
<NoAnimationDropdown
popupRender={() => (
<Menu
onClick={handleMenuClick}
data-test={`slice_${slice.slice_id}-menu`}
id={`slice_${slice.slice_id}-menu`}
selectable={false}
items={newMenuItems}
/>
)}
popupRender={() => menu}
overlayStyle={dropdownOverlayStyle}
trigger={['click']}
placement="bottomRight"
@@ -17,8 +17,8 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { useDownloadMenuItems } from '.';
import { Menu } from '@superset-ui/core/components/Menu';
import DownloadMenuItems from '.';
const createProps = () => ({
pdfMenuItemTitle: 'Export to PDF',
@@ -30,17 +30,19 @@ const createProps = () => ({
submenuKey: 'download',
});
const MenuWrapper = () => {
const downloadMenuItem = useDownloadMenuItems(createProps());
const menuItems: MenuItem[] = [downloadMenuItem];
return <Menu forceSubMenuRender items={menuItems} />;
const renderComponent = () => {
render(
<Menu forceSubMenuRender>
<DownloadMenuItems {...createProps()} />
</Menu>,
{
useRedux: true,
},
);
};
test('Should render menu items', () => {
render(<MenuWrapper />, {
useRedux: true,
});
renderComponent();
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
expect(screen.getByText('Download as Image')).toBeInTheDocument();
});
@@ -16,21 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SyntheticEvent } from 'react';
import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components/Menu';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { MenuKeys } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import downloadAsImage from 'src/utils/downloadAsImage';
import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
} from 'src/logger/LogUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { ComponentProps } from 'react';
import { DownloadScreenshotFormat } from './types';
import DownloadAsPdf from './DownloadAsPdf';
import DownloadAsImage from './DownloadAsImage';
export interface UseDownloadMenuItemsProps {
export interface DownloadMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
pdfMenuItemTitle: string;
imageMenuItemTitle: string;
dashboardTitle: string;
@@ -38,81 +33,56 @@ export interface UseDownloadMenuItemsProps {
dashboardId: number;
title: string;
disabled?: boolean;
submenuKey: string;
}
export const useDownloadMenuItems = (
props: UseDownloadMenuItemsProps,
): MenuItem => {
const DownloadMenuItems = (props: DownloadMenuItemProps) => {
const {
pdfMenuItemTitle,
imageMenuItemTitle,
logEvent,
dashboardId,
dashboardTitle,
submenuKey,
disabled,
title,
...rest
} = props;
const { addDangerToast } = useToasts();
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const isWebDriverScreenshotEnabled =
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
const onDownloadPdf = async (e: SyntheticEvent) => {
try {
downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF);
};
const onDownloadImage = async (e: SyntheticEvent) => {
try {
downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE);
};
const children: MenuItem[] = isWebDriverScreenshotEnabled
? [
{
key: DownloadScreenshotFormat.PDF,
label: pdfMenuItemTitle,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PDF),
},
{
key: DownloadScreenshotFormat.PNG,
label: imageMenuItemTitle,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PNG),
},
]
: [
{
key: 'download-pdf',
label: pdfMenuItemTitle,
onClick: (e: any) => onDownloadPdf(e.domEvent),
},
{
key: 'download-image',
label: imageMenuItemTitle,
onClick: (e: any) => onDownloadImage(e.domEvent),
},
];
return {
key: MenuKeys.Download,
type: 'submenu',
label: title,
disabled,
children,
};
return isWebDriverScreenshotEnabled ? (
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<Menu.Item
key={DownloadScreenshotFormat.PDF}
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PDF)}
>
{pdfMenuItemTitle}
</Menu.Item>
<Menu.Item
key={DownloadScreenshotFormat.PNG}
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PNG)}
>
{imageMenuItemTitle}
</Menu.Item>
</Menu.SubMenu>
) : (
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<DownloadAsPdf
text={pdfMenuItemTitle}
dashboardTitle={dashboardTitle}
logEvent={logEvent}
/>
<DownloadAsImage
text={imageMenuItemTitle}
dashboardTitle={dashboardTitle}
logEvent={logEvent}
/>
</Menu.SubMenu>
);
};
export default DownloadMenuItems;
@@ -17,7 +17,7 @@
* under the License.
*/
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import {
render,
screen,
@@ -26,8 +26,7 @@ import {
} from 'spec/helpers/testing-library';
import * as copyTextToClipboard from 'src/utils/copy';
import fetchMock from 'fetch-mock';
import { ComponentProps } from 'react';
import { useShareMenuItems, ShareMenuItemProps } from '.';
import ShareMenuItems from '.';
const spy = jest.spyOn(copyTextToClipboard, 'default');
@@ -70,23 +69,17 @@ afterAll((): void => {
window.location = location;
});
const MenuWrapper = (
props: ComponentProps<typeof Menu> & { shareProps: ShareMenuItemProps },
) => {
const shareMenuItems = useShareMenuItems(props.shareProps);
const menuItems: MenuItem[] = [shareMenuItems];
return <Menu {...props} items={menuItems} />;
};
test('Should render menu items', () => {
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={createProps()}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
@@ -97,13 +90,14 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
spy.mockResolvedValue(undefined);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -129,13 +123,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
spy.mockRejectedValue(undefined);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -162,13 +157,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
test('Click on "Share dashboard by email" and succeed', async () => {
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -195,13 +191,14 @@ test('Click on "Share dashboard by email" and fail', async () => {
);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -19,13 +19,12 @@
import { ComponentProps, RefObject } from 'react';
import copyTextToClipboard from 'src/utils/copy';
import { t, logging } from '@superset-ui/core';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux';
export interface ShareMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
url?: string;
copyMenuItemTitle: string;
emailMenuItemTitle: string;
@@ -41,10 +40,9 @@ export interface ShareMenuItemProps
setOpenKeys?: Function;
title: string;
disabled?: boolean;
[key: string]: any;
}
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
const ShareMenuItems = (props: ShareMenuItemProps) => {
const {
copyMenuItemTitle,
emailMenuItemTitle,
@@ -98,23 +96,20 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
}
}
return {
...rest,
type: 'submenu',
label: title,
key: MenuKeys.Share,
disabled,
children: [
{
key: MenuKeys.CopyLink,
label: copyMenuItemTitle,
onClick: onCopyLink,
},
{
key: MenuKeys.ShareByEmail,
label: emailMenuItemTitle,
onClick: onShareByEmail,
},
],
};
return (
<Menu.SubMenu
title={title}
key={MenuKeys.Share}
disabled={disabled}
{...rest}
>
<Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}>
{copyMenuItemTitle}
</Menu.Item>
<Menu.Item key={MenuKeys.ShareByEmail} onClick={() => onShareByEmail()}>
{emailMenuItemTitle}
</Menu.Item>
</Menu.SubMenu>
);
};
export default ShareMenuItems;
@@ -155,34 +155,6 @@ const FilterValue: FC<FilterControlProps> = ({
dashboardId,
});
const filterOwnState = filter.dataMask?.ownState || {};
if (filter?.cascadeParentIds?.length) {
// Prevent unnecessary backend requests by validating parent filter selections first
let selectedParentFilterValueCounts = 0;
filter?.cascadeParentIds?.forEach(pId => {
const extraFormData = dataMaskSelected?.[pId]?.extraFormData;
if (extraFormData?.filters?.length) {
selectedParentFilterValueCounts += extraFormData.filters.length;
} else if (extraFormData?.time_range) {
selectedParentFilterValueCounts += 1;
}
});
// check if all parent filters with defaults have a value selected
let depsCount = dependencies.filters?.length ?? 0;
if (dependencies?.time_range) {
depsCount += 1;
}
if (selectedParentFilterValueCounts !== depsCount) {
// child filter should not request backend until it
// has all the required information from parent filters
return;
}
}
// TODO: We should try to improve our useEffect hooks to depend more on
// granular information instead of big objects that require deep comparison.
const customizer = (
@@ -254,7 +226,6 @@ const FilterValue: FC<FilterControlProps> = ({
hasDataSource,
isRefreshing,
shouldRefresh,
dataMaskSelected,
]);
useEffect(() => {
@@ -96,14 +96,14 @@ test('remove filter', async () => {
test('add filter', async () => {
defaultRender();
// First trash icon
const addFilterButton = await screen.findByText('Add filter');
const addFilterButton = await screen.findByText('Add Filter');
userEvent.click(addFilterButton);
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
});
test('add divider', async () => {
defaultRender();
const addFilterButton = await screen.findByText('Add divider');
const addFilterButton = await screen.findByText('Add Divider');
userEvent.click(addFilterButton);
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
});
@@ -128,7 +128,7 @@ test('filter container should scroll to bottom when adding items', async () => {
defaultRender(state, props);
const addFilterButton = await screen.findByText('Add filter');
const addFilterButton = await screen.findByText('Add Filter');
userEvent.click(addFilterButton);
@@ -111,7 +111,7 @@ const FilterTitlePane: FC<Props> = ({
data-test="add-new-filter-button"
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
>
{t('Add filter')}
{t('Add Filter')}
</Button>
<Button
buttonSize="default"
@@ -125,7 +125,7 @@ const FilterTitlePane: FC<Props> = ({
data-test="add-new-divider-button"
onClick={() => handleOnAdd(NativeFilterType.Divider)}
>
{t('Add divider')}
{t('Add Divider')}
</Button>
</div>
</TabsContainer>
@@ -1,93 +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 { Route } from 'react-router-dom';
import { getExtensionsRegistry } from '@superset-ui/core';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
import type { ThemeStorage } from '@superset-ui/core';
import { store } from 'src/views/store';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* In-memory implementation of ThemeStorage interface for embedded contexts.
* Persistent storage is not required for embedded dashboards.
*/
class ThemeMemoryStorageAdapter implements ThemeStorage {
private storage = new Map<string, string>();
getItem(key: string): string | null {
return this.storage.get(key) || null;
}
setItem(key: string, value: string): void {
this.storage.set(key, value);
}
removeItem(key: string): void {
this.storage.delete(key);
}
}
const themeController = new ThemeController({
storage: new ThemeMemoryStorageAdapter(),
});
export const getThemeController = (): ThemeController => themeController;
const { common } = getBootstrapData();
const extensionsRegistry = getExtensionsRegistry();
export const EmbeddedContextProviders: React.FC = ({ children }) => {
const RootContextProviderExtension = extensionsRegistry.get(
'root.context.provider',
);
return (
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>
);
};
+10 -30
View File
@@ -21,27 +21,20 @@ import 'src/public-path';
import { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import {
type SupersetThemeConfig,
makeApi,
t,
logging,
} from '@superset-ui/core';
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import {
EmbeddedContextProviders,
getThemeController,
} from './EmbeddedContextProviders';
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
@@ -51,7 +44,9 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
function log(...info: unknown[]) {
if (debugMode) logging.debug(`[superset]`, ...info);
if (debugMode) {
logging.debug(`[superset]`, ...info);
}
}
const LazyDashboardPage = lazy(
@@ -90,12 +85,12 @@ const EmbededLazyDashboardPage = () => {
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<EmbeddedContextProviders>
<RootContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</EmbeddedContextProviders>
</RootContextProviders>
</Suspense>
);
@@ -250,13 +245,12 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: SupersetThemeConfig }) => {
(payload: { themeConfig: AnyThemeConfig }) => {
const { themeConfig } = payload;
log('Received setThemeConfig request:', themeConfig);
try {
const themeController = getThemeController();
themeController.setThemeConfig(themeConfig);
themeObject.setConfig(themeConfig);
return { success: true, message: 'Theme applied' };
} catch (error) {
logging.error('Failed to apply theme config:', error);
@@ -264,22 +258,8 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
}
},
);
Switchboard.start();
}
});
// Clean up theme controller on page unload
window.addEventListener('beforeunload', () => {
try {
const controller = getThemeController();
if (controller) {
log('Destroying theme controller');
controller.destroy();
}
} catch (error) {
logging.warn('Failed to destroy theme controller:', error);
}
});
log('embed page is ready to receive messages');
@@ -53,6 +53,7 @@ import {
sections,
} from '@superset-ui/chart-controls';
import { useSelector } from 'react-redux';
import { rgba } from 'emotion-rgba';
import { kebabCase, isEqual } from 'lodash';
import {
@@ -117,11 +118,16 @@ const iconStyles = css`
const actionButtonsContainerStyles = (theme: SupersetTheme) => css`
display: flex;
position: sticky;
bottom: 0;
flex-direction: column;
align-items: center;
padding: ${theme.sizeUnit * 4}px;
background: ${theme.colorBgContainer};
flex-shrink: 0;
z-index: 999;
background: linear-gradient(
${rgba(theme.colorBgBase, 0)},
${theme.colorBgBase} 35%
);
& > button {
min-width: 156px;
@@ -132,18 +138,15 @@ const Styles = styled.div`
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
// Resizable add overflow-y: auto as a style to this div
// To override it, we need to use !important
overflow: visible !important;
#controlSections {
flex: 1;
overflow: auto;
height: 100%;
overflow: visible;
padding-bottom: ${({ theme }) => theme.sizeUnit * 10}px;
}
.tab-content {
overflow: auto;
flex: 1 1 100%;
@@ -30,6 +30,10 @@ export type DateLabelProps = {
onClick?: (event: MouseEvent) => void;
};
// This is the color that antd components (such as Select or Input) use on hover
// TODO: use theme.colorPrimary here and in antd components
const ACTIVE_BORDER_COLOR = '#45BED6';
const LabelContainer = styled.div<{
isActive?: boolean;
isPlaceholder?: boolean;
@@ -43,9 +47,10 @@ const LabelContainer = styled.div<{
padding: 0 ${theme.sizeUnit * 3}px;
background-color: ${theme.colorBgContainer};
background-color: ${theme.colors.grayscale.light5};
border: 1px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
border: 1px solid
${isActive ? ACTIVE_BORDER_COLOR : theme.colors.grayscale.light2};
border-radius: ${theme.borderRadius}px;
cursor: pointer;
@@ -53,11 +58,11 @@ const LabelContainer = styled.div<{
transition: border-color 0.3s cubic-bezier(0.65, 0.05, 0.36, 1);
:hover,
:focus {
border-color: ${theme.colorPrimary};
border-color: ${ACTIVE_BORDER_COLOR};
}
.date-label-content {
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
color: ${isPlaceholder ? theme.colors.grayscale.light1 : theme.colorText};
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
@@ -66,7 +71,6 @@ const LabelContainer = styled.div<{
}
span[role='img'] {
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
margin-left: auto;
padding-left: ${theme.sizeUnit}px;
@@ -91,7 +91,7 @@ afterEach(() => {
});
const getFormatSwitch = () =>
screen.getByRole('switch', { name: 'formatted original' });
screen.getByRole('switch', { name: 'Show original SQL' });
test('renders the component with Formatted SQL and buttons', async () => {
const { container } = setup(mockProps);
@@ -26,17 +26,11 @@ import {
} from 'react';
import { useSelector } from 'react-redux';
import rison from 'rison';
import { styled, SupersetClient, t, useTheme } from '@superset-ui/core';
import {
Icons,
Switch,
Button,
Skeleton,
Card,
Space,
} from '@superset-ui/core/components';
import { styled, SupersetClient, t } from '@superset-ui/core';
import { Icons, Switch, Button, Skeleton } from '@superset-ui/core/components';
import { CopyToClipboard } from 'src/components';
import { RootState } from 'src/dashboard/types';
import { CopyButton } from 'src/explore/components/DataTableControl';
import { findPermission } from 'src/utils/findPermission';
import CodeSyntaxHighlighter, {
SupportedLanguage,
@@ -44,6 +38,14 @@ import CodeSyntaxHighlighter, {
} from '@superset-ui/core/components/CodeSyntaxHighlighter';
import { useHistory } from 'react-router-dom';
const CopyButtonViewQuery = styled(CopyButton)`
${({ theme }) => `
&& {
margin: 0 0 ${theme.sizeUnit}px;
}
`}
`;
export interface ViewQueryProps {
sql: string;
datasource: string;
@@ -56,14 +58,26 @@ const StyledSyntaxContainer = styled.div`
flex-direction: column;
`;
const StyledHeaderMenuContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: ${({ theme }) => -theme.sizeUnit * 4}px;
align-items: flex-end;
`;
const StyledHeaderActionContainer = styled.div`
display: flex;
flex-direction: row;
column-gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
flex: 1;
`;
const StyledFooter = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
const StyledLabel = styled.label`
font-size: ${({ theme }) => theme.fontSize}px;
`;
const DATASET_BACKEND_QUERY = {
@@ -73,7 +87,6 @@ const DATASET_BACKEND_QUERY = {
const ViewQuery: FC<ViewQueryProps> = props => {
const { sql, language = 'sql', datasource } = props;
const theme = useTheme();
const datasetId = datasource.split('__')[0];
const [formattedSQL, setFormattedSQL] = useState<string>();
const [showFormatSQL, setShowFormatSQL] = useState(true);
@@ -140,57 +153,46 @@ const ViewQuery: FC<ViewQueryProps> = props => {
}, [sql]);
return (
<Card bodyStyle={{ padding: theme.sizeUnit * 4 }}>
<StyledSyntaxContainer key={sql}>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
<StyledFooter>
<Space size={theme.sizeUnit * 2}>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<Button
buttonStyle="secondary"
buttonSize="small"
icon={<Icons.CopyOutlined />}
>
{t('Copy')}
</Button>
}
/>
{canAccessSQLLab && (
<Button
buttonStyle="secondary"
<StyledSyntaxContainer key={sql}>
<StyledHeaderMenuContainer>
<StyledHeaderActionContainer>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<CopyButtonViewQuery
buttonSize="small"
onClick={navToSQLLab}
icon={<Icons.CopyOutlined />}
>
{t('View in SQL Lab')}
</Button>
)}
</Space>
<Space size={theme.sizeUnit * 2} align="center">
<Icons.ConsoleSqlOutlined />
<Switch
id="formatSwitch"
checked={showFormatSQL}
onChange={formatCurrentQuery}
checkedChildren={t('formatted')}
unCheckedChildren={t('original')}
/>
</Space>
</StyledFooter>
</StyledSyntaxContainer>
</Card>
{t('Copy')}
</CopyButtonViewQuery>
}
/>
{canAccessSQLLab && (
<Button onClick={navToSQLLab}>{t('View in SQL Lab')}</Button>
)}
</StyledHeaderActionContainer>
<StyledHeaderActionContainer>
<Switch
id="formatSwitch"
checked={!showFormatSQL}
onChange={formatCurrentQuery}
/>
<StyledLabel htmlFor="formatSwitch">
{t('Show original SQL')}
</StyledLabel>
</StyledHeaderActionContainer>
</StyledHeaderMenuContainer>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
</StyledSyntaxContainer>
);
};
@@ -42,7 +42,6 @@ const ViewQueryModalContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
@@ -87,10 +86,9 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
return (
<ViewQueryModalContainer>
{result.map((item, index) =>
{result.map(item =>
item.query ? (
<ViewQuery
key={`query-${index}`}
datasource={latestQueryFormData.datasource}
sql={item.query}
language="sql"
@@ -41,9 +41,6 @@ import {
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers();
class MainPreset extends Preset {
@@ -259,22 +256,4 @@ describe('VizTypeControl', () => {
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
});
it('Search input is focused when modal opens', async () => {
// Mock the focus method to track if it was called
const focusSpy = jest.fn();
const originalFocus = HTMLInputElement.prototype.focus;
HTMLInputElement.prototype.focus = focusSpy;
await waitForRenderWrapper();
const searchInput = screen.getByTestId(getTestId('search-input'));
// Verify that focus() was called on the search input
expect(focusSpy).toHaveBeenCalled();
expect(searchInput).toBeInTheDocument();
// Restore the original focus method
HTMLInputElement.prototype.focus = originalFocus;
});
});
@@ -575,13 +575,6 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
setIsSearchFocused(true);
}, []);
// Auto-focus the search input when the modal opens
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value),
[],
@@ -34,7 +34,7 @@ import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import { getChartPermalink } from 'src/utils/urlUtils';
import copyTextToClipboard from 'src/utils/copy';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
import { logEvent } from 'src/logger/actions';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
@@ -123,18 +123,12 @@ export const useExploreAdditionalActionsMenu = (
const theme = useTheme();
const { addDangerToast, addSuccessToast } = useToasts();
const dispatch = useDispatch();
const [showReportSubMenu, setShowReportSubMenu] = useState(null);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const chart = useSelector(
state => state.charts?.[getChartKey(state.explore)],
);
// Use the updated report menu items hook
const reportMenuItem = useHeaderReportMenuItems({
chart,
showReportModal,
setCurrentReportDeleting,
});
const { datasource } = latestQueryFormData;
const shareByEmail = useCallback(async () => {
@@ -209,106 +203,14 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
const menu = useMemo(() => {
const menuItems = [];
// Edit chart properties
if (slice) {
menuItems.push({
key: MENU_KEYS.EDIT_PROPERTIES,
label: t('Edit chart properties'),
onClick: () => {
const handleMenuClick = useCallback(
({ key, domEvent }) => {
switch (key) {
case MENU_KEYS.EDIT_PROPERTIES:
onOpenPropertiesModal();
setIsDropdownVisible(false);
},
});
}
// On dashboards submenu
menuItems.push({
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
type: 'submenu',
label: t('On dashboards'),
children: [
{
key: 'dashboards-content',
label: (
<DashboardsSubMenu
chartId={slice?.slice_id}
dashboards={dashboards}
/>
),
},
],
});
// Divider
menuItems.push({ type: 'divider' });
// Download submenu
const downloadChildren = [];
if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) {
downloadChildren.push(
{
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to original .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportCSV();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
label: t('Export to pivoted .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportCSVPivoted();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
label: t('Export to Pivoted Excel'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportPivotExcel(
'.pvtTable',
slice?.slice_name ?? t('pivoted_xlsx'),
);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
);
} else {
downloadChildren.push({
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_CSV:
exportCSV();
setIsDropdownVisible(false);
dispatch(
@@ -317,17 +219,18 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
});
}
downloadChildren.push(
{
key: MENU_KEYS.EXPORT_TO_JSON,
label: t('Export to .JSON'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_CSV_PIVOTED:
exportCSVPivoted();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.EXPORT_TO_JSON:
exportJson();
setIsDropdownVisible(false);
dispatch(
@@ -336,33 +239,8 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.DOWNLOAD_AS_IMAGE,
label: t('Download as image'),
icon: <Icons.FileImageOutlined />,
onClick: e => {
downloadAsImage(
'.panel-body .chart-container',
slice?.slice_name ?? t('New chart'),
true,
)(e.domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_XLSX,
label: t('Export to Excel'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_XLSX:
exportExcel();
setIsDropdownVisible(false);
dispatch(
@@ -371,128 +249,225 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
},
);
menuItems.push({
key: MENU_KEYS.DOWNLOAD_SUBMENU,
type: 'submenu',
label: t('Download'),
children: downloadChildren,
});
// Share submenu
const shareChildren = [
{
key: MENU_KEYS.COPY_PERMALINK,
label: t('Copy permalink to clipboard'),
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
downloadAsImage(
'.panel-body .chart-container',
// eslint-disable-next-line camelcase
slice?.slice_name ?? t('New chart'),
true,
)(domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.COPY_PERMALINK:
copyLink();
setIsDropdownVisible(false);
},
},
{
key: MENU_KEYS.SHARE_BY_EMAIL,
label: t('Share chart by email'),
onClick: () => {
break;
case MENU_KEYS.EMBED_CODE:
setIsDropdownVisible(false);
break;
case MENU_KEYS.SHARE_BY_EMAIL:
shareByEmail();
setIsDropdownVisible(false);
},
},
];
break;
case MENU_KEYS.VIEW_QUERY:
setIsDropdownVisible(false);
break;
case MENU_KEYS.RUN_IN_SQL_LAB:
onOpenInEditor(latestQueryFormData, domEvent.metaKey);
setIsDropdownVisible(false);
break;
default:
break;
}
},
[
copyLink,
exportCSV,
exportCSVPivoted,
exportJson,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,
shareByEmail,
slice?.slice_name,
],
);
if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) {
shareChildren.push({
key: MENU_KEYS.EMBED_CODE,
label: (
const menu = useMemo(
() => (
<Menu onClick={handleMenuClick} selectable={false} {...rest}>
<>
{slice && (
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
{t('Edit chart properties')}
</Menu.Item>
)}
<Menu.SubMenu
title={t('On dashboards')}
key={MENU_KEYS.DASHBOARDS_ADDED_TO}
>
<DashboardsSubMenu
chartId={slice?.slice_id}
dashboards={dashboards}
/>
</Menu.SubMenu>
<Menu.Divider />
</>
<Menu.SubMenu title={t('Download')} key={MENU_KEYS.DOWNLOAD_SUBMENU}>
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
<>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to original .CSV')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to pivoted .CSV')}
</Menu.Item>
</>
) : (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .CSV')}
</Menu.Item>
)}
<Menu.Item
key={MENU_KEYS.EXPORT_TO_JSON}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .JSON')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
icon={<Icons.FileImageOutlined />}
>
{t('Download as image')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_XLSX}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Excel')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
{t('Copy permalink to clipboard')}
</Menu.Item>
<Menu.Item key={MENU_KEYS.SHARE_BY_EMAIL}>
{t('Share chart by email')}
</Menu.Item>
{isFeatureEnabled(FeatureFlag.EmbeddableCharts) ? (
<Menu.Item key={MENU_KEYS.EMBED_CODE}>
<ModalTrigger
triggerNode={
<div data-test="embed-code-button">{t('Embed code')}</div>
}
modalTitle={t('Embed code')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
}
maxWidth={`${theme.sizeUnit * 100}px`}
destroyOnHidden
responsive
/>
</Menu.Item>
) : null}
</Menu.SubMenu>
<Menu.Divider />
{showReportSubMenu ? (
<>
<HeaderReportDropDown
submenuTitle={t('Manage email report')}
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
showReportSubMenu={showReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
<Menu.Divider />
</>
) : (
<HeaderReportDropDown
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
)}
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger
triggerNode={
<div data-test="embed-code-button">{t('Embed code')}</div>
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('Embed code')}
modalTitle={t('View query')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
}
maxWidth={`${theme.sizeUnit * 100}px`}
destroyOnHidden
draggable
resizable
responsive
/>
),
onClick: () => setIsDropdownVisible(false),
});
}
menuItems.push({
key: MENU_KEYS.SHARE_SUBMENU,
type: 'submenu',
label: t('Share'),
children: shareChildren,
});
// Divider
menuItems.push({ type: 'divider' });
// Report menu item
if (reportMenuItem) {
menuItems.push(reportMenuItem);
}
// View query
menuItems.push({
key: MENU_KEYS.VIEW_QUERY,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
}
draggable
resizable
responsive
/>
),
onClick: () => setIsDropdownVisible(false),
});
// Run in SQL Lab
if (datasource) {
menuItems.push({
key: MENU_KEYS.RUN_IN_SQL_LAB,
label: t('Run in SQL Lab'),
onClick: e => {
onOpenInEditor(latestQueryFormData, e.domEvent.metaKey);
setIsDropdownVisible(false);
},
});
}
return <Menu selectable={false} items={menuItems} {...rest} />;
}, [
addDangerToast,
canDownloadCSV,
copyLink,
dashboards,
datasource,
dispatch,
exportCSV,
exportCSVPivoted,
exportExcel,
exportJson,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,
reportMenuItem,
shareByEmail,
slice,
theme.sizeUnit,
]);
</Menu.Item>
{datasource && (
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
{t('Run in SQL Lab')}
</Menu.Item>
)}
</Menu>
),
[
addDangerToast,
canDownloadCSV,
chart,
dashboards,
handleMenuClick,
isDropdownVisible,
latestQueryFormData,
showReportSubMenu,
slice,
theme.sizeUnit,
],
);
return [menu, isDropdownVisible, setIsDropdownVisible];
};
@@ -191,8 +191,8 @@ describe('exploreUtils', () => {
});
describe('buildV1ChartDataPayload', () => {
it('generate valid request payload despite no registered buildQuery', async () => {
const v1RequestPayload = await buildV1ChartDataPayload({
it('generate valid request payload despite no registered buildQuery', () => {
const v1RequestPayload = buildV1ChartDataPayload({
formData: { ...formData, viz_type: 'my_custom_viz' },
});
expect(v1RequestPayload.hasOwnProperty('queries')).toBeTruthy();
@@ -207,7 +207,7 @@ export const getQuerySettings = formData => {
];
};
export const buildV1ChartDataPayload = async ({
export const buildV1ChartDataPayload = ({
formData,
force,
resultFormat,
@@ -242,7 +242,7 @@ export const buildV1ChartDataPayload = async ({
export const getLegacyEndpointType = ({ resultType, resultFormat }) =>
resultFormat === 'csv' ? resultFormat : resultType;
export const exportChart = async ({
export const exportChart = ({
formData,
resultFormat = 'json',
resultType = 'full',
@@ -262,7 +262,7 @@ export const exportChart = async ({
payload = formData;
} else {
url = ensureAppRoot('/api/v1/chart/data');
payload = await buildV1ChartDataPayload({
payload = buildV1ChartDataPayload({
formData,
force,
resultFormat,

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