mirror of
https://github.com/apache/superset.git
synced 2026-07-31 19:12:27 +00:00
Compare commits
58 Commits
no-console
...
mcp_servic
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d7467bafd | ||
|
|
71f294b7d3 | ||
|
|
e839d0989a | ||
|
|
714e21b3ec | ||
|
|
bf78eb69ed | ||
|
|
a13c1ba8c2 | ||
|
|
422c34a6ee | ||
|
|
cd213fc57d | ||
|
|
0f20a88598 | ||
|
|
33d16eaca1 | ||
|
|
5b56bc622b | ||
|
|
8cbfe027b5 | ||
|
|
c3b3edc6ba | ||
|
|
aa06bb9fda | ||
|
|
0f222b9034 | ||
|
|
7044153ca4 | ||
|
|
ce82c35bb6 | ||
|
|
9b25bd973f | ||
|
|
5f7502b85c | ||
|
|
1d5372210f | ||
|
|
64af53f6f6 | ||
|
|
56b308340e | ||
|
|
3b2b7609a8 | ||
|
|
61a91f80fe | ||
|
|
ed3c5ecbc2 | ||
|
|
c8fbd4233c | ||
|
|
fc85f68585 | ||
|
|
e825bbe1f4 | ||
|
|
a80b637a2a | ||
|
|
541b3bd727 | ||
|
|
a909799e5c | ||
|
|
01329f1c62 | ||
|
|
67f621d360 | ||
|
|
b552dbf4a1 | ||
|
|
364af98c04 | ||
|
|
afdb8b38a6 | ||
|
|
fc7ea804bc | ||
|
|
7c256ae9aa | ||
|
|
1b190abc3b | ||
|
|
c6c71bf835 | ||
|
|
cd5ead7f11 | ||
|
|
e5eebe28f9 | ||
|
|
9eac6ef433 | ||
|
|
d523d523e5 | ||
|
|
91a3214ed4 | ||
|
|
95b787f024 | ||
|
|
39121791e8 | ||
|
|
9d40fe913f | ||
|
|
748ae49c8c | ||
|
|
a9d543b6f4 | ||
|
|
55d6130fc4 | ||
|
|
b98e3eb309 | ||
|
|
b469077e0e | ||
|
|
397b4e450b | ||
|
|
0f97002520 | ||
|
|
2312250127 | ||
|
|
cd52193869 | ||
|
|
9ffe680aaa |
@@ -1,20 +0,0 @@
|
||||
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
|
||||
FROM python:3.11.13-bookworm AS base
|
||||
|
||||
# Install system dependencies that Superset needs
|
||||
# This layer will be cached across Codespace sessions
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv for fast Python package management
|
||||
# This will also be cached in the image
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
|
||||
|
||||
# Set the cargo/bin directory in PATH for all users
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
@@ -3,14 +3,3 @@
|
||||
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
|
||||
|
||||
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**
|
||||
|
||||
## Pre-installed Development Environment
|
||||
|
||||
When you create a new Codespace from this repository, it automatically:
|
||||
|
||||
1. **Creates a Python virtual environment** using `uv venv`
|
||||
2. **Installs all development dependencies** via `uv pip install -r requirements/development.txt`
|
||||
3. **Sets up pre-commit hooks** with `pre-commit install`
|
||||
4. **Activates the virtual environment** automatically in all terminals
|
||||
|
||||
The virtual environment is located at `/workspaces/{repository-name}/.venv` and is automatically activated through environment variables set in the devcontainer configuration.
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
# Superset Codespaces environment setup
|
||||
# This file is appended to ~/.bashrc during Codespace setup
|
||||
|
||||
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
|
||||
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||
|
||||
if [ -n "$WORKSPACE_DIR" ]; then
|
||||
# Check if virtual environment exists
|
||||
if [ -d "$WORKSPACE_DIR/.venv" ]; then
|
||||
# Activate the virtual environment
|
||||
source "$WORKSPACE_DIR/.venv/bin/activate"
|
||||
echo "✅ Python virtual environment activated"
|
||||
|
||||
# Verify pre-commit is installed and set up
|
||||
if command -v pre-commit &> /dev/null; then
|
||||
echo "✅ pre-commit is available ($(pre-commit --version))"
|
||||
# Install git hooks if not already installed
|
||||
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
|
||||
echo "🪝 Installing pre-commit hooks..."
|
||||
cd "$WORKSPACE_DIR" && pre-commit install
|
||||
fi
|
||||
else
|
||||
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
|
||||
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
|
||||
fi
|
||||
|
||||
# Always cd to the workspace directory for convenience
|
||||
cd "$WORKSPACE_DIR"
|
||||
fi
|
||||
|
||||
# Add helpful aliases for Superset development
|
||||
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
|
||||
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
|
||||
|
||||
# Show helpful message on login
|
||||
echo ""
|
||||
echo "🚀 Superset Codespaces Environment"
|
||||
echo "=================================="
|
||||
|
||||
# Check if Superset is running
|
||||
if docker ps 2>/dev/null | grep -q "superset"; then
|
||||
echo "✅ Superset is running!"
|
||||
echo " - Check the 'Ports' tab for your live Superset URL"
|
||||
echo " - Initial startup takes 10-20 minutes"
|
||||
echo " - Login: admin/admin"
|
||||
else
|
||||
echo "⚠️ Superset is not running. Use: start-superset"
|
||||
# Check if there's a startup log
|
||||
if [ -f "/tmp/superset-startup.log" ]; then
|
||||
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Quick commands:"
|
||||
echo " start-superset - Start Superset with Docker Compose"
|
||||
echo " setup-dev - Set up Python environment (if not already done)"
|
||||
echo " pre-commit run - Run pre-commit checks on staged files"
|
||||
echo ""
|
||||
@@ -1,20 +0,0 @@
|
||||
#!/bin/bash
|
||||
# Script to build and push the devcontainer image to GitHub Container Registry
|
||||
# This allows caching the image between Codespace sessions
|
||||
|
||||
# You'll need to run this with appropriate GitHub permissions
|
||||
# gh auth login --scopes write:packages
|
||||
|
||||
REGISTRY="ghcr.io"
|
||||
OWNER="apache"
|
||||
REPO="superset"
|
||||
TAG="devcontainer-base"
|
||||
|
||||
echo "Building devcontainer image..."
|
||||
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
|
||||
|
||||
echo "Pushing to GitHub Container Registry..."
|
||||
docker push $REGISTRY/$OWNER/$REPO:$TAG
|
||||
|
||||
echo "Done! Update .devcontainer/devcontainer.json to use:"
|
||||
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""
|
||||
19
.devcontainer/default/devcontainer.json
Normal file
19
.devcontainer/default/devcontainer.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
// Extend the base configuration
|
||||
"extends": "../devcontainer-base.json",
|
||||
|
||||
"name": "Apache Superset Development (Default)",
|
||||
|
||||
// Forward ports for development
|
||||
"forwardPorts": [9001],
|
||||
"portsAttributes": {
|
||||
"9001": {
|
||||
"label": "Superset (via Webpack Dev Server)",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "public"
|
||||
}
|
||||
},
|
||||
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.sh"
|
||||
}
|
||||
39
.devcontainer/devcontainer-base.json
Normal file
39
.devcontainer/devcontainer-base.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
|
||||
// Using the same base as Dockerfile, but non-slim for dev tools
|
||||
"image": "python:3.11.13-bookworm",
|
||||
|
||||
"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"
|
||||
}
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.vscode-pylance",
|
||||
"charliermarsh.ruff",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,8 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// Option 1: Use pre-built image directly
|
||||
// "image": "ghcr.io/apache/superset:devcontainer-base",
|
||||
|
||||
// Option 2: Build from Dockerfile with cache (current approach)
|
||||
"build": {
|
||||
"dockerfile": "Dockerfile",
|
||||
"context": ".",
|
||||
// Cache from the Apache registry image
|
||||
"cacheFrom": ["ghcr.io/apache/superset:devcontainer-base"]
|
||||
},
|
||||
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
|
||||
// Using the same base as Dockerfile, but non-slim for dev tools
|
||||
"image": "python:3.11.13-bookworm",
|
||||
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
@@ -39,17 +32,10 @@
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
|
||||
// Auto-start Superset after ensuring Docker is ready
|
||||
// Run in foreground to see any errors, but don't block on failures
|
||||
"postStartCommand": "bash -c 'echo \"Waiting 30s for services to initialize...\"; sleep 30; .devcontainer/start-superset.sh || echo \"⚠️ Auto-start failed - run start-superset manually\"'",
|
||||
|
||||
// Set environment variables
|
||||
"remoteEnv": {
|
||||
// Removed automatic venv activation to prevent startup issues
|
||||
// The setup script will handle this
|
||||
},
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.sh",
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
@@ -3,76 +3,30 @@
|
||||
|
||||
echo "🔧 Setting up Superset development environment..."
|
||||
|
||||
# System dependencies and uv are now pre-installed in the Docker image
|
||||
# This speeds up Codespace creation significantly!
|
||||
# The universal image has most tools, just need Superset-specific libs
|
||||
echo "📦 Installing Superset-specific dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh
|
||||
|
||||
# Create virtual environment using uv
|
||||
echo "🐍 Creating Python virtual environment..."
|
||||
if ! uv venv; then
|
||||
echo "❌ Failed to create virtual environment"
|
||||
exit 1
|
||||
fi
|
||||
# Install uv for fast Python package management
|
||||
echo "📦 Installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
|
||||
# Install Python dependencies
|
||||
echo "📦 Installing Python dependencies..."
|
||||
if ! uv pip install -r requirements/development.txt; then
|
||||
echo "❌ Failed to install Python dependencies"
|
||||
echo "💡 You may need to run this manually after the Codespace starts"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Install pre-commit hooks
|
||||
echo "🪝 Installing pre-commit hooks..."
|
||||
if source .venv/bin/activate && pre-commit install; then
|
||||
echo "✅ Pre-commit hooks installed"
|
||||
else
|
||||
echo "⚠️ Pre-commit hooks installation failed (non-critical)"
|
||||
fi
|
||||
# Add cargo/bin to PATH for uv
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
|
||||
|
||||
# Install Claude Code CLI via npm
|
||||
echo "🤖 Installing Claude Code..."
|
||||
if npm install -g @anthropic-ai/claude-code; then
|
||||
echo "✅ Claude Code installed"
|
||||
else
|
||||
echo "⚠️ Claude Code installation failed (non-critical)"
|
||||
fi
|
||||
npm install -g @anthropic-ai/claude-code
|
||||
|
||||
# Make the start script executable
|
||||
chmod +x .devcontainer/start-superset.sh
|
||||
|
||||
# Add bashrc additions for automatic venv activation
|
||||
echo "🔧 Setting up automatic environment activation..."
|
||||
if [ -f ~/.bashrc ]; then
|
||||
# Check if we've already added our additions
|
||||
if ! grep -q "Superset Codespaces environment setup" ~/.bashrc; then
|
||||
echo "" >> ~/.bashrc
|
||||
cat .devcontainer/bashrc-additions >> ~/.bashrc
|
||||
echo "✅ Added automatic venv activation to ~/.bashrc"
|
||||
else
|
||||
echo "✅ Bashrc additions already present"
|
||||
fi
|
||||
else
|
||||
# Create bashrc if it doesn't exist
|
||||
cat .devcontainer/bashrc-additions > ~/.bashrc
|
||||
echo "✅ Created ~/.bashrc with automatic venv activation"
|
||||
fi
|
||||
|
||||
# Also add to zshrc since that's the default shell
|
||||
if [ -f ~/.zshrc ] || [ -n "$ZSH_VERSION" ]; then
|
||||
if ! grep -q "Superset Codespaces environment setup" ~/.zshrc; then
|
||||
echo "" >> ~/.zshrc
|
||||
cat .devcontainer/bashrc-additions >> ~/.zshrc
|
||||
echo "✅ Added automatic venv activation to ~/.zshrc"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ Development environment setup complete!"
|
||||
echo ""
|
||||
echo "📝 The virtual environment will be automatically activated in new terminals"
|
||||
echo ""
|
||||
echo "🔄 To activate in this terminal, run:"
|
||||
echo " source ~/.bashrc"
|
||||
echo ""
|
||||
echo "🚀 To start Superset:"
|
||||
echo " start-superset"
|
||||
echo ""
|
||||
echo "🚀 Run '.devcontainer/start-superset.sh' to start Superset"
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
#!/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"
|
||||
|
||||
# Check if MCP is enabled
|
||||
if [ "$ENABLE_MCP" = "true" ]; then
|
||||
echo "🤖 MCP Service will be available at port 5008"
|
||||
fi
|
||||
|
||||
# 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
|
||||
@@ -18,71 +18,32 @@ else
|
||||
echo "📁 Using current directory: $(pwd)"
|
||||
fi
|
||||
|
||||
# Wait for Docker to be available
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
|
||||
max_attempts=30
|
||||
attempt=0
|
||||
while ! docker info > /dev/null 2>&1; do
|
||||
if [ $attempt -eq $max_attempts ]; then
|
||||
echo "❌ Docker failed to start after $max_attempts attempts"
|
||||
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
|
||||
echo "🔄 Please restart the Codespace or run this script manually later"
|
||||
exit 1
|
||||
fi
|
||||
echo " Attempt $((attempt + 1))/$max_attempts..."
|
||||
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
|
||||
sleep 2
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
echo "✅ Docker is ready!"
|
||||
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
|
||||
|
||||
# Check if Superset containers are already running
|
||||
if docker ps | grep -q "superset"; then
|
||||
echo "✅ Superset containers are already running!"
|
||||
echo ""
|
||||
echo "🌐 To access Superset:"
|
||||
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
|
||||
echo " 2. Find port 9001 and click the globe icon to open"
|
||||
echo " 3. Wait 10-20 minutes for initial startup"
|
||||
echo ""
|
||||
echo "📝 Login credentials: admin/admin"
|
||||
exit 0
|
||||
# Check if docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
sleep 5
|
||||
fi
|
||||
|
||||
# Clean up any existing containers
|
||||
echo "🧹 Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose-light.yml down
|
||||
docker-compose -f docker-compose-light.yml --profile mcp down
|
||||
|
||||
# Start services
|
||||
echo "🏗️ Starting Superset in background (daemon mode)..."
|
||||
echo "🏗️ Building and starting services..."
|
||||
echo ""
|
||||
echo "📝 Once started, login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin"
|
||||
echo ""
|
||||
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
|
||||
|
||||
# Start in detached mode
|
||||
docker-compose -f docker-compose-light.yml up -d
|
||||
|
||||
echo ""
|
||||
echo "✅ Docker Compose started successfully!"
|
||||
echo ""
|
||||
echo "📋 Important information:"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "⏱️ Initial startup takes 10-20 minutes"
|
||||
echo "🌐 Check the 'Ports' tab for your Superset URL (port 9001)"
|
||||
echo "👤 Login: admin / admin"
|
||||
echo ""
|
||||
echo "📊 Useful commands:"
|
||||
echo " docker-compose -f docker-compose-light.yml logs -f # Follow logs"
|
||||
echo " docker-compose -f docker-compose-light.yml ps # Check status"
|
||||
echo " docker-compose -f docker-compose-light.yml down # Stop services"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo ""
|
||||
echo "💤 Keeping terminal open for 60 seconds to test persistence..."
|
||||
sleep 60
|
||||
echo "✅ Test complete - check if this terminal is still visible!"
|
||||
|
||||
# Show final status
|
||||
docker-compose -f docker-compose-light.yml ps
|
||||
# Run docker-compose and capture exit code
|
||||
if [ "$ENABLE_MCP" = "true" ]; then
|
||||
echo "🤖 Starting with MCP Service enabled..."
|
||||
docker-compose -f docker-compose-light.yml --profile mcp up
|
||||
else
|
||||
docker-compose -f docker-compose-light.yml up
|
||||
fi
|
||||
EXIT_CODE=$?
|
||||
|
||||
# If it failed, provide helpful instructions
|
||||
|
||||
29
.devcontainer/with-mcp/devcontainer.json
Normal file
29
.devcontainer/with-mcp/devcontainer.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
// Extend the base configuration
|
||||
"extends": "../devcontainer-base.json",
|
||||
|
||||
"name": "Apache Superset Development with MCP",
|
||||
|
||||
// Forward ports for development
|
||||
"forwardPorts": [9001, 5008],
|
||||
"portsAttributes": {
|
||||
"9001": {
|
||||
"label": "Superset (via Webpack Dev Server)",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "public"
|
||||
},
|
||||
"5008": {
|
||||
"label": "MCP Service (Model Context Protocol)",
|
||||
"onAutoForward": "notify",
|
||||
"visibility": "private"
|
||||
}
|
||||
},
|
||||
|
||||
// Auto-start Superset with MCP on Codespace resume
|
||||
"postStartCommand": "ENABLE_MCP=true .devcontainer/start-superset.sh",
|
||||
|
||||
// Environment variables
|
||||
"containerEnv": {
|
||||
"ENABLE_MCP": "true"
|
||||
}
|
||||
}
|
||||
215
CHART_METADATA_API.md
Normal file
215
CHART_METADATA_API.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# Chart Metadata API Reference
|
||||
|
||||
The Superset MCP service provides rich metadata alongside chart generation to enable better UI integration and user experiences.
|
||||
|
||||
## Background & Design Philosophy
|
||||
|
||||
Modern chart systems need to provide more than just visual output. Inspired by contemporary web standards and LLM integration patterns, this metadata system addresses several key needs:
|
||||
|
||||
**Accessibility-First Design**: Following WCAG guidelines and `aria-*` attribute patterns, charts include semantic descriptions and accessibility metadata to ensure inclusive experiences.
|
||||
|
||||
**Rich Context for AI Systems**: Similar to how platforms like social media generate rich previews (OpenGraph, Twitter Cards), charts provide semantic understanding beyond just visual representation - enabling AI agents to reason about and describe visualizations meaningfully.
|
||||
|
||||
**Performance-Aware Integration**: Modern web APIs emphasize performance transparency (Core Web Vitals, etc.). Charts include execution metrics and optimization suggestions to help UIs make informed decisions about rendering and user feedback.
|
||||
|
||||
**Capability-Driven UX**: Rather than requiring UIs to hardcode chart type behaviors, the system exposes what each chart can actually do - enabling dynamic, contextual interfaces that adapt to chart capabilities.
|
||||
|
||||
## Overview
|
||||
|
||||
When generating charts via `generate_chart`, the response includes structured metadata that helps UIs:
|
||||
- Present appropriate controls and interactions
|
||||
- Generate accessible descriptions
|
||||
- Optimize rendering performance
|
||||
- Guide user workflows
|
||||
|
||||
## Metadata Types
|
||||
|
||||
### ChartCapabilities
|
||||
|
||||
Describes what interactions and features the chart supports.
|
||||
|
||||
```python
|
||||
{
|
||||
"supports_interaction": bool, # User can interact (zoom, pan, hover)
|
||||
"supports_real_time": bool, # Chart can update with live data
|
||||
"supports_drill_down": bool, # Can navigate to more detailed views
|
||||
"supports_export": bool, # Can be exported to other formats
|
||||
"optimal_formats": [ # Recommended preview formats
|
||||
"url", # Static image URL
|
||||
"interactive", # HTML with JavaScript controls
|
||||
"ascii", # Text-based representation
|
||||
"vega_lite" # Vega-Lite specification
|
||||
],
|
||||
"data_types": [ # Types of data visualized
|
||||
"time_series", # Time-based data
|
||||
"categorical", # Discrete categories
|
||||
"metric" # Numeric measurements
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**UI Integration:**
|
||||
- Show/hide interaction controls based on `supports_interaction`
|
||||
- Enable real-time updates if `supports_real_time`
|
||||
- Display drill-down options for `supports_drill_down`
|
||||
- Choose optimal preview format from `optimal_formats`
|
||||
|
||||
### ChartSemantics
|
||||
|
||||
Provides semantic understanding of what the chart represents and reveals.
|
||||
|
||||
```python
|
||||
{
|
||||
"primary_insight": "Shows trends and changes over time",
|
||||
"data_story": "This line chart analyzes sales, revenue over Q1-Q4",
|
||||
"recommended_actions": [
|
||||
"Review data patterns and trends",
|
||||
"Consider filtering for more detail",
|
||||
"Export chart for reporting"
|
||||
],
|
||||
"anomalies": [], # Notable outliers (future enhancement)
|
||||
"statistical_summary": {} # Key statistics (future enhancement)
|
||||
}
|
||||
```
|
||||
|
||||
**UI Integration:**
|
||||
- Display `primary_insight` as chart description
|
||||
- Use `data_story` for accessibility and tooltips
|
||||
- Show `recommended_actions` as suggested next steps
|
||||
- Highlight `anomalies` in the visualization
|
||||
|
||||
### AccessibilityMetadata
|
||||
|
||||
Information for creating inclusive, accessible chart experiences.
|
||||
|
||||
```python
|
||||
{
|
||||
"color_blind_safe": bool, # Uses colorblind-friendly palette
|
||||
"alt_text": "Chart showing Sales Data over time",
|
||||
"high_contrast_available": bool # High contrast version available
|
||||
}
|
||||
```
|
||||
|
||||
**UI Integration:**
|
||||
- Use `alt_text` for screen readers
|
||||
- Show accessibility indicators if `color_blind_safe`
|
||||
- Offer high contrast mode if available
|
||||
|
||||
### PerformanceMetadata
|
||||
|
||||
Performance information for optimization and user feedback.
|
||||
|
||||
```python
|
||||
{
|
||||
"query_duration_ms": 1250, # Time to generate chart data
|
||||
"cache_status": "hit|miss|error", # Whether data came from cache
|
||||
"optimization_suggestions": [ # Performance improvement tips
|
||||
"Consider adding date filters to reduce data volume",
|
||||
"Chart complexity may impact load time"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**UI Integration:**
|
||||
- Show loading indicators based on `query_duration_ms`
|
||||
- Display cache status for debugging
|
||||
- Present `optimization_suggestions` to users
|
||||
- Warn about slow queries
|
||||
|
||||
## Example Response
|
||||
|
||||
```json
|
||||
{
|
||||
"chart": {
|
||||
"id": 123,
|
||||
"slice_name": "Sales Trends Q1-Q4",
|
||||
"viz_type": "echarts_timeseries_line",
|
||||
"url": "/explore/?slice_id=123"
|
||||
},
|
||||
"capabilities": {
|
||||
"supports_interaction": true,
|
||||
"supports_real_time": false,
|
||||
"supports_drill_down": false,
|
||||
"supports_export": true,
|
||||
"optimal_formats": ["url", "interactive", "ascii"],
|
||||
"data_types": ["time_series", "metric"]
|
||||
},
|
||||
"semantics": {
|
||||
"primary_insight": "Shows trends and changes over time",
|
||||
"data_story": "This line chart analyzes sales over Q1-Q4",
|
||||
"recommended_actions": [
|
||||
"Review seasonal patterns",
|
||||
"Export for quarterly report"
|
||||
]
|
||||
},
|
||||
"accessibility": {
|
||||
"color_blind_safe": true,
|
||||
"alt_text": "Line chart showing sales trends from Q1 to Q4",
|
||||
"high_contrast_available": false
|
||||
},
|
||||
"performance": {
|
||||
"query_duration_ms": 450,
|
||||
"cache_status": "miss",
|
||||
"optimization_suggestions": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### React Component Integration
|
||||
|
||||
```jsx
|
||||
function ChartComponent({ chartData }) {
|
||||
const { capabilities, semantics, accessibility, performance } = chartData;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Accessibility */}
|
||||
<img
|
||||
src={chartData.chart.url}
|
||||
alt={accessibility.alt_text}
|
||||
aria-describedby="chart-description"
|
||||
/>
|
||||
|
||||
{/* Semantic description */}
|
||||
<p id="chart-description">{semantics.primary_insight}</p>
|
||||
|
||||
{/* Conditional controls based on capabilities */}
|
||||
{capabilities.supports_interaction && (
|
||||
<InteractiveControls />
|
||||
)}
|
||||
|
||||
{capabilities.supports_export && (
|
||||
<ExportButton />
|
||||
)}
|
||||
|
||||
{/* Performance feedback */}
|
||||
{performance.query_duration_ms > 2000 && (
|
||||
<SlowQueryWarning suggestions={performance.optimization_suggestions} />
|
||||
)}
|
||||
|
||||
{/* Recommended actions */}
|
||||
<ActionSuggestions actions={semantics.recommended_actions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Chart Type Mapping
|
||||
|
||||
Different chart types provide different capabilities:
|
||||
|
||||
| Chart Type | Interaction | Real-time | Drill-down | Optimal Formats |
|
||||
|------------|------------|-----------|------------|-----------------|
|
||||
| `echarts_timeseries_line` | ✅ | ✅ | ❌ | url, interactive, ascii |
|
||||
| `echarts_timeseries_bar` | ✅ | ✅ | ❌ | url, interactive, ascii |
|
||||
| `table` | ❌ | ❌ | ✅ | url, table, ascii |
|
||||
| `pie` | ✅ | ❌ | ❌ | url, interactive |
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- **Statistical Summary**: Automatic calculation of mean, median, trends
|
||||
- **Anomaly Detection**: Identification of outliers and unusual patterns
|
||||
- **Smart Recommendations**: ML-powered suggestions for chart improvements
|
||||
- **Accessibility Scoring**: Automated accessibility compliance checking
|
||||
1
LLMS.md
1
LLMS.md
@@ -180,6 +180,7 @@ pre-commit run eslint # Frontend linting
|
||||
|
||||
## Platform-Specific Instructions
|
||||
|
||||
- **[LLMS.md](LLMS.md)** - General LLM development guide (READ THIS FIRST)
|
||||
- **[CLAUDE.md](CLAUDE.md)** - For Claude/Anthropic tools
|
||||
- **[.github/copilot-instructions.md](.github/copilot-instructions.md)** - For GitHub Copilot
|
||||
- **[GEMINI.md](GEMINI.md)** - For Google Gemini tools
|
||||
|
||||
@@ -25,6 +25,12 @@
|
||||
# - Volumes are isolated by project name (e.g., project1_db_home_light, project2_db_home_light)
|
||||
# - Database name is intentionally different (superset_light) to prevent accidental cross-connections
|
||||
#
|
||||
# MCP Service (Model Context Protocol):
|
||||
# - Optional service for LLM agent integration, available under 'mcp' profile
|
||||
# - To include MCP: docker-compose -f docker-compose-light.yml --profile mcp up
|
||||
# - MCP runs on port 5008 by default (customize with MCP_PORT=5009)
|
||||
# - Enable SQL debugging with MCP_SQL_DEBUG=true
|
||||
#
|
||||
# For verbose logging during development:
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
|
||||
# -----------------------------------------------------------------------
|
||||
@@ -150,6 +156,37 @@ services:
|
||||
required: false
|
||||
volumes: *superset-volumes
|
||||
|
||||
superset-mcp-light:
|
||||
profiles:
|
||||
- mcp
|
||||
build:
|
||||
<<: *common-build
|
||||
command: ["/app/docker/docker-bootstrap.sh", "mcp"]
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${MCP_PORT:-5008}:5008" # Parameterized port
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
user: *superset-user
|
||||
depends_on:
|
||||
superset-init-light:
|
||||
condition: service_completed_successfully
|
||||
volumes: *superset-volumes
|
||||
env_file:
|
||||
- path: docker/.env # default
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
environment:
|
||||
# Override DB connection for light service
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: superset_light
|
||||
POSTGRES_DB: superset_light
|
||||
# Use light-specific config that disables Redis
|
||||
SUPERSET_CONFIG_PATH: /app/docker/pythonpath_dev/superset_config_docker_light.py
|
||||
# Enable SQL debugging for MCP if needed
|
||||
SQLALCHEMY_DEBUG: ${MCP_SQL_DEBUG:-false}
|
||||
|
||||
volumes:
|
||||
superset_home_light:
|
||||
external: false
|
||||
|
||||
@@ -78,6 +78,10 @@ case "${1}" in
|
||||
echo "Starting web app..."
|
||||
/usr/bin/run-server.sh
|
||||
;;
|
||||
mcp)
|
||||
echo "Starting MCP service..."
|
||||
superset mcp run --host 0.0.0.0 --port ${MCP_PORT:-5008} --debug
|
||||
;;
|
||||
*)
|
||||
echo "Unknown Operation!!!"
|
||||
;;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -87,66 +87,8 @@ Restart Superset to apply changes.
|
||||
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
|
||||
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
Add font URLs to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
|
||||
CUSTOM_FONT_URLS = [
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||||
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
]
|
||||
|
||||
# Update CSP to allow font sources
|
||||
TALISMAN_CONFIG = {
|
||||
"content_security_policy": {
|
||||
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
||||
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Custom Fonts in Themes
|
||||
|
||||
Once configured, reference the fonts in your theme configuration:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
|
||||
# ... other theme tokens
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or in the CRUD interface theme JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Font Sources
|
||||
|
||||
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
|
||||
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
|
||||
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
|
||||
|
||||
This feature works with the stock Docker image - no custom build required!
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Superset includes built-in light and dark themes
|
||||
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
||||
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
||||
- **Custom Fonts**: Load external fonts via configuration without rebuilding
|
||||
|
||||
@@ -137,7 +137,7 @@ contributing to Apache Superset more accessible to developers worldwide.
|
||||
|
||||
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=codespaces&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
|
||||
|
||||
:::caution
|
||||
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||
@@ -421,6 +421,14 @@ Then make sure you run your WSGI server using the right worker type:
|
||||
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
|
||||
```
|
||||
|
||||
You can log anything to the browser console, including objects:
|
||||
|
||||
```python
|
||||
from superset import app
|
||||
app.logger.error('An exception occurred!')
|
||||
app.logger.info(form_data)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
|
||||
|
||||
741
docs/docs/mcp-service/api-reference.mdx
Normal file
741
docs/docs/mcp-service/api-reference.mdx
Normal file
@@ -0,0 +1,741 @@
|
||||
---
|
||||
title: API Reference
|
||||
sidebar_position: 3
|
||||
version: 1
|
||||
---
|
||||
|
||||
# MCP Tools API Reference
|
||||
|
||||
Complete reference for all 16 MCP tools with request/response examples.
|
||||
|
||||
> 🚀 **First time here?** Start with [Dashboard Tools](#dashboard-tools) or [Chart Tools](#chart-tools) to see the most commonly used features.
|
||||
>
|
||||
> 🔐 **Need authentication?** See the [Authentication Guide](./authentication) for JWT setup.
|
||||
>
|
||||
> 🔧 **Want to add tools?** Check the [Development Guide](./development#adding-new-tools) for step-by-step instructions.
|
||||
|
||||
## Dashboard Tools
|
||||
|
||||
### list_dashboards
|
||||
|
||||
List dashboards with search, filtering, and pagination support.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"search": "sales", // Optional: Search term
|
||||
"filters": [ // Optional: Advanced filters
|
||||
{
|
||||
"col": "published",
|
||||
"opr": "eq",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"page": 1, // Optional: Page number (default: 1)
|
||||
"page_size": 20, // Optional: Items per page (default: 20)
|
||||
"select_columns": [ // Optional: Specific columns
|
||||
"id", "dashboard_title", "uuid"
|
||||
],
|
||||
"use_cache": true // Optional: Use cached data (default: true)
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"dashboards": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"dashboard_title": "Sales Performance",
|
||||
"url": "/superset/dashboard/1/",
|
||||
"published": true,
|
||||
"owners": ["admin"],
|
||||
"created_on": "2024-01-15T10:30:00Z",
|
||||
"changed_on": "2024-01-20T14:15:00Z"
|
||||
}
|
||||
],
|
||||
"total_count": 45,
|
||||
"page": 1,
|
||||
"page_size": 20,
|
||||
"cache_status": {
|
||||
"cache_hit": true,
|
||||
"cache_age_seconds": 300
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### get_dashboard_info
|
||||
|
||||
Get detailed information about a specific dashboard.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"identifier": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", // ID, UUID, or slug
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"dashboard_id": 1,
|
||||
"uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
|
||||
"dashboard_title": "Sales Performance Dashboard",
|
||||
"slug": "sales-performance",
|
||||
"url": "/superset/dashboard/1/",
|
||||
"published": true,
|
||||
"owners": ["admin", "analyst"],
|
||||
"roles": ["Sales Team"],
|
||||
"charts": [
|
||||
{
|
||||
"id": 10,
|
||||
"slice_name": "Monthly Revenue",
|
||||
"viz_type": "line"
|
||||
},
|
||||
{
|
||||
"id": 11,
|
||||
"slice_name": "Regional Sales",
|
||||
"viz_type": "bar"
|
||||
}
|
||||
],
|
||||
"filters": [
|
||||
{
|
||||
"column": "region",
|
||||
"type": "select"
|
||||
}
|
||||
],
|
||||
"created_on": "2024-01-15T10:30:00Z",
|
||||
"changed_on": "2024-01-20T14:15:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### generate_dashboard
|
||||
|
||||
Create a new dashboard with multiple charts.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"chart_ids": [10, 11, 12, 13],
|
||||
"dashboard_title": "Q4 Performance Dashboard",
|
||||
"description": "Quarterly performance metrics and KPIs",
|
||||
"published": true,
|
||||
"layout_type": "grid" // Optional: "grid" or "tabs"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"dashboard_id": 25,
|
||||
"uuid": "new-dash-uuid-here",
|
||||
"dashboard_title": "Q4 Performance Dashboard",
|
||||
"url": "/superset/dashboard/25/",
|
||||
"charts_added": 4,
|
||||
"layout": {
|
||||
"type": "grid",
|
||||
"columns": 2,
|
||||
"rows": 2
|
||||
},
|
||||
"created_on": "2024-01-25T16:45:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## Chart Tools
|
||||
|
||||
### list_charts
|
||||
|
||||
List charts with advanced filtering and search capabilities.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"search": "revenue",
|
||||
"filters": [
|
||||
{
|
||||
"col": "viz_type",
|
||||
"opr": "in",
|
||||
"value": ["line", "bar", "area"]
|
||||
}
|
||||
],
|
||||
"page": 1,
|
||||
"page_size": 25,
|
||||
"select_columns": ["id", "slice_name", "viz_type", "uuid"],
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"charts": [
|
||||
{
|
||||
"id": 10,
|
||||
"uuid": "chart-uuid-1",
|
||||
"slice_name": "Monthly Revenue Trend",
|
||||
"viz_type": "line",
|
||||
"datasource_name": "sales_data",
|
||||
"owners": ["admin"],
|
||||
"created_on": "2024-01-10T09:15:00Z"
|
||||
}
|
||||
],
|
||||
"total_count": 125,
|
||||
"page": 1,
|
||||
"page_size": 25
|
||||
}
|
||||
```
|
||||
|
||||
### get_chart_info
|
||||
|
||||
Get comprehensive chart information including configuration.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"identifier": 10, // ID or UUID
|
||||
"include_form_data": true, // Include chart configuration
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"chart_id": 10,
|
||||
"uuid": "chart-uuid-1",
|
||||
"slice_name": "Monthly Revenue Trend",
|
||||
"viz_type": "line",
|
||||
"datasource_id": 5,
|
||||
"datasource_name": "sales_data",
|
||||
"datasource_type": "table",
|
||||
"form_data": {
|
||||
"viz_type": "line",
|
||||
"x_axis": "month",
|
||||
"metrics": ["sum__revenue"],
|
||||
"time_range": "Last 12 months"
|
||||
},
|
||||
"query_context": {
|
||||
"datasource": {"id": 5, "type": "table"},
|
||||
"queries": [{"columns": [], "metrics": ["sum__revenue"]}]
|
||||
},
|
||||
"explore_url": "/superset/explore/?form_data=%7B%22slice_id%22%3A10%7D",
|
||||
"owners": ["admin"],
|
||||
"created_on": "2024-01-10T09:15:00Z",
|
||||
"changed_on": "2024-01-15T11:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### generate_chart
|
||||
|
||||
Create a new chart with specified configuration.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"dataset_id": "5",
|
||||
"config": {
|
||||
"chart_type": "xy",
|
||||
"x": {"name": "month", "label": "Month"},
|
||||
"y": [
|
||||
{
|
||||
"name": "revenue",
|
||||
"aggregate": "SUM",
|
||||
"label": "Total Revenue"
|
||||
},
|
||||
{
|
||||
"name": "orders",
|
||||
"aggregate": "COUNT",
|
||||
"label": "Order Count"
|
||||
}
|
||||
],
|
||||
"kind": "line",
|
||||
"x_axis": {
|
||||
"title": "Month",
|
||||
"format": "smart_date"
|
||||
},
|
||||
"y_axis": {
|
||||
"title": "Revenue ($)",
|
||||
"format": "$,.0f"
|
||||
},
|
||||
"legend": {
|
||||
"show": true,
|
||||
"position": "top"
|
||||
}
|
||||
},
|
||||
"slice_name": "Revenue and Orders Trend",
|
||||
"description": "Monthly revenue and order count comparison",
|
||||
"save_chart": true,
|
||||
"generate_preview": true,
|
||||
"preview_formats": ["url", "ascii"]
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"chart_id": 45,
|
||||
"uuid": "new-chart-uuid",
|
||||
"slice_name": "Revenue and Orders Trend",
|
||||
"viz_type": "echarts_timeseries_line",
|
||||
"datasource_id": 5,
|
||||
"explore_url": "/superset/explore/?form_data=%7B%22slice_id%22%3A45%7D",
|
||||
"query_executed": true,
|
||||
"query_result": {
|
||||
"status": "success",
|
||||
"row_count": 12,
|
||||
"execution_time": 0.145
|
||||
},
|
||||
"preview": {
|
||||
"url": {
|
||||
"preview_url": "http://localhost:5008/screenshot/chart/45.png",
|
||||
"width": 800,
|
||||
"height": 600
|
||||
},
|
||||
"ascii": {
|
||||
"ascii_content": "Revenue Trend\n==============\nJan |████████████████ $125K\nFeb |██████████████████ $140K\n...",
|
||||
"width": 80,
|
||||
"height": 20
|
||||
}
|
||||
},
|
||||
"created_on": "2024-01-25T14:20:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### get_chart_data
|
||||
|
||||
Export chart data in multiple formats.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"identifier": 10,
|
||||
"format": "json", // "json", "csv", "excel"
|
||||
"limit": 1000, // Optional: Row limit
|
||||
"offset": 0, // Optional: Row offset
|
||||
"filters": [ // Optional: Additional filters
|
||||
{
|
||||
"column": "region",
|
||||
"op": "=",
|
||||
"value": "US"
|
||||
}
|
||||
],
|
||||
"use_cache": true,
|
||||
"force_refresh": false
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"data": [
|
||||
{
|
||||
"month": "2024-01",
|
||||
"revenue": 125000,
|
||||
"orders": 450
|
||||
},
|
||||
{
|
||||
"month": "2024-02",
|
||||
"revenue": 140000,
|
||||
"orders": 520
|
||||
}
|
||||
],
|
||||
"total_rows": 12,
|
||||
"columns": [
|
||||
{"name": "month", "type": "DATE"},
|
||||
{"name": "revenue", "type": "BIGINT"},
|
||||
{"name": "orders", "type": "BIGINT"}
|
||||
],
|
||||
"query": {
|
||||
"sql": "SELECT month, SUM(revenue) as revenue, COUNT(*) as orders FROM sales_data GROUP BY month ORDER BY month",
|
||||
"execution_time": 0.089
|
||||
},
|
||||
"cache_status": {
|
||||
"cache_hit": false,
|
||||
"cache_type": "query",
|
||||
"refreshed": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### get_chart_preview
|
||||
|
||||
Generate chart previews in multiple formats.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"identifier": 10,
|
||||
"format": "url", // "url", "base64", "ascii", "table"
|
||||
"width": 800, // For image formats
|
||||
"height": 600, // For image formats
|
||||
"ascii_width": 80, // For ASCII format
|
||||
"ascii_height": 20, // For ASCII format
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Examples:**
|
||||
|
||||
**URL Format:**
|
||||
```json
|
||||
{
|
||||
"format": "url",
|
||||
"preview_url": "http://localhost:5008/screenshot/chart/10.png",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"supports_interaction": false,
|
||||
"expires_at": "2024-01-26T14:20:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
**ASCII Format:**
|
||||
```json
|
||||
{
|
||||
"format": "ascii",
|
||||
"ascii_content": "Monthly Revenue Trend\n=====================\n\nJan |████████████████████ $125K\nFeb |██████████████████████ $140K\nMar |███████████████████ $135K\nApr |█████████████████████████ $155K\n\nRange: $125K to $155K\n▁▃▂▅▇▆▄▃▂▄▅▆▇▅▃▂",
|
||||
"width": 80,
|
||||
"height": 20,
|
||||
"supports_color": false
|
||||
}
|
||||
```
|
||||
|
||||
**Table Format:**
|
||||
```json
|
||||
{
|
||||
"format": "table",
|
||||
"table_data": "Monthly Revenue Data\n====================\n\nMonth | Revenue | Orders\n---------|----------|--------\nJan 2024 | $125,000 | 450\nFeb 2024 | $140,000 | 520\nMar 2024 | $135,000 | 495\n\nTotal: 12 rows × 3 columns",
|
||||
"row_count": 12,
|
||||
"supports_sorting": true
|
||||
}
|
||||
```
|
||||
|
||||
## Dataset Tools
|
||||
|
||||
### list_datasets
|
||||
|
||||
List available datasets with columns and metrics.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"search": "sales",
|
||||
"filters": [
|
||||
{
|
||||
"col": "is_active",
|
||||
"opr": "eq",
|
||||
"value": true
|
||||
}
|
||||
],
|
||||
"include_columns": true, // Include column metadata
|
||||
"include_metrics": true, // Include metric metadata
|
||||
"page": 1,
|
||||
"page_size": 15
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"datasets": [
|
||||
{
|
||||
"id": 1,
|
||||
"uuid": "dataset-uuid-1",
|
||||
"table_name": "sales_data",
|
||||
"database_name": "main_warehouse",
|
||||
"schema": "public",
|
||||
"owners": ["admin"],
|
||||
"columns": [
|
||||
{
|
||||
"column_name": "region",
|
||||
"type": "VARCHAR",
|
||||
"is_active": true,
|
||||
"is_dttm": false
|
||||
},
|
||||
{
|
||||
"column_name": "revenue",
|
||||
"type": "DECIMAL",
|
||||
"is_active": true,
|
||||
"is_dttm": false
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"metric_name": "sum__revenue",
|
||||
"expression": "SUM(revenue)",
|
||||
"metric_type": "sum"
|
||||
}
|
||||
],
|
||||
"created_on": "2024-01-05T08:00:00Z"
|
||||
}
|
||||
],
|
||||
"total_count": 23,
|
||||
"page": 1,
|
||||
"page_size": 15
|
||||
}
|
||||
```
|
||||
|
||||
### get_dataset_info
|
||||
|
||||
Get detailed dataset information with full column/metric metadata.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"identifier": "dataset-uuid-1", // ID or UUID
|
||||
"include_columns": true,
|
||||
"include_metrics": true,
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"dataset_id": 1,
|
||||
"uuid": "dataset-uuid-1",
|
||||
"table_name": "sales_data",
|
||||
"database_name": "main_warehouse",
|
||||
"database_id": 1,
|
||||
"schema": "public",
|
||||
"sql": null,
|
||||
"is_active": true,
|
||||
"owners": ["admin", "data_team"],
|
||||
"columns": [
|
||||
{
|
||||
"id": 101,
|
||||
"column_name": "region",
|
||||
"type": "VARCHAR",
|
||||
"is_active": true,
|
||||
"is_dttm": false,
|
||||
"groupby": true,
|
||||
"filterable": true,
|
||||
"description": "Geographic region"
|
||||
},
|
||||
{
|
||||
"id": 102,
|
||||
"column_name": "order_date",
|
||||
"type": "DATE",
|
||||
"is_active": true,
|
||||
"is_dttm": true,
|
||||
"groupby": true,
|
||||
"filterable": true
|
||||
}
|
||||
],
|
||||
"metrics": [
|
||||
{
|
||||
"id": 201,
|
||||
"metric_name": "sum__revenue",
|
||||
"expression": "SUM(revenue)",
|
||||
"metric_type": "sum",
|
||||
"is_active": true,
|
||||
"description": "Total revenue"
|
||||
}
|
||||
],
|
||||
"created_on": "2024-01-05T08:00:00Z",
|
||||
"changed_on": "2024-01-18T12:30:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
## System Tools
|
||||
|
||||
### get_superset_instance_info
|
||||
|
||||
Get Superset instance information and statistics.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"include_statistics": true, // Include usage statistics
|
||||
"include_tools": true, // Include available MCP tools
|
||||
"use_cache": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"version": "4.1.0",
|
||||
"build": "apache-superset-4.1.0",
|
||||
"mcp_service_version": "1.0.0",
|
||||
"authentication": {
|
||||
"enabled": true,
|
||||
"type": "jwt_bearer",
|
||||
"required_scopes": ["dashboard:read", "chart:read"]
|
||||
},
|
||||
"statistics": {
|
||||
"dashboards": {
|
||||
"total": 45,
|
||||
"published": 32
|
||||
},
|
||||
"charts": {
|
||||
"total": 125,
|
||||
"by_viz_type": {
|
||||
"line": 35,
|
||||
"bar": 28,
|
||||
"table": 42,
|
||||
"pie": 20
|
||||
}
|
||||
},
|
||||
"datasets": {
|
||||
"total": 23,
|
||||
"active": 18
|
||||
},
|
||||
"users": {
|
||||
"total": 15,
|
||||
"active": 12
|
||||
}
|
||||
},
|
||||
"mcp_tools": [
|
||||
{
|
||||
"name": "list_dashboards",
|
||||
"description": "List dashboards with search and filtering",
|
||||
"category": "dashboard"
|
||||
},
|
||||
{
|
||||
"name": "generate_chart",
|
||||
"description": "Create new charts programmatically",
|
||||
"category": "chart"
|
||||
}
|
||||
],
|
||||
"database_connections": [
|
||||
{
|
||||
"id": 1,
|
||||
"database_name": "main_warehouse",
|
||||
"backend": "postgresql",
|
||||
"status": "healthy"
|
||||
}
|
||||
],
|
||||
"cache_status": {
|
||||
"enabled": true,
|
||||
"backend": "redis",
|
||||
"hit_rate": 0.85
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### generate_explore_link
|
||||
|
||||
Generate Superset explore URLs with pre-configured chart settings.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"dataset_id": "1",
|
||||
"chart_config": {
|
||||
"viz_type": "line",
|
||||
"x_axis": "month",
|
||||
"metrics": ["sum__revenue"],
|
||||
"time_range": "Last 6 months"
|
||||
},
|
||||
"title": "Revenue Analysis",
|
||||
"cache_form_data": true
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"explore_url": "/superset/explore/?form_data_key=abc123def456",
|
||||
"full_url": "http://localhost:8088/superset/explore/?form_data_key=abc123def456",
|
||||
"form_data_key": "abc123def456",
|
||||
"expires_at": "2024-01-26T16:45:00Z",
|
||||
"chart_config": {
|
||||
"viz_type": "line",
|
||||
"datasource": "1__table",
|
||||
"x_axis": "month",
|
||||
"metrics": ["sum__revenue"],
|
||||
"time_range": "Last 6 months"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## SQL Lab Tools
|
||||
|
||||
### open_sql_lab_with_context
|
||||
|
||||
Open SQL Lab with pre-configured database, schema, and SQL.
|
||||
|
||||
**Request Schema:**
|
||||
```json
|
||||
{
|
||||
"database_connection_id": 1,
|
||||
"schema": "public",
|
||||
"dataset_in_context": "sales_data",
|
||||
"sql": "SELECT region, SUM(revenue) as total_revenue\nFROM sales_data \nWHERE order_date >= '2024-01-01'\nGROUP BY region\nORDER BY total_revenue DESC",
|
||||
"title": "Regional Sales Analysis"
|
||||
}
|
||||
```
|
||||
|
||||
**Response Example:**
|
||||
```json
|
||||
{
|
||||
"sql_lab_url": "/superset/sqllab/?dbid=1&schema=public&sql_template=encoded_sql_here",
|
||||
"full_url": "http://localhost:8088/superset/sqllab/?dbid=1&schema=public&sql_template=encoded_sql_here",
|
||||
"database_connection": {
|
||||
"id": 1,
|
||||
"database_name": "main_warehouse",
|
||||
"backend": "postgresql"
|
||||
},
|
||||
"schema": "public",
|
||||
"sql_template": "SELECT region, SUM(revenue) as total_revenue...",
|
||||
"context": {
|
||||
"dataset": "sales_data",
|
||||
"title": "Regional Sales Analysis"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Error Responses
|
||||
|
||||
All tools can return error responses with this structure:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Chart not found with identifier: 999",
|
||||
"error_type": "NotFound",
|
||||
"suggestions": [
|
||||
"Verify the chart ID exists",
|
||||
"Check if you have permission to access this chart",
|
||||
"Try using the chart UUID instead of ID"
|
||||
],
|
||||
"details": {
|
||||
"identifier": 999,
|
||||
"identifier_type": "id"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Cache Status
|
||||
|
||||
Many responses include cache status information:
|
||||
|
||||
```json
|
||||
{
|
||||
"cache_status": {
|
||||
"cache_hit": true, // Data served from cache
|
||||
"cache_type": "query", // Type: query, metadata, form_data
|
||||
"cache_age_seconds": 300, // Age of cached data
|
||||
"refreshed": false // Whether cache was refreshed
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This API reference provides complete documentation for integrating with the Superset MCP service, including all request schemas, response formats, and error handling patterns.
|
||||
|
||||
## What's Next?
|
||||
|
||||
### 🔐 **Ready for Production?**
|
||||
Set up authentication and security with the [Authentication Guide](./authentication).
|
||||
|
||||
### 🔧 **Want to Add More Tools?**
|
||||
Learn how to extend the MCP service in the [Development Guide](./development).
|
||||
|
||||
### 🏗️ **Need Architecture Details?**
|
||||
Understand the system design in the [Architecture Overview](./architecture).
|
||||
|
||||
### 🏢 **Enterprise Features?**
|
||||
Explore advanced capabilities in the [Preset Integration Guide](./preset-integration).
|
||||
|
||||
> 📖 **Back to Documentation Index**: [MCP Service](./intro)
|
||||
191
docs/docs/mcp-service/architecture.mdx
Normal file
191
docs/docs/mcp-service/architecture.mdx
Normal file
@@ -0,0 +1,191 @@
|
||||
---
|
||||
title: Architecture Overview
|
||||
sidebar_position: 5
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Architecture Overview
|
||||
|
||||
The Superset Model Context Protocol (MCP) service provides a modular, schema-driven interface for programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built on FastMCP for LLM agents and automation tools.
|
||||
|
||||
**Status:** Phase 1 Complete. Core functionality stable, authentication production-ready. See [SIP-171](https://github.com/apache/superset/issues/33870) for roadmap.
|
||||
|
||||
## Core Architecture
|
||||
|
||||
### Tool Structure
|
||||
- **16 MCP tools** organized by domain: `dashboard/`, `chart/`, `dataset/`, `system/`
|
||||
- All tools decorated with `@mcp.tool` and `@mcp_auth_hook`
|
||||
- **Import inside functions**: All Superset DAOs/commands imported in function body to ensure proper app context
|
||||
- Pydantic v2 schemas with LLM/OpenAPI-compatible field descriptions
|
||||
|
||||
### Request Schema Pattern
|
||||
Eliminates LLM parameter validation issues using structured request objects:
|
||||
```python
|
||||
# New approach - single request object
|
||||
get_dataset_info(request={"identifier": 123}) # ID
|
||||
get_dataset_info(request={"identifier": "uuid-string"}) # UUID
|
||||
|
||||
# Old approach - replaced
|
||||
get_dataset_info(dataset_id=123)
|
||||
```
|
||||
|
||||
### Multi-Identifier Support
|
||||
- **Charts/Datasets**: ID (numeric) or UUID (string)
|
||||
- **Dashboards**: ID (numeric), UUID (string), or slug (string)
|
||||
- Validation prevents conflicting parameters (search + filters)
|
||||
|
||||
## Available Tools
|
||||
|
||||
### Dashboard Tools (5)
|
||||
- `list_dashboards` - List with search/filters/pagination
|
||||
- `get_dashboard_info` - Get by ID/UUID/slug
|
||||
- `get_dashboard_available_filters` - Discover filterable columns
|
||||
- `generate_dashboard` - Create dashboards with multiple charts
|
||||
- `add_chart_to_existing_dashboard` - Add charts to existing dashboards
|
||||
|
||||
### Chart Tools (8)
|
||||
- `list_charts` - List with search/filters/pagination
|
||||
- `get_chart_info` - Get by ID/UUID
|
||||
- `get_chart_available_filters` - Discover filterable columns
|
||||
- `generate_chart` - Create charts (table, line, bar, area, scatter)
|
||||
- `update_chart` - Update saved charts
|
||||
- `update_chart_preview` - Update cached previews
|
||||
- `get_chart_data` - Export data (JSON/CSV/Excel)
|
||||
- `get_chart_preview` - Screenshots, ASCII art, table previews
|
||||
|
||||
### Dataset Tools (3)
|
||||
- `list_datasets` - List with columns/metrics
|
||||
- `get_dataset_info` - Get by ID/UUID with metadata
|
||||
- `get_dataset_available_filters` - Discover filterable columns
|
||||
|
||||
### System Tools (2)
|
||||
- `get_superset_instance_info` - Instance statistics and version
|
||||
- `generate_explore_link` - Generate chart exploration URLs
|
||||
|
||||
### SQL Lab Tools (1)
|
||||
- `open_sql_lab_with_context` - Pre-configured SQL Lab sessions
|
||||
|
||||
## Authentication & Security
|
||||
|
||||
### JWT Bearer Authentication
|
||||
Production-ready authentication with configurable factory pattern:
|
||||
```python
|
||||
# In superset_config.py
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
|
||||
MCP_JWT_ISSUER = "https://auth.company.com/"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp-api"
|
||||
```
|
||||
|
||||
### Scope-Based Authorization
|
||||
| Tool Category | Required Scope |
|
||||
|---------------|----------------|
|
||||
| Dashboard ops | `dashboard:read` |
|
||||
| Chart ops | `chart:read` / `chart:write` |
|
||||
| Dataset ops | `dataset:read` |
|
||||
| System ops | `instance:read` |
|
||||
|
||||
### Audit Logging
|
||||
All operations logged with MCP context:
|
||||
- User impersonation tracking
|
||||
- Tool execution details
|
||||
- Sanitized payloads (sensitive data redacted)
|
||||
|
||||
## Cache Control
|
||||
|
||||
Leverages Superset's existing cache layers with comprehensive control:
|
||||
|
||||
### Cache Types
|
||||
1. **Query Result Cache** - Database query results
|
||||
2. **Metadata Cache** - Table schemas, columns, metrics
|
||||
3. **Form Data Cache** - Chart configurations
|
||||
4. **Dashboard Cache** - Rendered components
|
||||
|
||||
### Cache Parameters
|
||||
Tools support cache control through request schemas:
|
||||
- `use_cache`: Enable/disable caching (default: true)
|
||||
- `force_refresh`: Force cache refresh (default: false)
|
||||
- `cache_timeout`: Override timeout in seconds
|
||||
- `refresh_metadata`: Force metadata refresh
|
||||
|
||||
### Cache Status Reporting
|
||||
```json
|
||||
{
|
||||
"cache_status": {
|
||||
"cache_hit": true,
|
||||
"cache_type": "query",
|
||||
"cache_age_seconds": 300,
|
||||
"refreshed": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Abstractions
|
||||
|
||||
### Generic Base Classes
|
||||
- **ModelListTool**: Handles list/search/filter operations with pagination
|
||||
- **ModelGetInfoTool**: Single object retrieval by multiple identifier types
|
||||
- **ModelGetAvailableFiltersTool**: Returns filterable columns/operators
|
||||
|
||||
### Implementation Pattern
|
||||
```python
|
||||
@mcp.tool
|
||||
@mcp_auth_hook
|
||||
def my_tool(request: MyRequest) -> MyResponse:
|
||||
# Import Superset modules inside function
|
||||
from superset.daos.dashboard import DashboardDAO
|
||||
from superset.commands.chart.create import CreateChartCommand
|
||||
|
||||
# Tool implementation
|
||||
return response
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### URL Configuration
|
||||
Centralized URL management for consistent link generation:
|
||||
```python
|
||||
# In superset_config.py
|
||||
SUPERSET_WEBSERVER_ADDRESS = "http://localhost:8088" # Development
|
||||
SUPERSET_WEBSERVER_ADDRESS = "https://superset.company.com" # Production
|
||||
```
|
||||
|
||||
### Schema Design Principles
|
||||
- **Minimal columns** in list responses
|
||||
- **Optional fields** in info schemas for missing data handling
|
||||
- **Null exclusion** for cleaner JSON responses
|
||||
- **Type safety** with clear Pydantic validation
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
1. **Choose domain folder**: `dashboard/`, `chart/`, `dataset/`, or `system/`
|
||||
2. **Define schemas**: Use Pydantic with field descriptions
|
||||
3. **Implement tool**:
|
||||
- Decorate with `@mcp.tool` and `@mcp_auth_hook`
|
||||
- Import Superset modules inside function body
|
||||
- Use generic abstractions where applicable
|
||||
4. **Register**: Add to appropriate `__init__.py`
|
||||
5. **Test**: Add unit tests in `tests/unit_tests/mcp_service/`
|
||||
|
||||
## Current Status
|
||||
|
||||
### ✅ Phase 1 Complete
|
||||
- FastMCP server with CLI
|
||||
- JWT authentication with RBAC
|
||||
- All 16 core tools implemented
|
||||
- Request schema pattern
|
||||
- Cache control system
|
||||
- Audit logging
|
||||
- 194+ unit tests
|
||||
|
||||
### 🎯 Future Enhancements
|
||||
- Demo notebooks and video examples
|
||||
- OAuth integration for user impersonation
|
||||
- Enhanced chart rendering formats
|
||||
- Advanced security features
|
||||
|
||||
**Production Ready**: Core functionality stable with comprehensive testing and authentication.
|
||||
|
||||
---
|
||||
|
||||
For setup and usage, see the [MCP Service overview](./intro).
|
||||
434
docs/docs/mcp-service/authentication.mdx
Normal file
434
docs/docs/mcp-service/authentication.mdx
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
title: Authentication & Security
|
||||
sidebar_position: 4
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Authentication & Security
|
||||
|
||||
The MCP service provides enterprise-grade JWT Bearer authentication with flexible configuration options and comprehensive security controls.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Development Mode (Default)
|
||||
|
||||
:::tip
|
||||
Authentication is **disabled by default** for local development - no configuration needed.
|
||||
:::
|
||||
|
||||
```bash
|
||||
# No configuration needed - service runs without authentication
|
||||
superset mcp run --port 5008 --debug
|
||||
```
|
||||
|
||||
### Production Mode
|
||||
|
||||
:::warning
|
||||
Always enable authentication for production deployments to secure your Superset instance.
|
||||
:::
|
||||
|
||||
Enable JWT authentication in your Superset configuration:
|
||||
|
||||
```python
|
||||
# In superset_config.py
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
|
||||
MCP_JWT_ISSUER = "https://auth.company.com/"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp-api"
|
||||
MCP_REQUIRED_SCOPES = ["dashboard:read", "chart:read", "dataset:read"]
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Option 1: Simple Configuration
|
||||
|
||||
Add to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Enable authentication
|
||||
MCP_AUTH_ENABLED = True
|
||||
|
||||
# JWT settings
|
||||
MCP_JWKS_URI = "https://auth.company.com/.well-known/jwks.json"
|
||||
MCP_JWT_ISSUER = "https://auth.company.com/"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp-api"
|
||||
MCP_REQUIRED_SCOPES = ["dashboard:read", "chart:read"]
|
||||
|
||||
# Optional: User resolution
|
||||
MCP_JWT_USER_CLAIM = "sub" # JWT claim for username (default: "sub")
|
||||
MCP_JWT_EMAIL_CLAIM = "email" # JWT claim for email (default: "email")
|
||||
MCP_FALLBACK_USER = "admin" # Fallback user if JWT user not found
|
||||
```
|
||||
|
||||
### Option 2: Custom Factory
|
||||
|
||||
For advanced authentication requirements:
|
||||
|
||||
```python
|
||||
def create_custom_mcp_auth(app):
|
||||
"""Custom auth factory for enterprise environments."""
|
||||
from fastmcp.server.auth.providers.bearer import BearerAuthProvider
|
||||
|
||||
return BearerAuthProvider(
|
||||
jwks_uri=app.config["MCP_JWKS_URI"],
|
||||
issuer=app.config["MCP_JWT_ISSUER"],
|
||||
audience=app.config["MCP_JWT_AUDIENCE"],
|
||||
required_scopes=app.config.get("MCP_REQUIRED_SCOPES", []),
|
||||
user_resolver=custom_user_resolver,
|
||||
cache_ttl=300 # Cache JWKS for 5 minutes
|
||||
)
|
||||
|
||||
MCP_AUTH_FACTORY = create_custom_mcp_auth
|
||||
```
|
||||
|
||||
### Option 3: Environment Variables
|
||||
|
||||
For containerized deployments:
|
||||
|
||||
```bash
|
||||
# Environment variables
|
||||
export MCP_AUTH_ENABLED=true
|
||||
export MCP_JWKS_URI=https://auth.company.com/.well-known/jwks.json
|
||||
export MCP_JWT_ISSUER=https://auth.company.com/
|
||||
export MCP_JWT_AUDIENCE=superset-mcp-api
|
||||
export MCP_REQUIRED_SCOPES=dashboard:read,chart:read,dataset:read
|
||||
```
|
||||
|
||||
## Identity Provider Integration
|
||||
|
||||
### Auth0
|
||||
|
||||
```python
|
||||
# Auth0 configuration
|
||||
MCP_JWKS_URI = "https://your-tenant.auth0.com/.well-known/jwks.json"
|
||||
MCP_JWT_ISSUER = "https://your-tenant.auth0.com/"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp-api"
|
||||
```
|
||||
|
||||
### Okta
|
||||
|
||||
```python
|
||||
# Okta configuration
|
||||
MCP_JWKS_URI = "https://your-org.okta.com/oauth2/default/v1/keys"
|
||||
MCP_JWT_ISSUER = "https://your-org.okta.com/oauth2/default"
|
||||
MCP_JWT_AUDIENCE = "api://superset-mcp"
|
||||
```
|
||||
|
||||
### AWS Cognito
|
||||
|
||||
```python
|
||||
# Cognito configuration
|
||||
MCP_JWKS_URI = "https://cognito-idp.{region}.amazonaws.com/{userPoolId}/.well-known/jwks.json"
|
||||
MCP_JWT_ISSUER = "https://cognito-idp.{region}.amazonaws.com/{userPoolId}"
|
||||
MCP_JWT_AUDIENCE = "your-app-client-id"
|
||||
```
|
||||
|
||||
### Azure AD
|
||||
|
||||
```python
|
||||
# Azure AD configuration
|
||||
MCP_JWKS_URI = "https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys"
|
||||
MCP_JWT_ISSUER = "https://login.microsoftonline.com/{tenant}/v2.0"
|
||||
MCP_JWT_AUDIENCE = "api://superset-mcp"
|
||||
```
|
||||
|
||||
## Scope-Based Authorization
|
||||
|
||||
### Standard Scopes
|
||||
|
||||
The MCP service defines these standard scopes:
|
||||
|
||||
| Scope | Description | Required For |
|
||||
|-------|-------------|--------------|
|
||||
| `dashboard:read` | Read dashboard information | `list_dashboards`, `get_dashboard_info` |
|
||||
| `dashboard:write` | Create/modify dashboards | `generate_dashboard`, `add_chart_to_existing_dashboard` |
|
||||
| `chart:read` | Read chart information | `list_charts`, `get_chart_info`, `get_chart_data` |
|
||||
| `chart:write` | Create/modify charts | `generate_chart`, `update_chart` |
|
||||
| `dataset:read` | Read dataset information | `list_datasets`, `get_dataset_info` |
|
||||
| `instance:read` | Read instance information | `get_superset_instance_info` |
|
||||
|
||||
### Custom Scopes
|
||||
|
||||
Define custom scopes for specific use cases:
|
||||
|
||||
```python
|
||||
# Custom scope definitions
|
||||
CUSTOM_MCP_SCOPES = {
|
||||
"analytics:export": "Export analytical data",
|
||||
"reports:generate": "Generate automated reports",
|
||||
"admin:config": "Access administrative configuration"
|
||||
}
|
||||
|
||||
# Map tools to custom scopes
|
||||
def get_custom_required_scopes(tool_name: str) -> List[str]:
|
||||
scope_map = {
|
||||
"get_chart_data": ["chart:read", "analytics:export"],
|
||||
"generate_dashboard": ["dashboard:write", "reports:generate"],
|
||||
"get_superset_instance_info": ["instance:read", "admin:config"]
|
||||
}
|
||||
return scope_map.get(tool_name, [])
|
||||
|
||||
MCP_SCOPE_RESOLVER = get_custom_required_scopes
|
||||
```
|
||||
|
||||
## JWT Token Format
|
||||
|
||||
### Required Claims
|
||||
|
||||
Your JWT tokens must include these standard claims:
|
||||
|
||||
```json
|
||||
{
|
||||
"iss": "https://auth.company.com/", // Issuer
|
||||
"aud": "superset-mcp-api", // Audience
|
||||
"sub": "user@company.com", // Subject (username)
|
||||
"exp": 1704118800, // Expiration timestamp
|
||||
"iat": 1704115200, // Issued at timestamp
|
||||
"scope": "dashboard:read chart:read" // Space-separated scopes
|
||||
}
|
||||
```
|
||||
|
||||
### Optional Claims
|
||||
|
||||
Additional claims for enhanced functionality:
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@company.com", // User email
|
||||
"name": "John Doe", // Full name
|
||||
"groups": ["analysts", "sales_team"], // User groups
|
||||
"tenant_id": "company_123", // Multi-tenant ID
|
||||
"role": "analyst" // User role
|
||||
}
|
||||
```
|
||||
|
||||
## Client Integration
|
||||
|
||||
### API Client Usage
|
||||
|
||||
```python
|
||||
import requests
|
||||
|
||||
# Get JWT token from your identity provider
|
||||
token = get_jwt_token()
|
||||
|
||||
# Call MCP service with Bearer authentication
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Content-Type": "application/json"
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
"http://localhost:5008/call_tool",
|
||||
headers=headers,
|
||||
json={
|
||||
"tool": "list_dashboards",
|
||||
"arguments": {"search": "sales"}
|
||||
}
|
||||
)
|
||||
|
||||
data = response.json()
|
||||
```
|
||||
|
||||
### Claude Desktop with Authentication
|
||||
|
||||
For Claude Desktop, the proxy script handles authentication:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
# run_proxy_with_auth.sh
|
||||
|
||||
# Get token from environment or file
|
||||
if [ -f ~/.superset_mcp_token ]; then
|
||||
TOKEN=$(cat ~/.superset_mcp_token)
|
||||
else
|
||||
TOKEN=${SUPERSET_MCP_TOKEN}
|
||||
fi
|
||||
|
||||
# Export token for proxy
|
||||
export MCP_AUTH_TOKEN="$TOKEN"
|
||||
|
||||
cd /path/to/superset
|
||||
source venv/bin/activate
|
||||
exec fastmcp proxy http://localhost:5008 --auth-header "Authorization: Bearer $TOKEN"
|
||||
```
|
||||
|
||||
## User Resolution
|
||||
|
||||
### Default User Resolution
|
||||
|
||||
The service maps JWT claims to Superset users:
|
||||
|
||||
```python
|
||||
def default_user_resolver(claims: Dict[str, Any]) -> User:
|
||||
"""Default user resolution from JWT claims."""
|
||||
|
||||
# Extract username from configurable claim
|
||||
username = claims.get(app.config.get("MCP_JWT_USER_CLAIM", "sub"))
|
||||
|
||||
# Find Superset user
|
||||
user = security_manager.find_user(username=username)
|
||||
|
||||
if not user:
|
||||
# Try email lookup
|
||||
email = claims.get(app.config.get("MCP_JWT_EMAIL_CLAIM", "email"))
|
||||
if email:
|
||||
user = security_manager.find_user(email=email)
|
||||
|
||||
if not user and app.config.get("MCP_FALLBACK_USER"):
|
||||
# Use fallback user for development
|
||||
user = security_manager.find_user(username=app.config["MCP_FALLBACK_USER"])
|
||||
|
||||
return user
|
||||
```
|
||||
|
||||
### Custom User Resolution
|
||||
|
||||
Implement custom user resolution logic:
|
||||
|
||||
```python
|
||||
def custom_user_resolver(claims: Dict[str, Any]) -> User:
|
||||
"""Custom user resolution for enterprise environments."""
|
||||
|
||||
# Extract custom claims
|
||||
employee_id = claims.get("employee_id")
|
||||
tenant_id = claims.get("tenant_id")
|
||||
|
||||
# Multi-tenant user lookup
|
||||
user = find_user_by_employee_id(employee_id, tenant_id)
|
||||
|
||||
if user:
|
||||
# Set additional context
|
||||
user.mcp_tenant_id = tenant_id
|
||||
user.mcp_groups = claims.get("groups", [])
|
||||
|
||||
return user
|
||||
|
||||
# Use custom resolver
|
||||
MCP_USER_RESOLVER = custom_user_resolver
|
||||
```
|
||||
|
||||
## Security Features
|
||||
|
||||
### Token Validation
|
||||
|
||||
Comprehensive JWT validation:
|
||||
|
||||
- **Signature verification**: RS256 with JWKS key rotation support
|
||||
- **Expiration checking**: Automatic token expiry validation
|
||||
- **Audience validation**: Prevents token reuse across services
|
||||
- **Issuer validation**: Ensures tokens from trusted sources only
|
||||
- **Scope validation**: Enforces tool-level permissions
|
||||
|
||||
### Request Security
|
||||
|
||||
- **HTTPS enforcement**: Production deployments should use HTTPS
|
||||
- **Rate limiting**: Configurable per-user rate limits
|
||||
- **Request logging**: All authenticated requests logged with user context
|
||||
- **Input validation**: Comprehensive request schema validation
|
||||
|
||||
### Audit Logging
|
||||
|
||||
Every tool call is logged with security context:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2024-01-25T14:30:00Z",
|
||||
"user_id": "user@company.com",
|
||||
"tool_name": "get_chart_data",
|
||||
"source": "mcp",
|
||||
"jwt_subject": "user@company.com",
|
||||
"jwt_scopes": ["chart:read", "analytics:export"],
|
||||
"tenant_id": "company_123",
|
||||
"request_id": "req_12345",
|
||||
"execution_time": 0.145,
|
||||
"status": "success"
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Authentication
|
||||
|
||||
### Generate Test Tokens
|
||||
|
||||
For development and testing:
|
||||
|
||||
```python
|
||||
from fastmcp.server.auth.providers.bearer import RSAKeyPair
|
||||
|
||||
# Generate test keypair
|
||||
keypair = RSAKeyPair.generate()
|
||||
print("Public key:", keypair.public_key)
|
||||
|
||||
# Create test token
|
||||
token = keypair.create_token(
|
||||
subject="test@example.com",
|
||||
issuer="https://test.example.com",
|
||||
audience="superset-mcp-api",
|
||||
scopes=["dashboard:read", "chart:read", "dataset:read"],
|
||||
expires_in=3600 # 1 hour
|
||||
)
|
||||
print("Test token:", token)
|
||||
```
|
||||
|
||||
### Test Configuration
|
||||
|
||||
```python
|
||||
# Test configuration with generated keypair
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_JWT_PUBLIC_KEY = """-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA...
|
||||
-----END PUBLIC KEY-----"""
|
||||
MCP_JWT_ISSUER = "https://test.example.com"
|
||||
MCP_JWT_AUDIENCE = "superset-mcp-api"
|
||||
MCP_FALLBACK_USER = "admin"
|
||||
```
|
||||
|
||||
### Manual Testing
|
||||
|
||||
```bash
|
||||
# Test with curl
|
||||
curl -X POST http://localhost:5008/call_tool \
|
||||
-H "Authorization: Bearer $TEST_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"tool": "get_superset_instance_info",
|
||||
"arguments": {"include_statistics": true}
|
||||
}'
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Token Validation Errors:**
|
||||
```
|
||||
Error: Invalid JWT signature
|
||||
Solution: Verify JWKS_URI is accessible and contains correct keys
|
||||
```
|
||||
|
||||
**User Not Found:**
|
||||
```
|
||||
Error: User not found for JWT subject
|
||||
Solution: Check MCP_JWT_USER_CLAIM configuration and user exists in Superset
|
||||
```
|
||||
|
||||
**Insufficient Scopes:**
|
||||
```
|
||||
Error: Missing required scope 'chart:read'
|
||||
Solution: Update JWT token to include required scopes
|
||||
```
|
||||
|
||||
### Debug Configuration
|
||||
|
||||
Enable debug logging for authentication issues:
|
||||
|
||||
```python
|
||||
# Enhanced logging for auth debugging
|
||||
import logging
|
||||
logging.getLogger('superset.mcp_service.auth').setLevel(logging.DEBUG)
|
||||
|
||||
# Log all JWT validation steps
|
||||
MCP_AUTH_DEBUG = True
|
||||
```
|
||||
|
||||
This authentication guide provides comprehensive coverage for securing the MCP service in production environments while maintaining development flexibility.
|
||||
705
docs/docs/mcp-service/development.mdx
Normal file
705
docs/docs/mcp-service/development.mdx
Normal file
@@ -0,0 +1,705 @@
|
||||
---
|
||||
title: Development Guide
|
||||
sidebar_position: 2
|
||||
version: 1
|
||||
---
|
||||
|
||||
# MCP Service Development Guide
|
||||
|
||||
This guide covers the internal architecture, development workflows, and patterns for extending the Superset MCP service.
|
||||
|
||||
> 🚀 **New to MCP?** Start with the [Overview](./overview) to understand what the service does before diving into development.
|
||||
>
|
||||
> 📚 **Need API examples?** Check the [API Reference](./api-reference) to see how existing tools work.
|
||||
>
|
||||
> 🔐 **Planning production use?** Review [Authentication](./authentication) for security considerations.
|
||||
|
||||
## Internal Architecture
|
||||
|
||||
### Component Overview
|
||||
|
||||
The MCP service follows a layered architecture with clear separation of concerns:
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Transport Layer"
|
||||
HTTP[HTTP Server :5008]
|
||||
FastMCP[FastMCP Protocol Handler]
|
||||
end
|
||||
|
||||
subgraph "Auth & Middleware Layer"
|
||||
AuthHook[Auth Hook Decorator]
|
||||
JWT[JWT Validator]
|
||||
RBAC[RBAC Engine]
|
||||
Audit[Audit Logger]
|
||||
end
|
||||
|
||||
subgraph "Tool Layer"
|
||||
Tools[16 MCP Tools<br/>Tool Decorated]
|
||||
Schemas[Pydantic Schemas]
|
||||
Validation[Request Validation]
|
||||
end
|
||||
|
||||
subgraph "Business Logic Layer"
|
||||
Generic[Generic Tool Abstractions]
|
||||
ModelList[ModelListTool]
|
||||
ModelGet[ModelGetInfoTool]
|
||||
ModelFilter[ModelGetAvailableFiltersTool]
|
||||
end
|
||||
|
||||
subgraph "Data Access Layer"
|
||||
DAOs[Superset DAOs]
|
||||
Commands[Superset Commands]
|
||||
Cache[Cache Manager]
|
||||
end
|
||||
|
||||
subgraph "Storage Layer"
|
||||
MetaDB[(Metadata DB)]
|
||||
DataWH[(Data Warehouse)]
|
||||
Redis[(Redis Cache)]
|
||||
end
|
||||
|
||||
HTTP --> FastMCP
|
||||
FastMCP --> AuthHook
|
||||
AuthHook --> JWT
|
||||
JWT --> RBAC
|
||||
RBAC --> Audit
|
||||
Audit --> Tools
|
||||
|
||||
Tools --> Schemas
|
||||
Schemas --> Validation
|
||||
Validation --> Generic
|
||||
|
||||
Generic --> ModelList
|
||||
Generic --> ModelGet
|
||||
Generic --> ModelFilter
|
||||
|
||||
ModelList --> DAOs
|
||||
ModelGet --> DAOs
|
||||
ModelFilter --> DAOs
|
||||
|
||||
Tools --> Commands
|
||||
Commands --> Cache
|
||||
|
||||
DAOs --> MetaDB
|
||||
Commands --> MetaDB
|
||||
Commands --> DataWH
|
||||
Cache --> Redis
|
||||
```
|
||||
|
||||
### Request Flow
|
||||
|
||||
Every MCP tool call follows this execution pattern:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Client as LLM Client
|
||||
participant MCP as FastMCP Server
|
||||
participant Auth as Auth Hook
|
||||
participant Tool as MCP Tool
|
||||
participant Generic as Generic Abstraction
|
||||
participant DAO as Superset DAO
|
||||
participant DB as Database
|
||||
|
||||
Client->>+MCP: tool_call(request)
|
||||
MCP->>+Auth: validate_and_authorize()
|
||||
Auth->>Auth: Validate JWT token
|
||||
Auth->>Auth: Check required scopes
|
||||
Auth->>Auth: Set Flask g.user context
|
||||
Auth->>Auth: Log audit event
|
||||
Auth->>+Tool: execute_tool(validated_request)
|
||||
|
||||
Tool->>Tool: Parse Pydantic request schema
|
||||
Tool->>+Generic: Use generic abstraction
|
||||
Generic->>+DAO: Query Superset data
|
||||
DAO->>+DB: Execute SQL
|
||||
DB-->>-DAO: Return results
|
||||
DAO-->>-Generic: Return objects
|
||||
Generic->>Generic: Apply pagination/filtering
|
||||
Generic-->>-Tool: Return formatted data
|
||||
|
||||
Tool->>Tool: Build Pydantic response schema
|
||||
Tool-->>-Auth: Return response
|
||||
Auth->>Auth: Log success audit event
|
||||
Auth-->>-MCP: Return validated response
|
||||
MCP-->>-Client: JSON response
|
||||
```
|
||||
|
||||
### Tool Registration System
|
||||
|
||||
Tools are automatically discovered and registered through the decorator pattern:
|
||||
|
||||
```python
|
||||
# superset/mcp_service/mcp_app.py
|
||||
from fastmcp import FastMCP
|
||||
|
||||
# Global MCP instance
|
||||
mcp = FastMCP("Superset MCP Service")
|
||||
|
||||
# Tools register themselves via decorators
|
||||
@mcp.tool
|
||||
@mcp_auth_hook(['chart:read'])
|
||||
def get_chart_info(request: GetChartInfoRequest) -> GetChartInfoResponse:
|
||||
# Tool implementation
|
||||
pass
|
||||
|
||||
# All tool modules imported to trigger registration
|
||||
from superset.mcp_service.chart.tool import *
|
||||
from superset.mcp_service.dashboard.tool import *
|
||||
from superset.mcp_service.dataset.tool import *
|
||||
from superset.mcp_service.system.tool import *
|
||||
```
|
||||
|
||||
## Development Patterns
|
||||
|
||||
### Tool Implementation Pattern
|
||||
|
||||
All tools follow this standardized pattern:
|
||||
|
||||
```python
|
||||
# Example: superset/mcp_service/chart/tool/get_chart_info.py
|
||||
from superset.mcp_service.auth import mcp_auth_hook
|
||||
from superset.mcp_service.mcp_app import mcp
|
||||
from superset.mcp_service.schemas.chart_schemas import (
|
||||
GetChartInfoRequest,
|
||||
GetChartInfoResponse,
|
||||
ChartError
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
@mcp_auth_hook(['chart:read'])
|
||||
def get_chart_info(request: GetChartInfoRequest) -> GetChartInfoResponse:
|
||||
"""
|
||||
Get detailed information about a specific chart.
|
||||
|
||||
Supports lookup by ID or UUID with comprehensive metadata.
|
||||
"""
|
||||
try:
|
||||
# CRITICAL: Import Superset modules inside function
|
||||
from superset.daos.chart import ChartDAO
|
||||
from superset.models.slice import Slice
|
||||
|
||||
# Use generic abstraction for common operations
|
||||
from superset.mcp_service.generic_tools import ModelGetInfoTool
|
||||
|
||||
tool = ModelGetInfoTool(
|
||||
dao=ChartDAO,
|
||||
model=Slice,
|
||||
response_schema=GetChartInfoResponse,
|
||||
identifier_field_map={
|
||||
'id': 'id',
|
||||
'uuid': 'uuid'
|
||||
}
|
||||
)
|
||||
|
||||
return tool.execute(request)
|
||||
|
||||
except Exception as e:
|
||||
return ChartError(
|
||||
error=f"Failed to get chart info: {str(e)}",
|
||||
error_type="ChartInfoError"
|
||||
)
|
||||
```
|
||||
|
||||
### Schema Design Patterns
|
||||
|
||||
Pydantic schemas follow these conventions:
|
||||
|
||||
```python
|
||||
# Request Schema Pattern
|
||||
class GetChartInfoRequest(BaseModel):
|
||||
"""Request to get detailed chart information."""
|
||||
|
||||
identifier: Union[int, str] = Field(
|
||||
...,
|
||||
description="Chart ID (numeric) or UUID (string)"
|
||||
)
|
||||
|
||||
include_form_data: bool = Field(
|
||||
default=True,
|
||||
description="Whether to include chart configuration"
|
||||
)
|
||||
|
||||
use_cache: bool = Field(
|
||||
default=True,
|
||||
description="Whether to use cached data"
|
||||
)
|
||||
|
||||
# Response Schema Pattern
|
||||
class GetChartInfoResponse(BaseModel):
|
||||
"""Detailed chart information response."""
|
||||
|
||||
chart_id: int = Field(description="Chart numeric ID")
|
||||
uuid: Optional[str] = Field(description="Chart UUID")
|
||||
slice_name: str = Field(description="Chart display name")
|
||||
viz_type: str = Field(description="Visualization type")
|
||||
datasource_id: Optional[int] = Field(description="Dataset ID")
|
||||
form_data: Optional[Dict[str, Any]] = Field(description="Chart configuration")
|
||||
explore_url: Optional[str] = Field(description="Explore URL for editing")
|
||||
|
||||
# Cache status for transparency
|
||||
cache_status: Optional[CacheStatus] = Field(description="Cache hit information")
|
||||
|
||||
# Error Schema Pattern
|
||||
class ChartError(BaseModel):
|
||||
"""Chart operation error response."""
|
||||
|
||||
error: str = Field(description="Error message")
|
||||
error_type: str = Field(description="Error type identifier")
|
||||
suggestions: Optional[List[str]] = Field(description="Suggested fixes")
|
||||
```
|
||||
|
||||
### Generic Tool Abstractions
|
||||
|
||||
Common operations are abstracted into reusable classes:
|
||||
|
||||
```python
|
||||
# superset/mcp_service/generic_tools.py
|
||||
from typing import Type, Dict, Any, List, Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
class ModelListTool:
|
||||
"""Generic tool for list operations with pagination and filtering."""
|
||||
|
||||
def __init__(self,
|
||||
dao: Type,
|
||||
model: Type,
|
||||
response_schema: Type[BaseModel],
|
||||
default_columns: List[str] = None,
|
||||
searchable_columns: List[str] = None):
|
||||
self.dao = dao
|
||||
self.model = model
|
||||
self.response_schema = response_schema
|
||||
self.default_columns = default_columns or []
|
||||
self.searchable_columns = searchable_columns or []
|
||||
|
||||
def execute(self, request: BaseModel) -> BaseModel:
|
||||
"""Execute list operation with pagination and filtering."""
|
||||
|
||||
# Build query with filters
|
||||
query = self.dao.find_all()
|
||||
|
||||
# Apply search if provided
|
||||
if hasattr(request, 'search') and request.search:
|
||||
query = self._apply_search(query, request.search)
|
||||
|
||||
# Apply filters if provided
|
||||
if hasattr(request, 'filters') and request.filters:
|
||||
query = self._apply_filters(query, request.filters)
|
||||
|
||||
# Apply pagination
|
||||
total = query.count()
|
||||
|
||||
if hasattr(request, 'page') and hasattr(request, 'page_size'):
|
||||
offset = (request.page - 1) * request.page_size
|
||||
query = query.offset(offset).limit(request.page_size)
|
||||
|
||||
# Execute query and serialize
|
||||
results = query.all()
|
||||
serialized = [self._serialize_model(obj) for obj in results]
|
||||
|
||||
return self.response_schema(
|
||||
results=serialized,
|
||||
total_count=total,
|
||||
page=getattr(request, 'page', 1),
|
||||
page_size=getattr(request, 'page_size', len(serialized))
|
||||
)
|
||||
|
||||
class ModelGetInfoTool:
|
||||
"""Generic tool for getting single object by multiple identifier types."""
|
||||
|
||||
def __init__(self,
|
||||
dao: Type,
|
||||
model: Type,
|
||||
response_schema: Type[BaseModel],
|
||||
identifier_field_map: Dict[str, str]):
|
||||
self.dao = dao
|
||||
self.model = model
|
||||
self.response_schema = response_schema
|
||||
self.identifier_field_map = identifier_field_map
|
||||
|
||||
def execute(self, request: BaseModel) -> BaseModel:
|
||||
"""Execute get operation with multi-identifier support."""
|
||||
|
||||
identifier = request.identifier
|
||||
|
||||
# Determine identifier type and field
|
||||
if isinstance(identifier, int):
|
||||
field = self.identifier_field_map.get('id', 'id')
|
||||
obj = self.dao.find_by_id(identifier)
|
||||
elif isinstance(identifier, str):
|
||||
if len(identifier) == 36 and '-' in identifier: # UUID format
|
||||
field = self.identifier_field_map.get('uuid', 'uuid')
|
||||
obj = self.dao.find_by_uuid(identifier)
|
||||
else: # Assume slug
|
||||
field = self.identifier_field_map.get('slug', 'slug')
|
||||
obj = getattr(self.dao, 'find_by_slug', lambda x: None)(identifier)
|
||||
|
||||
if not obj:
|
||||
raise ValueError(f"Object not found with identifier: {identifier}")
|
||||
|
||||
# Serialize and return
|
||||
serialized = self._serialize_model(obj)
|
||||
return self.response_schema(**serialized)
|
||||
```
|
||||
|
||||
## Adding New Tools
|
||||
|
||||
### Step-by-Step Process
|
||||
|
||||
1. **Define the Domain**
|
||||
|
||||
Choose the appropriate domain folder:
|
||||
- `dashboard/` - Dashboard operations
|
||||
- `chart/` - Chart operations
|
||||
- `dataset/` - Dataset operations
|
||||
- `system/` - System-level operations
|
||||
|
||||
2. **Create Schemas**
|
||||
|
||||
```bash
|
||||
# Create schema file
|
||||
touch superset/mcp_service/schemas/my_domain_schemas.py
|
||||
```
|
||||
|
||||
```python
|
||||
# Define request/response schemas
|
||||
class MyToolRequest(BaseModel):
|
||||
param1: str = Field(description="Parameter description")
|
||||
param2: Optional[int] = Field(default=None, description="Optional parameter")
|
||||
|
||||
class MyToolResponse(BaseModel):
|
||||
result: str = Field(description="Result description")
|
||||
metadata: Dict[str, Any] = Field(description="Additional metadata")
|
||||
```
|
||||
|
||||
3. **Implement the Tool**
|
||||
|
||||
```bash
|
||||
# Create tool file
|
||||
touch superset/mcp_service/my_domain/tool/my_tool.py
|
||||
```
|
||||
|
||||
```python
|
||||
@mcp.tool
|
||||
@mcp_auth_hook(['required:scope'])
|
||||
def my_tool(request: MyToolRequest) -> MyToolResponse:
|
||||
"""Tool description for LLM."""
|
||||
|
||||
# Import Superset modules inside function
|
||||
from superset.daos.my_dao import MyDAO
|
||||
|
||||
# Implement business logic
|
||||
result = MyDAO.do_something(request.param1)
|
||||
|
||||
return MyToolResponse(
|
||||
result=result,
|
||||
metadata={"processed_at": datetime.utcnow()}
|
||||
)
|
||||
```
|
||||
|
||||
4. **Register the Tool**
|
||||
|
||||
```python
|
||||
# Add to superset/mcp_service/my_domain/tool/__init__.py
|
||||
from .my_tool import my_tool
|
||||
|
||||
__all__ = ['my_tool']
|
||||
```
|
||||
|
||||
```python
|
||||
# Import in superset/mcp_service/mcp_app.py
|
||||
from superset.mcp_service.my_domain.tool import *
|
||||
```
|
||||
|
||||
5. **Add Tests**
|
||||
|
||||
```bash
|
||||
# Create test file
|
||||
touch tests/unit_tests/mcp_service/test_my_tool.py
|
||||
```
|
||||
|
||||
```python
|
||||
import pytest
|
||||
from superset.mcp_service.my_domain.tool.my_tool import my_tool
|
||||
from superset.mcp_service.schemas.my_domain_schemas import MyToolRequest
|
||||
|
||||
class TestMyTool:
|
||||
def test_my_tool_success(self):
|
||||
request = MyToolRequest(param1="test")
|
||||
response = my_tool(request)
|
||||
assert response.result == "expected_result"
|
||||
```
|
||||
|
||||
### Tool Best Practices
|
||||
|
||||
1. **Import Inside Functions**
|
||||
```python
|
||||
# ❌ DON'T: Import at module level
|
||||
from superset.daos.chart import ChartDAO
|
||||
|
||||
@mcp.tool
|
||||
def my_tool():
|
||||
# Tool implementation
|
||||
pass
|
||||
|
||||
# ✅ DO: Import inside function
|
||||
@mcp.tool
|
||||
def my_tool():
|
||||
from superset.daos.chart import ChartDAO
|
||||
# Tool implementation
|
||||
pass
|
||||
```
|
||||
|
||||
2. **Use Generic Abstractions**
|
||||
```python
|
||||
# ✅ Leverage existing patterns
|
||||
@mcp.tool
|
||||
def list_my_objects(request):
|
||||
from superset.mcp_service.generic_tools import ModelListTool
|
||||
|
||||
tool = ModelListTool(
|
||||
dao=MyDAO,
|
||||
model=MyModel,
|
||||
response_schema=ListMyObjectsResponse
|
||||
)
|
||||
return tool.execute(request)
|
||||
```
|
||||
|
||||
3. **Comprehensive Error Handling**
|
||||
```python
|
||||
@mcp.tool
|
||||
def my_tool(request):
|
||||
try:
|
||||
# Tool implementation
|
||||
return success_response
|
||||
except PermissionError as e:
|
||||
return MyToolError(
|
||||
error="Permission denied",
|
||||
error_type="PermissionError",
|
||||
suggestions=["Check user permissions"]
|
||||
)
|
||||
except Exception as e:
|
||||
return MyToolError(
|
||||
error=f"Unexpected error: {str(e)}",
|
||||
error_type="InternalError"
|
||||
)
|
||||
```
|
||||
|
||||
## Testing Patterns
|
||||
|
||||
### Unit Test Structure
|
||||
|
||||
```python
|
||||
# tests/unit_tests/mcp_service/test_chart_tools.py
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
from superset.mcp_service.chart.tool.get_chart_info import get_chart_info
|
||||
from superset.mcp_service.schemas.chart_schemas import GetChartInfoRequest
|
||||
|
||||
class TestGetChartInfo:
|
||||
"""Test suite for get_chart_info tool."""
|
||||
|
||||
@patch('superset.mcp_service.chart.tool.get_chart_info.ChartDAO')
|
||||
def test_get_chart_info_by_id_success(self, mock_dao):
|
||||
"""Test successful chart lookup by ID."""
|
||||
|
||||
# Setup mock
|
||||
mock_chart = Mock()
|
||||
mock_chart.id = 1
|
||||
mock_chart.slice_name = "Test Chart"
|
||||
mock_chart.viz_type = "line"
|
||||
mock_dao.find_by_id.return_value = mock_chart
|
||||
|
||||
# Execute
|
||||
request = GetChartInfoRequest(identifier=1)
|
||||
response = get_chart_info(request)
|
||||
|
||||
# Verify
|
||||
assert response.chart_id == 1
|
||||
assert response.slice_name == "Test Chart"
|
||||
mock_dao.find_by_id.assert_called_once_with(1)
|
||||
|
||||
@patch('superset.mcp_service.chart.tool.get_chart_info.ChartDAO')
|
||||
def test_get_chart_info_not_found(self, mock_dao):
|
||||
"""Test chart not found scenario."""
|
||||
|
||||
# Setup mock
|
||||
mock_dao.find_by_id.return_value = None
|
||||
|
||||
# Execute
|
||||
request = GetChartInfoRequest(identifier=999)
|
||||
response = get_chart_info(request)
|
||||
|
||||
# Verify error response
|
||||
assert hasattr(response, 'error')
|
||||
assert "not found" in response.error.lower()
|
||||
```
|
||||
|
||||
### Integration Test Patterns
|
||||
|
||||
```python
|
||||
# tests/integration_tests/mcp_service/test_chart_integration.py
|
||||
import pytest
|
||||
from superset.app import create_app
|
||||
from superset.mcp_service.mcp_app import mcp
|
||||
from tests.integration_tests.base_tests import SupersetTestCase
|
||||
|
||||
class TestChartIntegration(SupersetTestCase):
|
||||
"""Integration tests for chart tools."""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.app = create_app()
|
||||
self.app_context = self.app.app_context()
|
||||
self.app_context.push()
|
||||
|
||||
def tearDown(self):
|
||||
self.app_context.pop()
|
||||
super().tearDown()
|
||||
|
||||
def test_chart_workflow_integration(self):
|
||||
"""Test complete chart workflow."""
|
||||
|
||||
# Create chart
|
||||
create_request = {
|
||||
"dataset_id": "1",
|
||||
"config": {
|
||||
"chart_type": "table",
|
||||
"columns": [{"name": "region"}]
|
||||
}
|
||||
}
|
||||
|
||||
create_response = mcp.call_tool("generate_chart", create_request)
|
||||
chart_id = create_response["chart_id"]
|
||||
|
||||
# Get chart info
|
||||
info_request = {"identifier": chart_id}
|
||||
info_response = mcp.call_tool("get_chart_info", info_request)
|
||||
|
||||
assert info_response["chart_id"] == chart_id
|
||||
assert info_response["viz_type"] == "table"
|
||||
|
||||
# Get chart data
|
||||
data_request = {"identifier": chart_id, "limit": 10}
|
||||
data_response = mcp.call_tool("get_chart_data", data_request)
|
||||
|
||||
assert "data" in data_response
|
||||
assert len(data_response["data"]) <= 10
|
||||
```
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
### Caching Strategy
|
||||
|
||||
The MCP service leverages Superset's existing cache layers:
|
||||
|
||||
```python
|
||||
# Cache control in tools
|
||||
@mcp.tool
|
||||
def get_chart_data(request: GetChartDataRequest):
|
||||
"""Tool with cache control."""
|
||||
|
||||
cache_config = {
|
||||
'use_cache': request.use_cache,
|
||||
'force_refresh': request.force_refresh,
|
||||
'cache_timeout': request.cache_timeout
|
||||
}
|
||||
|
||||
# Use Superset's cache infrastructure
|
||||
result = execute_with_cache(query, cache_config)
|
||||
|
||||
return ChartDataResponse(
|
||||
data=result.data,
|
||||
cache_status=result.cache_status
|
||||
)
|
||||
```
|
||||
|
||||
### Query Optimization
|
||||
|
||||
```python
|
||||
# Efficient pagination
|
||||
def list_objects(query, page, page_size):
|
||||
"""Optimized pagination pattern."""
|
||||
|
||||
# Count query optimization
|
||||
total = query.count()
|
||||
|
||||
# Limit columns for list operations
|
||||
query = query.options(load_only('id', 'name', 'created_on'))
|
||||
|
||||
# Apply pagination
|
||||
offset = (page - 1) * page_size
|
||||
results = query.offset(offset).limit(page_size).all()
|
||||
|
||||
return results, total
|
||||
```
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### Authentication Flow
|
||||
|
||||
```python
|
||||
# JWT validation and user context
|
||||
@mcp_auth_hook(['chart:read'])
|
||||
def secure_tool(request):
|
||||
"""Tool with proper security context."""
|
||||
|
||||
# g.user is set by auth hook
|
||||
user_id = g.user.id
|
||||
|
||||
# Apply user-specific filtering
|
||||
query = ChartDAO.find_all().filter(
|
||||
Chart.owners.contains(g.user)
|
||||
)
|
||||
|
||||
return execute_query(query)
|
||||
```
|
||||
|
||||
### Input Validation
|
||||
|
||||
```python
|
||||
# Comprehensive request validation
|
||||
class CreateChartRequest(BaseModel):
|
||||
"""Validated chart creation request."""
|
||||
|
||||
dataset_id: Union[int, str] = Field(
|
||||
...,
|
||||
description="Dataset ID or UUID"
|
||||
)
|
||||
|
||||
config: ChartConfig = Field(
|
||||
...,
|
||||
description="Chart configuration"
|
||||
)
|
||||
|
||||
@validator('dataset_id')
|
||||
def validate_dataset_id(cls, v):
|
||||
"""Validate dataset exists and user has access."""
|
||||
# Validation logic
|
||||
return v
|
||||
|
||||
@validator('config')
|
||||
def validate_chart_config(cls, v):
|
||||
"""Validate chart configuration."""
|
||||
# Configuration validation
|
||||
return v
|
||||
```
|
||||
|
||||
This development guide provides comprehensive coverage of the MCP service's internal architecture and development patterns, enabling team members to effectively extend and maintain the system.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
### 📚 **Ready to Use Your New Tools?**
|
||||
Test your implementations with examples from the [API Reference](./api-reference).
|
||||
|
||||
### 🔐 **Securing Your Extensions?**
|
||||
Add authentication to your tools using the [Authentication Guide](./authentication).
|
||||
|
||||
### 🏗️ **Understanding the Big Picture?**
|
||||
See the complete system design in the [Architecture Overview](./architecture).
|
||||
|
||||
### 🏢 **Building Enterprise Features?**
|
||||
Explore advanced patterns in the [Preset Integration Guide](./preset-integration).
|
||||
|
||||
> 📖 **Back to Documentation Index**: [MCP Service](./intro)
|
||||
124
docs/docs/mcp-service/intro.mdx
Normal file
124
docs/docs/mcp-service/intro.mdx
Normal file
@@ -0,0 +1,124 @@
|
||||
---
|
||||
title: MCP Service
|
||||
sidebar_position: 1
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Superset MCP Service
|
||||
|
||||
The Superset Model Context Protocol (MCP) service provides programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built for LLM agents and automation tools.
|
||||
|
||||
## What is MCP?
|
||||
|
||||
The Model Context Protocol (MCP) is an open standard that allows AI assistants to securely connect to data sources and tools. Superset's MCP service exposes **16 production-ready tools** that enable:
|
||||
|
||||
- 📊 **Data Exploration**: List and query dashboards, charts, and datasets
|
||||
- 🔧 **Chart Creation**: Generate visualizations programmatically
|
||||
- 📈 **Data Export**: Extract data in multiple formats (JSON, CSV, Excel)
|
||||
- 🔗 **Navigation**: Generate explore links and SQL Lab sessions
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
|
||||
:::note
|
||||
The MCP service is included with Superset development setup. FastMCP dependencies are installed automatically with `make install`.
|
||||
:::
|
||||
|
||||
```bash
|
||||
# MCP service is included with Superset development setup
|
||||
git clone https://github.com/apache/superset.git
|
||||
cd superset
|
||||
make venv && source venv/bin/activate
|
||||
make install
|
||||
|
||||
# Start Superset
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
|
||||
# Start MCP service (separate terminal)
|
||||
source venv/bin/activate
|
||||
superset mcp run --port 5008 --debug
|
||||
```
|
||||
|
||||
### Claude Desktop Integration
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Superset MCP": {
|
||||
"command": "/path/to/superset/superset/mcp_service/run_proxy.sh",
|
||||
"args": [],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🔧 **16 Production Tools**
|
||||
| Category | Tools | Purpose |
|
||||
|----------|-------|---------|
|
||||
| **Dashboard** (5) | List, get info, create, add charts | Dashboard management |
|
||||
| **Chart** (8) | Full CRUD, data export, previews | Chart operations |
|
||||
| **Dataset** (3) | List, get info, discover filters | Dataset exploration |
|
||||
| **System** (2) | Instance info, explore links | System integration |
|
||||
| **SQL Lab** (1) | Pre-configured sessions | SQL development |
|
||||
|
||||
### 🔐 **Enterprise Security**
|
||||
- **JWT Bearer Authentication**: Production-ready with configurable factory pattern
|
||||
- **RBAC Integration**: Scope-based permissions with Superset's security model
|
||||
- **Audit Logging**: Comprehensive MCP context tracking
|
||||
|
||||
### 📊 **Advanced Capabilities**
|
||||
- **Multi-format Export**: JSON, CSV, Excel data export
|
||||
- **Chart Previews**: Screenshots, ASCII art, table representations
|
||||
- **Cache Control**: Leverage Superset's existing cache infrastructure
|
||||
- **Request Schemas**: Eliminates LLM parameter validation issues
|
||||
|
||||
## Example Usage
|
||||
|
||||
```python
|
||||
# List dashboards
|
||||
dashboards = client.call_tool("list_dashboards", {
|
||||
"search": "sales",
|
||||
"page_size": 10
|
||||
})
|
||||
|
||||
# Create a chart
|
||||
chart = client.call_tool("generate_chart", {
|
||||
"dataset_id": "1",
|
||||
"config": {
|
||||
"chart_type": "line",
|
||||
"x": {"name": "date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}]
|
||||
}
|
||||
})
|
||||
|
||||
# Export chart data
|
||||
data = client.call_tool("get_chart_data", {
|
||||
"identifier": chart["chart_id"],
|
||||
"format": "json",
|
||||
"limit": 1000
|
||||
})
|
||||
```
|
||||
|
||||
## Status
|
||||
|
||||
✅ **Phase 1 Complete** - Core functionality stable, authentication production-ready, comprehensive testing coverage.
|
||||
|
||||
## Documentation Structure
|
||||
|
||||
### Getting Started
|
||||
- **[Overview](./overview)** - Features, use cases, and examples
|
||||
- **[API Reference](./api-reference)** - Complete tool documentation
|
||||
|
||||
### Development
|
||||
- **[Development Guide](./development)** - Internal architecture and adding tools
|
||||
- **[Architecture](./architecture)** - System design and patterns
|
||||
|
||||
### Production
|
||||
- **[Authentication](./authentication)** - JWT setup and security
|
||||
- **[Preset Integration](./preset-integration)** - Enterprise features
|
||||
|
||||
> 🚀 **Ready to start?** Continue with the [Overview](./overview) for detailed examples and use cases.
|
||||
196
docs/docs/mcp-service/overview.mdx
Normal file
196
docs/docs/mcp-service/overview.mdx
Normal file
@@ -0,0 +1,196 @@
|
||||
---
|
||||
title: MCP Service Overview
|
||||
sidebar_position: 1
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Superset MCP Service
|
||||
|
||||
The Superset Model Context Protocol (MCP) service provides a modular, schema-driven interface for programmatic access to Superset dashboards, charts, datasets, and instance metadata. Built on FastMCP, it's designed for LLM agents and automation tools.
|
||||
|
||||
**Status:** ✅ Phase 1 Complete. Core functionality stable, authentication production-ready, comprehensive testing coverage.
|
||||
|
||||
## What is MCP?
|
||||
|
||||
The Model Context Protocol (MCP) is an open standard for connecting AI assistants to data sources and tools. Superset's MCP service exposes 16 tools that allow LLM agents to:
|
||||
|
||||
- **Explore data**: List and query dashboards, charts, and datasets
|
||||
- **Create visualizations**: Generate charts and dashboards programmatically
|
||||
- **Export data**: Extract chart data in multiple formats
|
||||
- **Navigate interfaces**: Generate explore links and SQL Lab sessions
|
||||
|
||||
## Key Features
|
||||
|
||||
### 🔧 **16 Production-Ready Tools**
|
||||
- **Dashboard Tools (5)**: List, get info, create dashboards, add charts
|
||||
- **Chart Tools (8)**: Full CRUD operations, data export, screenshot previews
|
||||
- **Dataset Tools (3)**: List, get info, discover filterable columns
|
||||
- **System Tools (2)**: Instance info, explore link generation
|
||||
- **SQL Lab Tools (1)**: Pre-configured SQL sessions
|
||||
|
||||
### 🔐 **Enterprise Authentication**
|
||||
- **JWT Bearer Authentication**: Production-ready with configurable factory pattern
|
||||
- **RBAC Integration**: Scope-based permissions with Superset's security model
|
||||
- **Audit Logging**: Comprehensive MCP context tracking with impersonation support
|
||||
|
||||
### 📊 **Advanced Capabilities**
|
||||
- **Multi-format Export**: JSON, CSV, Excel data export
|
||||
- **Chart Previews**: Screenshots, ASCII art, and table representations
|
||||
- **Cache Control**: Comprehensive control over Superset's cache layers
|
||||
- **Request Schema Pattern**: Eliminates LLM parameter validation issues
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph "Client Layer"
|
||||
LLM[LLM/Agent Client]
|
||||
Claude[Claude Desktop]
|
||||
SDK[Custom SDK]
|
||||
end
|
||||
|
||||
subgraph "MCP Service Layer"
|
||||
FastMCP[FastMCP Server<br/>Port 5008]
|
||||
Auth[JWT Auth Hook]
|
||||
Tools[16 MCP Tools]
|
||||
end
|
||||
|
||||
subgraph "Superset Integration"
|
||||
DAOs[Superset DAOs]
|
||||
Commands[Superset Commands]
|
||||
Cache[Cache Layer]
|
||||
end
|
||||
|
||||
subgraph "Data Layer"
|
||||
DB[(Superset Database)]
|
||||
DataWarehouse[(Data Warehouse)]
|
||||
end
|
||||
|
||||
LLM --> FastMCP
|
||||
Claude --> FastMCP
|
||||
SDK --> FastMCP
|
||||
|
||||
FastMCP --> Auth
|
||||
Auth --> Tools
|
||||
|
||||
Tools --> DAOs
|
||||
Tools --> Commands
|
||||
Tools --> Cache
|
||||
|
||||
DAOs --> DB
|
||||
Commands --> DB
|
||||
Commands --> DataWarehouse
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Quick Setup
|
||||
|
||||
```bash
|
||||
# Clone and install Superset
|
||||
git clone https://github.com/apache/superset.git
|
||||
cd superset
|
||||
make venv && source venv/bin/activate
|
||||
make install
|
||||
|
||||
# Start Superset
|
||||
superset run -p 8088 --with-threads --reload --debugger
|
||||
|
||||
# Start MCP service (in separate terminal)
|
||||
source venv/bin/activate
|
||||
superset mcp run --port 5008 --debug
|
||||
```
|
||||
|
||||
### Connect to Claude Desktop
|
||||
|
||||
:::note
|
||||
The MCP service runs on HTTP and requires a proxy for Claude Desktop integration.
|
||||
:::
|
||||
|
||||
```bash
|
||||
# Install FastMCP proxy
|
||||
pip install fastmcp
|
||||
```
|
||||
|
||||
Configure Claude Desktop (`~/.config/Claude/claude_desktop_config.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"Superset MCP": {
|
||||
"command": "/path/to/superset/superset/mcp_service/run_proxy.sh",
|
||||
"args": [],
|
||||
"env": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Data Exploration
|
||||
- "List all dashboards related to sales"
|
||||
- "Show me the charts in the Q4 Performance dashboard"
|
||||
- "What datasets are available for customer analysis?"
|
||||
|
||||
### Chart Creation
|
||||
- "Create a line chart showing revenue trends by month"
|
||||
- "Generate a table showing top 10 products by sales"
|
||||
- "Build a bar chart comparing regional performance"
|
||||
|
||||
### Data Export
|
||||
- "Export the sales data from this chart as CSV"
|
||||
- "Get the underlying data for this dashboard as JSON"
|
||||
- "Show me a preview of this chart as ASCII art"
|
||||
|
||||
### Dashboard Management
|
||||
- "Create a new dashboard with these 4 charts"
|
||||
- "Add this revenue chart to the executive dashboard"
|
||||
- "Generate an explore link for this chart configuration"
|
||||
|
||||
## Example Workflow
|
||||
|
||||
```python
|
||||
# List available dashboards
|
||||
dashboards = client.call_tool("list_dashboards", {
|
||||
"search": "sales",
|
||||
"page_size": 10
|
||||
})
|
||||
|
||||
# Get detailed dashboard info
|
||||
dashboard = client.call_tool("get_dashboard_info", {
|
||||
"identifier": dashboards["dashboards"][0]["id"]
|
||||
})
|
||||
|
||||
# Create a new chart
|
||||
chart = client.call_tool("generate_chart", {
|
||||
"dataset_id": "1",
|
||||
"config": {
|
||||
"chart_type": "line",
|
||||
"x": {"name": "date"},
|
||||
"y": [{"name": "revenue", "aggregate": "SUM"}]
|
||||
}
|
||||
})
|
||||
|
||||
# Export chart data
|
||||
data = client.call_tool("get_chart_data", {
|
||||
"identifier": chart["chart_id"],
|
||||
"format": "json",
|
||||
"limit": 1000
|
||||
})
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
### Ready to Use MCP?
|
||||
- **[📚 API Reference](./api-reference)** - Try all 16 tools with request/response examples
|
||||
- **[🔐 Authentication](./authentication)** - Set up JWT security for production use
|
||||
|
||||
### Want to Extend MCP?
|
||||
- **[🔧 Development Guide](./development)** - Learn internal architecture and add new tools
|
||||
- **[🏗️ Architecture](./architecture)** - Understand system design and deployment patterns
|
||||
|
||||
### Enterprise Deployment?
|
||||
- **[🏢 Preset Integration](./preset-integration)** - RBAC extensions and OIDC integration for enterprise
|
||||
|
||||
> 💡 **Getting started?** Return to the [MCP Service intro](./intro) for a complete overview.
|
||||
483
docs/docs/mcp-service/preset-integration.mdx
Normal file
483
docs/docs/mcp-service/preset-integration.mdx
Normal file
@@ -0,0 +1,483 @@
|
||||
---
|
||||
title: Preset.io Integration
|
||||
sidebar_position: 6
|
||||
version: 1
|
||||
---
|
||||
|
||||
# Preset.io Integration Guide
|
||||
|
||||
This document outlines integration points for the Preset.io team to extend the Superset MCP service with enterprise features, RBAC customizations, and OIDC integration.
|
||||
|
||||
## RBAC Extension Points
|
||||
|
||||
### Custom Authorization Factory
|
||||
|
||||
The MCP service supports custom authorization logic through the factory pattern:
|
||||
|
||||
```python
|
||||
# In preset_config.py or superset_config.py
|
||||
def create_preset_mcp_auth(app):
|
||||
"""Custom auth factory for Preset.io environments."""
|
||||
from superset.mcp_service.auth import create_auth_provider
|
||||
from preset.auth.mcp import PresetMCPAuthProvider
|
||||
|
||||
return PresetMCPAuthProvider(
|
||||
jwks_uri=app.config["PRESET_JWKS_URI"],
|
||||
issuer=app.config["PRESET_JWT_ISSUER"],
|
||||
audience=app.config["PRESET_JWT_AUDIENCE"],
|
||||
tenant_resolver=preset_tenant_resolver,
|
||||
rbac_manager=app.security_manager,
|
||||
)
|
||||
|
||||
MCP_AUTH_FACTORY = create_preset_mcp_auth
|
||||
```
|
||||
|
||||
### Multi-Tenant RBAC
|
||||
|
||||
Extend the base auth hook for tenant-aware permissions:
|
||||
|
||||
```python
|
||||
# preset/mcp/auth.py
|
||||
from superset.mcp_service.auth import mcp_auth_hook
|
||||
from functools import wraps
|
||||
|
||||
def preset_tenant_auth_hook(required_permissions=None):
|
||||
"""Preset-specific auth hook with tenant isolation."""
|
||||
def decorator(func):
|
||||
@wraps(func)
|
||||
@mcp_auth_hook(required_permissions)
|
||||
def wrapper(*args, **kwargs):
|
||||
# Extract tenant from JWT claims
|
||||
tenant_id = g.user.tenant_id if hasattr(g.user, 'tenant_id') else None
|
||||
|
||||
# Inject tenant context
|
||||
g.mcp_tenant_id = tenant_id
|
||||
g.mcp_tenant_context = get_tenant_context(tenant_id)
|
||||
|
||||
return func(*args, **kwargs)
|
||||
return wrapper
|
||||
return decorator
|
||||
```
|
||||
|
||||
### Custom Permission Scopes
|
||||
|
||||
Define Preset-specific permission scopes:
|
||||
|
||||
```python
|
||||
# preset/mcp/permissions.py
|
||||
PRESET_MCP_SCOPES = {
|
||||
# Tenant-level permissions
|
||||
"tenant:admin": "Full tenant administration",
|
||||
"tenant:read": "Read tenant resources",
|
||||
|
||||
# Workspace-level permissions
|
||||
"workspace:admin": "Full workspace administration",
|
||||
"workspace:read": "Read workspace resources",
|
||||
|
||||
# Enhanced dashboard permissions
|
||||
"dashboard:publish": "Publish dashboards to marketplace",
|
||||
"dashboard:embed": "Generate embed tokens",
|
||||
|
||||
# Enhanced chart permissions
|
||||
"chart:export": "Export chart data and configs",
|
||||
"chart:alerts": "Manage chart alerts and notifications",
|
||||
|
||||
# Dataset permissions with row-level security
|
||||
"dataset:rls": "Apply row-level security filters",
|
||||
"dataset:pii": "Access PII-flagged columns",
|
||||
}
|
||||
|
||||
def get_preset_required_scopes(tool_name: str, context: dict = None) -> List[str]:
|
||||
"""Map tool calls to Preset-specific permission requirements."""
|
||||
base_scopes = get_base_required_scopes(tool_name)
|
||||
|
||||
# Add tenant-aware scopes
|
||||
if context and context.get('tenant_id'):
|
||||
base_scopes.append(f"tenant:{context['tenant_id']}")
|
||||
|
||||
# Add workspace-aware scopes
|
||||
if context and context.get('workspace_id'):
|
||||
base_scopes.append(f"workspace:{context['workspace_id']}")
|
||||
|
||||
return base_scopes
|
||||
```
|
||||
|
||||
### Row-Level Security Integration
|
||||
|
||||
Extend data access tools with RLS:
|
||||
|
||||
```python
|
||||
# preset/mcp/rls.py
|
||||
def apply_preset_rls_filters(query_context: dict, user_context: dict) -> dict:
|
||||
"""Apply Preset row-level security filters to query context."""
|
||||
|
||||
# Get user's RLS rules from Preset metadata
|
||||
rls_rules = get_user_rls_rules(
|
||||
user_id=user_context['user_id'],
|
||||
tenant_id=user_context['tenant_id'],
|
||||
workspace_id=user_context.get('workspace_id')
|
||||
)
|
||||
|
||||
# Apply RLS filters to query
|
||||
for rule in rls_rules:
|
||||
if rule.applies_to_dataset(query_context['datasource']['id']):
|
||||
query_context = rule.apply_filter(query_context)
|
||||
|
||||
return query_context
|
||||
|
||||
# Usage in custom tools
|
||||
@mcp.tool
|
||||
@preset_tenant_auth_hook(['dataset:read', 'dataset:rls'])
|
||||
def preset_get_chart_data(request: GetChartDataRequest) -> ChartDataResponse:
|
||||
"""Get chart data with Preset RLS applied."""
|
||||
|
||||
# Apply RLS before executing query
|
||||
query_context = build_query_context(request)
|
||||
query_context = apply_preset_rls_filters(
|
||||
query_context,
|
||||
{'user_id': g.user.id, 'tenant_id': g.mcp_tenant_id}
|
||||
)
|
||||
|
||||
return execute_chart_data_query(query_context)
|
||||
```
|
||||
|
||||
## OIDC Integration Points
|
||||
|
||||
### Preset OIDC Provider
|
||||
|
||||
Custom OIDC integration for Preset environments:
|
||||
|
||||
```python
|
||||
# preset/mcp/oidc.py
|
||||
from superset.mcp_service.auth.providers.bearer import BearerAuthProvider
|
||||
import requests
|
||||
from typing import Dict, Any
|
||||
|
||||
class PresetOIDCAuthProvider(BearerAuthProvider):
|
||||
"""OIDC-specific auth provider for Preset.io."""
|
||||
|
||||
def __init__(self,
|
||||
oidc_discovery_url: str,
|
||||
client_id: str,
|
||||
client_secret: str = None,
|
||||
**kwargs):
|
||||
|
||||
# Discover OIDC endpoints
|
||||
self.discovery_doc = self._fetch_discovery_document(oidc_discovery_url)
|
||||
|
||||
super().__init__(
|
||||
jwks_uri=self.discovery_doc['jwks_uri'],
|
||||
issuer=self.discovery_doc['issuer'],
|
||||
**kwargs
|
||||
)
|
||||
|
||||
self.client_id = client_id
|
||||
self.client_secret = client_secret
|
||||
|
||||
def _fetch_discovery_document(self, discovery_url: str) -> Dict[str, Any]:
|
||||
"""Fetch OIDC discovery document."""
|
||||
response = requests.get(discovery_url)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def validate_token(self, token: str) -> Dict[str, Any]:
|
||||
"""Validate JWT token with OIDC-specific claims."""
|
||||
claims = super().validate_token(token)
|
||||
|
||||
# Validate OIDC-specific claims
|
||||
if claims.get('aud') != self.client_id:
|
||||
raise ValueError("Invalid audience claim")
|
||||
|
||||
# Extract Preset-specific claims
|
||||
claims['preset_tenant_id'] = claims.get('tenant_id')
|
||||
claims['preset_workspace_id'] = claims.get('workspace_id')
|
||||
claims['preset_roles'] = claims.get('roles', [])
|
||||
|
||||
return claims
|
||||
|
||||
def resolve_user(self, claims: Dict[str, Any]) -> Any:
|
||||
"""Resolve Superset user from OIDC claims."""
|
||||
from preset.auth.user_resolver import resolve_preset_user
|
||||
|
||||
return resolve_preset_user(
|
||||
subject=claims['sub'],
|
||||
email=claims.get('email'),
|
||||
tenant_id=claims.get('preset_tenant_id'),
|
||||
roles=claims.get('preset_roles', [])
|
||||
)
|
||||
```
|
||||
|
||||
### Configuration for OIDC
|
||||
|
||||
```python
|
||||
# In preset_config.py
|
||||
def create_preset_oidc_auth(app):
|
||||
"""Factory for Preset OIDC authentication."""
|
||||
from preset.mcp.oidc import PresetOIDCAuthProvider
|
||||
|
||||
return PresetOIDCAuthProvider(
|
||||
oidc_discovery_url=app.config["PRESET_OIDC_DISCOVERY_URL"],
|
||||
client_id=app.config["PRESET_OIDC_CLIENT_ID"],
|
||||
client_secret=app.config["PRESET_OIDC_CLIENT_SECRET"],
|
||||
audience=app.config["PRESET_MCP_AUDIENCE"],
|
||||
required_scopes=app.config.get("PRESET_MCP_REQUIRED_SCOPES", [])
|
||||
)
|
||||
|
||||
# MCP Configuration
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_AUTH_FACTORY = create_preset_oidc_auth
|
||||
|
||||
# OIDC Configuration
|
||||
PRESET_OIDC_DISCOVERY_URL = "https://auth.preset.io/.well-known/openid_configuration"
|
||||
PRESET_OIDC_CLIENT_ID = "preset-mcp-service"
|
||||
PRESET_OIDC_CLIENT_SECRET = os.environ.get("PRESET_OIDC_CLIENT_SECRET")
|
||||
PRESET_MCP_AUDIENCE = "preset-superset-mcp"
|
||||
PRESET_MCP_REQUIRED_SCOPES = [
|
||||
"openid", "profile", "email",
|
||||
"superset:read", "superset:write"
|
||||
]
|
||||
```
|
||||
|
||||
## Preset-Specific Tools
|
||||
|
||||
### Tenant Management Tools
|
||||
|
||||
```python
|
||||
# preset/mcp/tools/tenant.py
|
||||
@mcp.tool
|
||||
@preset_tenant_auth_hook(['tenant:read'])
|
||||
def get_tenant_info(request: GetTenantInfoRequest) -> TenantInfoResponse:
|
||||
"""Get Preset tenant information and quotas."""
|
||||
|
||||
tenant_id = g.mcp_tenant_id
|
||||
tenant = get_tenant_by_id(tenant_id)
|
||||
|
||||
return TenantInfoResponse(
|
||||
tenant_id=tenant.id,
|
||||
name=tenant.name,
|
||||
plan=tenant.plan,
|
||||
quotas=tenant.quotas,
|
||||
usage=get_tenant_usage(tenant_id),
|
||||
workspaces=list_tenant_workspaces(tenant_id)
|
||||
)
|
||||
|
||||
@mcp.tool
|
||||
@preset_tenant_auth_hook(['workspace:read'])
|
||||
def list_workspace_assets(request: ListWorkspaceAssetsRequest) -> ListWorkspaceAssetsResponse:
|
||||
"""List all assets in a Preset workspace."""
|
||||
|
||||
workspace_id = request.workspace_id
|
||||
tenant_id = g.mcp_tenant_id
|
||||
|
||||
# Validate workspace belongs to tenant
|
||||
validate_workspace_access(workspace_id, tenant_id)
|
||||
|
||||
assets = {
|
||||
'dashboards': list_workspace_dashboards(workspace_id),
|
||||
'charts': list_workspace_charts(workspace_id),
|
||||
'datasets': list_workspace_datasets(workspace_id)
|
||||
}
|
||||
|
||||
return ListWorkspaceAssetsResponse(
|
||||
workspace_id=workspace_id,
|
||||
assets=assets,
|
||||
total_count=sum(len(v) for v in assets.values())
|
||||
)
|
||||
```
|
||||
|
||||
### Embed Token Generation
|
||||
|
||||
```python
|
||||
# preset/mcp/tools/embed.py
|
||||
@mcp.tool
|
||||
@preset_tenant_auth_hook(['dashboard:embed'])
|
||||
def generate_embed_token(request: GenerateEmbedTokenRequest) -> EmbedTokenResponse:
|
||||
"""Generate secure embed token for dashboard/chart."""
|
||||
|
||||
# Validate resource access
|
||||
resource = validate_embed_resource_access(
|
||||
resource_type=request.resource_type,
|
||||
resource_id=request.resource_id,
|
||||
tenant_id=g.mcp_tenant_id
|
||||
)
|
||||
|
||||
# Generate signed embed token
|
||||
embed_token = create_embed_token(
|
||||
resource=resource,
|
||||
user_id=g.user.id,
|
||||
tenant_id=g.mcp_tenant_id,
|
||||
permissions=request.permissions,
|
||||
expiry=request.expiry_hours
|
||||
)
|
||||
|
||||
return EmbedTokenResponse(
|
||||
embed_token=embed_token,
|
||||
embed_url=f"{get_preset_base_url()}/embed/{embed_token}",
|
||||
expires_at=embed_token.expires_at
|
||||
)
|
||||
```
|
||||
|
||||
## Audit and Compliance Extensions
|
||||
|
||||
### Enhanced Audit Logging
|
||||
|
||||
```python
|
||||
# preset/mcp/audit.py
|
||||
from superset.mcp_service.auth import get_audit_context
|
||||
|
||||
def create_preset_audit_context(user_context: dict, tool_name: str,
|
||||
request_data: dict) -> dict:
|
||||
"""Create Preset-specific audit context."""
|
||||
|
||||
base_context = get_audit_context(user_context, tool_name, request_data)
|
||||
|
||||
# Add Preset-specific fields
|
||||
preset_context = {
|
||||
**base_context,
|
||||
'tenant_id': user_context.get('tenant_id'),
|
||||
'workspace_id': user_context.get('workspace_id'),
|
||||
'preset_user_role': user_context.get('preset_role'),
|
||||
'data_classification': classify_request_data(request_data),
|
||||
'compliance_flags': get_compliance_flags(tool_name, request_data)
|
||||
}
|
||||
|
||||
return preset_context
|
||||
|
||||
def log_preset_mcp_access(audit_context: dict):
|
||||
"""Log MCP access to Preset audit systems."""
|
||||
|
||||
# Log to Superset's audit system
|
||||
log_superset_audit_event(audit_context)
|
||||
|
||||
# Log to Preset's compliance system
|
||||
log_preset_compliance_event(audit_context)
|
||||
|
||||
# Log to external SIEM if configured
|
||||
if app.config.get('PRESET_SIEM_ENABLED'):
|
||||
log_to_siem(audit_context)
|
||||
```
|
||||
|
||||
### Data Classification
|
||||
|
||||
```python
|
||||
# preset/mcp/classification.py
|
||||
def classify_request_data(request_data: dict) -> dict:
|
||||
"""Classify data sensitivity in MCP requests."""
|
||||
|
||||
classification = {
|
||||
'contains_pii': False,
|
||||
'data_level': 'public',
|
||||
'retention_policy': 'standard'
|
||||
}
|
||||
|
||||
# Check for PII in request
|
||||
if contains_pii_fields(request_data):
|
||||
classification['contains_pii'] = True
|
||||
classification['data_level'] = 'restricted'
|
||||
classification['retention_policy'] = 'pii_compliant'
|
||||
|
||||
# Check for sensitive datasets
|
||||
if references_sensitive_datasets(request_data):
|
||||
classification['data_level'] = 'confidential'
|
||||
|
||||
return classification
|
||||
```
|
||||
|
||||
## Deployment Considerations
|
||||
|
||||
### Multi-Region Deployment
|
||||
|
||||
```python
|
||||
# preset/mcp/deployment.py
|
||||
def get_region_specific_config():
|
||||
"""Get region-specific MCP configuration."""
|
||||
|
||||
region = os.environ.get('PRESET_REGION', 'us-east-1')
|
||||
|
||||
config_map = {
|
||||
'us-east-1': {
|
||||
'jwks_uri': 'https://auth-us.preset.io/.well-known/jwks.json',
|
||||
'base_url': 'https://app.preset.io',
|
||||
'data_residency': 'US'
|
||||
},
|
||||
'eu-west-1': {
|
||||
'jwks_uri': 'https://auth-eu.preset.io/.well-known/jwks.json',
|
||||
'base_url': 'https://eu.preset.io',
|
||||
'data_residency': 'EU'
|
||||
}
|
||||
}
|
||||
|
||||
return config_map.get(region, config_map['us-east-1'])
|
||||
|
||||
# Usage in config
|
||||
region_config = get_region_specific_config()
|
||||
PRESET_JWKS_URI = region_config['jwks_uri']
|
||||
SUPERSET_WEBSERVER_ADDRESS = region_config['base_url']
|
||||
```
|
||||
|
||||
### Health Check Extensions
|
||||
|
||||
```python
|
||||
# preset/mcp/health.py
|
||||
@mcp.tool
|
||||
def preset_health_check() -> HealthCheckResponse:
|
||||
"""Preset-specific health check for MCP service."""
|
||||
|
||||
checks = {
|
||||
'mcp_service': check_mcp_service_health(),
|
||||
'database': check_database_health(),
|
||||
'auth_provider': check_auth_provider_health(),
|
||||
'tenant_isolation': check_tenant_isolation(),
|
||||
'rls_engine': check_rls_engine_health()
|
||||
}
|
||||
|
||||
overall_status = 'healthy' if all(
|
||||
check['status'] == 'healthy' for check in checks.values()
|
||||
) else 'degraded'
|
||||
|
||||
return HealthCheckResponse(
|
||||
status=overall_status,
|
||||
checks=checks,
|
||||
region=os.environ.get('PRESET_REGION'),
|
||||
version=get_preset_mcp_version()
|
||||
)
|
||||
```
|
||||
|
||||
## Configuration Templates
|
||||
|
||||
### Production Configuration
|
||||
|
||||
```python
|
||||
# preset_production_config.py
|
||||
from preset.mcp.auth import create_preset_oidc_auth
|
||||
from preset.mcp.audit import create_preset_audit_context
|
||||
|
||||
# MCP Service Configuration
|
||||
MCP_AUTH_ENABLED = True
|
||||
MCP_AUTH_FACTORY = create_preset_oidc_auth
|
||||
MCP_AUDIT_CONTEXT_FACTORY = create_preset_audit_context
|
||||
|
||||
# Preset OIDC Configuration
|
||||
PRESET_OIDC_DISCOVERY_URL = "https://auth.preset.io/.well-known/openid_configuration"
|
||||
PRESET_OIDC_CLIENT_ID = "preset-mcp-production"
|
||||
PRESET_MCP_AUDIENCE = "preset-superset-mcp"
|
||||
|
||||
# Security Configuration
|
||||
PRESET_MCP_REQUIRED_SCOPES = [
|
||||
"openid", "profile", "email",
|
||||
"tenant:read", "workspace:read",
|
||||
"dashboard:read", "chart:read", "dataset:read"
|
||||
]
|
||||
|
||||
# Audit Configuration
|
||||
PRESET_AUDIT_ENABLED = True
|
||||
PRESET_SIEM_ENABLED = True
|
||||
PRESET_COMPLIANCE_MODE = "SOC2"
|
||||
|
||||
# Performance Configuration
|
||||
PRESET_MCP_CACHE_ENABLED = True
|
||||
PRESET_MCP_RATE_LIMIT = "1000/hour"
|
||||
PRESET_MCP_TIMEOUT = 30
|
||||
```
|
||||
|
||||
This integration guide provides the Preset.io team with concrete extension points for implementing enterprise features while maintaining compatibility with the base MCP service architecture.
|
||||
@@ -87,6 +87,16 @@ const sidebars = {
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'MCP Service',
|
||||
items: [
|
||||
{
|
||||
type: 'autogenerated',
|
||||
dirName: 'mcp-service',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'doc',
|
||||
label: 'FAQ',
|
||||
|
||||
@@ -133,6 +133,7 @@ solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]
|
||||
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
|
||||
excel = ["xlrd>=1.2.0, <1.3"]
|
||||
fastmcp = ["fastmcp>=2.8.1"]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
@@ -202,6 +203,7 @@ development = [
|
||||
"pyinstrument>=4.0.2,<5",
|
||||
"pylint",
|
||||
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
|
||||
"pytest-asyncio", # need this due to not using latest pytest
|
||||
"pytest-cov",
|
||||
"pytest-mock",
|
||||
"python-ldap>=3.4.4",
|
||||
|
||||
@@ -16,4 +16,4 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
#
|
||||
-e .[development,bigquery,druid,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
|
||||
-e .[development,bigquery,druid,fastmcp,gevent,gsheets,mysql,postgres,presto,prophet,trino,thumbnails]
|
||||
|
||||
@@ -10,6 +10,14 @@ amqp==5.3.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# kombu
|
||||
annotated-types==0.7.0
|
||||
# via pydantic
|
||||
anyio==4.9.0
|
||||
# via
|
||||
# httpx
|
||||
# mcp
|
||||
# sse-starlette
|
||||
# starlette
|
||||
apispec==6.6.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -24,11 +32,14 @@ attrs==25.3.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cattrs
|
||||
# cyclopts
|
||||
# jsonschema
|
||||
# outcome
|
||||
# referencing
|
||||
# requests-cache
|
||||
# trio
|
||||
authlib==1.6.1
|
||||
# via fastmcp
|
||||
babel==2.17.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -77,6 +88,8 @@ celery==5.5.2
|
||||
certifi==2025.6.15
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# httpcore
|
||||
# httpx
|
||||
# requests
|
||||
# selenium
|
||||
cffi==1.17.1
|
||||
@@ -101,6 +114,7 @@ click==8.2.1
|
||||
# click-repl
|
||||
# flask
|
||||
# flask-appbuilder
|
||||
# uvicorn
|
||||
click-didyoumean==0.3.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -140,10 +154,13 @@ cryptography==44.0.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# authlib
|
||||
# paramiko
|
||||
# pyopenssl
|
||||
cycler==0.12.1
|
||||
# via matplotlib
|
||||
cyclopts==3.22.2
|
||||
# via fastmcp
|
||||
db-dtypes==1.3.1
|
||||
# via pandas-gbq
|
||||
defusedxml==0.7.1
|
||||
@@ -168,14 +185,23 @@ dnspython==2.7.0
|
||||
# email-validator
|
||||
docker==7.0.0
|
||||
# via apache-superset
|
||||
docstring-parser==0.17.0
|
||||
# via cyclopts
|
||||
docutils==0.21.2
|
||||
# via rich-rst
|
||||
email-validator==2.2.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-appbuilder
|
||||
# pydantic
|
||||
et-xmlfile==2.0.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# openpyxl
|
||||
exceptiongroup==1.3.0
|
||||
# via fastmcp
|
||||
fastmcp==2.10.6
|
||||
# via apache-superset
|
||||
filelock==3.12.2
|
||||
# via virtualenv
|
||||
flask==2.3.3
|
||||
@@ -327,6 +353,8 @@ gunicorn==23.0.0
|
||||
h11==0.16.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# httpcore
|
||||
# uvicorn
|
||||
# wsproto
|
||||
hashids==1.3.1
|
||||
# via
|
||||
@@ -337,6 +365,14 @@ holidays==0.25
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# prophet
|
||||
httpcore==1.0.9
|
||||
# via httpx
|
||||
httpx==0.28.1
|
||||
# via
|
||||
# fastmcp
|
||||
# mcp
|
||||
httpx-sse==0.4.1
|
||||
# via mcp
|
||||
humanize==4.12.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -346,7 +382,9 @@ identify==2.5.36
|
||||
idna==3.10
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# anyio
|
||||
# email-validator
|
||||
# httpx
|
||||
# requests
|
||||
# trio
|
||||
# url-normalize
|
||||
@@ -378,6 +416,7 @@ jsonschema==4.23.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# flask-appbuilder
|
||||
# mcp
|
||||
# openapi-schema-validator
|
||||
# openapi-spec-validator
|
||||
jsonschema-path==0.3.4
|
||||
@@ -437,6 +476,8 @@ matplotlib==3.9.0
|
||||
# via prophet
|
||||
mccabe==0.7.0
|
||||
# via pylint
|
||||
mcp==1.12.0
|
||||
# via fastmcp
|
||||
mdurl==0.1.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -475,6 +516,8 @@ odfpy==1.4.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# pandas
|
||||
openapi-pydantic==0.5.1
|
||||
# via fastmcp
|
||||
openapi-schema-validator==0.6.3
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -607,6 +650,16 @@ pycparser==2.22
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cffi
|
||||
pydantic==2.11.7
|
||||
# via
|
||||
# fastmcp
|
||||
# mcp
|
||||
# openapi-pydantic
|
||||
# pydantic-settings
|
||||
pydantic-core==2.33.2
|
||||
# via pydantic
|
||||
pydantic-settings==2.10.1
|
||||
# via mcp
|
||||
pydata-google-auth==1.9.0
|
||||
# via pandas-gbq
|
||||
pydruid==0.6.9
|
||||
@@ -642,6 +695,8 @@ pyparsing==3.2.3
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pyperclip==1.9.0
|
||||
# via fastmcp
|
||||
pysocks==1.7.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -649,8 +704,11 @@ pysocks==1.7.1
|
||||
pytest==7.4.4
|
||||
# via
|
||||
# apache-superset
|
||||
# pytest-asyncio
|
||||
# pytest-cov
|
||||
# pytest-mock
|
||||
pytest-asyncio==0.23.8
|
||||
# via apache-superset
|
||||
pytest-cov==6.0.0
|
||||
# via apache-superset
|
||||
pytest-mock==3.10.0
|
||||
@@ -674,12 +732,16 @@ python-dotenv==1.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
# fastmcp
|
||||
# pydantic-settings
|
||||
python-geohash==0.8.5
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
python-ldap==3.4.4
|
||||
# via apache-superset
|
||||
python-multipart==0.0.20
|
||||
# via mcp
|
||||
pytz==2025.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -734,7 +796,12 @@ rfc3339-validator==0.1.4
|
||||
rich==13.9.4
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# cyclopts
|
||||
# fastmcp
|
||||
# flask-limiter
|
||||
# rich-rst
|
||||
rich-rst==1.3.1
|
||||
# via cyclopts
|
||||
rpds-py==0.25.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -779,6 +846,7 @@ slack-sdk==3.35.0
|
||||
sniffio==1.3.1
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# anyio
|
||||
# trio
|
||||
sortedcontainers==2.4.0
|
||||
# via
|
||||
@@ -808,10 +876,14 @@ sqlglot==27.3.0
|
||||
# apache-superset
|
||||
sqloxide==0.1.51
|
||||
# via apache-superset
|
||||
sse-starlette==2.4.1
|
||||
# via mcp
|
||||
sshtunnel==0.4.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
starlette==0.47.2
|
||||
# via mcp
|
||||
statsd==4.0.1
|
||||
# via apache-superset
|
||||
tabulate==0.9.0
|
||||
@@ -839,13 +911,23 @@ typing-extensions==4.14.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# alembic
|
||||
# anyio
|
||||
# apache-superset
|
||||
# cattrs
|
||||
# exceptiongroup
|
||||
# limits
|
||||
# pydantic
|
||||
# pydantic-core
|
||||
# pyopenssl
|
||||
# referencing
|
||||
# selenium
|
||||
# shillelagh
|
||||
# starlette
|
||||
# typing-inspection
|
||||
typing-inspection==0.4.1
|
||||
# via
|
||||
# pydantic
|
||||
# pydantic-settings
|
||||
tzdata==2025.2
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -864,6 +946,8 @@ urllib3==2.5.0
|
||||
# requests
|
||||
# requests-cache
|
||||
# selenium
|
||||
uvicorn==0.35.0
|
||||
# via mcp
|
||||
vine==5.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -143,7 +143,6 @@ module.exports = {
|
||||
],
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'prettier'],
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
'@typescript-eslint/ban-ts-ignore': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0, // disabled temporarily
|
||||
'@typescript-eslint/ban-types': 0, // disabled temporarily
|
||||
@@ -341,20 +340,6 @@ module.exports = {
|
||||
'plugin:testing-library/react',
|
||||
],
|
||||
rules: {
|
||||
'no-console': 'off', // Allow console usage in test files
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: '@superset-ui/core',
|
||||
importNames: ['logging'],
|
||||
message:
|
||||
'Do not use logging in test files. Use console statements instead for testing.',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
@@ -388,6 +373,7 @@ module.exports = {
|
||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -411,16 +397,9 @@ module.exports = {
|
||||
'react/no-void-elements': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['scripts/**/*'],
|
||||
rules: {
|
||||
'no-console': 'off', // Allow console usage in scripts directory
|
||||
},
|
||||
},
|
||||
],
|
||||
// eslint-disable-next-line no-dupe-keys
|
||||
rules: {
|
||||
'no-console': 'error',
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': ['error', true],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
1563
superset-frontend/package-lock.json
generated
1563
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jest": "^30.0.4",
|
||||
"jest": "^30.0.2",
|
||||
"yeoman-test": "^10.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -177,7 +177,6 @@ const granularity: SharedControlConfig<'SelectControl'> = {
|
||||
'can type and use simple natural language as in `10 seconds`, ' +
|
||||
'`1 day` or `56 weeks`',
|
||||
),
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
@@ -205,7 +204,6 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
choices: (datasource as Dataset)?.time_grain_sqla || [],
|
||||
}),
|
||||
visibility: displayTimeRelatedControls,
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_range: SharedControlConfig<'DateFilterControl'> = {
|
||||
|
||||
@@ -20,7 +20,7 @@ import { useEffect, useState } from 'react';
|
||||
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
|
||||
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
|
||||
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
|
||||
import { useTheme, isThemeDark, logging } from '@superset-ui/core';
|
||||
import { useTheme, isThemeDark } from '@superset-ui/core';
|
||||
|
||||
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
|
||||
|
||||
@@ -59,7 +59,7 @@ const registerLanguage = async (language: SupportedLanguage): Promise<void> => {
|
||||
SyntaxHighlighterBase.registerLanguage(language, languageModule.default);
|
||||
registeredLanguages.add(language);
|
||||
} catch (error) {
|
||||
logging.warn(`Failed to load language ${language}:`, error);
|
||||
console.warn(`Failed to load language ${language}:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useState, useCallback } from 'react';
|
||||
import { logging, t } from '@superset-ui/core';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { Button } from '../Button';
|
||||
import { Form } from '../Form';
|
||||
import { Modal } from './Modal';
|
||||
@@ -60,7 +60,7 @@ export function FormModal({
|
||||
await formSubmitHandler(values);
|
||||
handleSave();
|
||||
} catch (err) {
|
||||
logging.error(err);
|
||||
console.error(err);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { logging } from '@superset-ui/core';
|
||||
import fetchRetry from 'fetch-retry';
|
||||
import { CallApi, Payload, JsonValue, JsonObject } from '../types';
|
||||
import {
|
||||
@@ -153,7 +152,8 @@ export default async function callApi({
|
||||
// while logging error to console for any attribute that fails the cast to String
|
||||
valueString = stringify ? JSON.stringify(value) : String(value);
|
||||
} catch (e) {
|
||||
logging.error(
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Unable to convert attribute '${key}' to a String(). '${key}' was not added to the formData in request.body for call to ${url}`,
|
||||
value,
|
||||
e,
|
||||
|
||||
@@ -16,8 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { logging } from '@superset-ui/core';
|
||||
|
||||
export enum OverwritePolicy {
|
||||
Allow = 'ALLOW',
|
||||
Prohibit = 'PROHIBIT',
|
||||
@@ -122,7 +120,8 @@ export default class Registry<
|
||||
(('value' in item && item.value !== value) || 'loader' in item);
|
||||
if (willOverwrite) {
|
||||
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
||||
logging.warn(
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Item with key "${key}" already exists. You are assigning a new value.`,
|
||||
);
|
||||
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
||||
@@ -147,7 +146,8 @@ export default class Registry<
|
||||
(('loader' in item && item.loader !== loader) || 'value' in item);
|
||||
if (willOverwrite) {
|
||||
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
||||
logging.warn(
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
`Item with key "${key}" already exists. You are assigning a new value.`,
|
||||
);
|
||||
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
||||
@@ -278,7 +278,7 @@ export default class Registry<
|
||||
listener(keys);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
logging.error('Exception thrown from a registry listener:', e);
|
||||
console.error('Exception thrown from a registry listener:', e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
COMMON_ERR_MESSAGES,
|
||||
JsonObject,
|
||||
SupersetClientResponse,
|
||||
logging,
|
||||
t,
|
||||
SupersetError,
|
||||
ErrorTypeEnum,
|
||||
@@ -257,7 +256,8 @@ export function getClientErrorObject(
|
||||
// fall back to Response.statusText or generic error of we cannot read the response
|
||||
let error = (response as any).statusText || (response as any).message;
|
||||
if (!error) {
|
||||
logging.error('non-standard error:', response);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('non-standard error:', response);
|
||||
error = t('An error occurred');
|
||||
}
|
||||
resolve({
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import UntypedJed from 'jed';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import logging from '../utils/logging';
|
||||
import {
|
||||
Jed,
|
||||
TranslatorConfig,
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { logging } from '@superset-ui/core';
|
||||
/* eslint no-console: 0 */
|
||||
|
||||
import Translator from './Translator';
|
||||
import { TranslatorConfig, Translations, LocaleData } from './types';
|
||||
|
||||
@@ -33,7 +34,7 @@ function configure(config?: TranslatorConfig) {
|
||||
|
||||
function getInstance() {
|
||||
if (!isConfigured) {
|
||||
logging.warn('You should call configure(...) before calling other methods');
|
||||
console.warn('You should call configure(...) before calling other methods');
|
||||
}
|
||||
|
||||
if (typeof singleton === 'undefined') {
|
||||
|
||||
@@ -30,8 +30,5 @@
|
||||
"homepage": "https://github.com/apache/superset#readme",
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@superset-ui/core": "^0.20.4"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { logging } from '@superset-ui/core';
|
||||
|
||||
export type Params = {
|
||||
port: MessagePort;
|
||||
name?: string;
|
||||
@@ -264,12 +262,12 @@ export class Switchboard {
|
||||
|
||||
private log(...args: unknown[]) {
|
||||
if (this.debugMode) {
|
||||
logging.debug(`[${this.name}]`, ...args);
|
||||
console.debug(`[${this.name}]`, ...args);
|
||||
}
|
||||
}
|
||||
|
||||
private logError(...args: unknown[]) {
|
||||
logging.error(`[${this.name}]`, ...args);
|
||||
console.error(`[${this.name}]`, ...args);
|
||||
}
|
||||
|
||||
private getNewMessageId() {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"declarationDir": "lib",
|
||||
"outDir": "lib"
|
||||
"outDir": "lib",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"exclude": [
|
||||
"lib",
|
||||
@@ -13,7 +14,5 @@
|
||||
"types/**/*",
|
||||
"../../types/**/*"
|
||||
],
|
||||
"references": [
|
||||
{ "path": "../superset-ui-core" },
|
||||
]
|
||||
"references": []
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
isDefined,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
logging,
|
||||
QueryFormData,
|
||||
QueryObjectFilterClause,
|
||||
SupersetClient,
|
||||
@@ -255,7 +254,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
}));
|
||||
})
|
||||
.catch(error => {
|
||||
logging.error(
|
||||
console.error(
|
||||
`Error loading layer for slice ${subsliceCopy.slice_id}:`,
|
||||
error,
|
||||
);
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useState } from 'react';
|
||||
import { Dropdown } from 'antd';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
||||
@@ -69,42 +69,34 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
overlay={
|
||||
<Menu
|
||||
multiple
|
||||
onClick={handleOnClick}
|
||||
onBlur={handleOnBlur}
|
||||
selectedKeys={selectedComparisonColumns}
|
||||
>
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</InfoText>
|
||||
{comparisonColumns.map((column: ComparisonColumn) => (
|
||||
<Menu.Item key={column.key}>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</InfoText>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map((column: ComparisonColumn) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
</CheckIconWrapper>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -589,7 +589,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show Cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -617,7 +617,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -631,7 +631,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'comparison_color_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Basic conditional formatting'),
|
||||
label: t('basic conditional formatting'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!isEmpty(controls?.time_compare?.value),
|
||||
@@ -672,7 +672,7 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'ConditionalFormattingControl',
|
||||
renderTrigger: true,
|
||||
label: t('Custom conditional formatting'),
|
||||
label: t('Custom Conditional Formatting'),
|
||||
extraColorChoices: [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
|
||||
@@ -25,7 +25,6 @@ import BaseEvent from 'ol/events/Event';
|
||||
import { unByKey } from 'ol/Observable';
|
||||
import { toLonLat } from 'ol/proj';
|
||||
import { debounce } from 'lodash';
|
||||
import { logging } from '@superset-ui/core';
|
||||
import { fitMapToCharts } from '../util/mapUtil';
|
||||
import { ChartLayer } from './ChartLayer';
|
||||
import { createLayer } from '../util/layerUtil';
|
||||
@@ -189,7 +188,7 @@ export const OlChartMap = (props: OlChartMapProps) => {
|
||||
if (createdLayer.status === 'fulfilled' && createdLayer.value) {
|
||||
olMap.getLayers().insertAt(0, createdLayer.value);
|
||||
} else {
|
||||
logging.warn(`Layer could not be created: ${configs[idx]}`);
|
||||
console.warn(`Layer could not be created: ${configs[idx]}`);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
* Util for layer related operations.
|
||||
*/
|
||||
|
||||
import { logging } from '@superset-ui/core';
|
||||
import OlParser from 'geostyler-openlayers-parser';
|
||||
import TileLayer from 'ol/layer/Tile';
|
||||
import TileWMS from 'ol/source/TileWMS';
|
||||
@@ -127,7 +126,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
|
||||
const olParser = new OlParser();
|
||||
writeStyleResult = await olParser.writeStyle(style);
|
||||
if (writeStyleResult.errors) {
|
||||
logging.warn('Could not create ol-style', writeStyleResult.errors);
|
||||
console.warn('Could not create ol-style', writeStyleResult.errors);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -155,7 +154,7 @@ export const createLayer = async (layerConf: LayerConf) => {
|
||||
} else if (isXyzLayerConf(layerConf)) {
|
||||
layer = createXyzLayer(layerConf);
|
||||
} else {
|
||||
logging.warn('Provided layerconfig is not recognized');
|
||||
console.warn('Provided layerconfig is not recognized');
|
||||
}
|
||||
return layer;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
ChartProps,
|
||||
convertKeysToCamelCase,
|
||||
DataRecord,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import { isObject } from 'lodash';
|
||||
import {
|
||||
@@ -90,7 +89,7 @@ export const groupByLocationGenericX = (
|
||||
const labelMap: string[] = queryData.label_map?.[k];
|
||||
|
||||
if (!labelMap) {
|
||||
logging.debug(
|
||||
console.log(
|
||||
'Cannot extract location from queryData. label_map not defined',
|
||||
);
|
||||
return;
|
||||
@@ -100,7 +99,7 @@ export const groupByLocationGenericX = (
|
||||
|
||||
if (geojsonCols.length > 1) {
|
||||
// TODO what should we do, if there is more than one geom column?
|
||||
logging.debug(
|
||||
console.log(
|
||||
'More than one geometry column detected. Using first found.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -358,22 +358,7 @@ const config: ControlPanelConfig = {
|
||||
['x_axis_time_format'],
|
||||
[xAxisLabelRotation],
|
||||
[xAxisLabelInterval],
|
||||
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||
[
|
||||
{
|
||||
name: 'show_query_identifiers',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show query identifiers'),
|
||||
description: t(
|
||||
'Add Query A and Query B identifiers to tooltips to help differentiate series',
|
||||
),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
|
||||
...richTooltipSection,
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -212,7 +212,6 @@ export default function transformProps(
|
||||
sortSeriesAscendingB,
|
||||
timeGrainSqla,
|
||||
percentageThreshold,
|
||||
showQueryIdentifiers = false,
|
||||
metrics = [],
|
||||
metricsB = [],
|
||||
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
@@ -396,17 +395,10 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
let displayName: string;
|
||||
let displayName = `${entryName} (Query A)`;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameA} (Query A)`
|
||||
: MetricDisplayNameA;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -461,17 +453,10 @@ export default function transformProps(
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName: string;
|
||||
let displayName = `${entryName} (Query B)`;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameB} (Query B)`
|
||||
: MetricDisplayNameB;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -711,13 +696,14 @@ export default function transformProps(
|
||||
zoomable,
|
||||
),
|
||||
// @ts-ignore
|
||||
data: series
|
||||
data: rawSeriesA
|
||||
.concat(rawSeriesB)
|
||||
.filter(
|
||||
entry =>
|
||||
extractForecastSeriesContext((entry.name || '') as string).type ===
|
||||
ForecastSeriesEnum.Observation,
|
||||
)
|
||||
.map(entry => entry.id || entry.name || '')
|
||||
.map(entry => entry.name || '')
|
||||
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
|
||||
@@ -60,7 +60,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval?: number | string;
|
||||
colorScheme?: string;
|
||||
@@ -134,7 +133,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
groupbyB: [],
|
||||
zoomable: TIMESERIES_DEFAULTS.zoomable,
|
||||
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
|
||||
showQueryIdentifiers: false,
|
||||
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
|
||||
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
|
||||
...DEFAULT_TITLE_FORM_DATA,
|
||||
|
||||
@@ -95,27 +95,27 @@ function getTotalValuePadding({
|
||||
top: donut ? 'middle' : '0',
|
||||
left: 'center',
|
||||
};
|
||||
const LEGEND_HEIGHT = 15;
|
||||
const LEGEND_WIDTH = 215;
|
||||
if (chartPadding.top) {
|
||||
padding.top = donut
|
||||
? `${50 + (chartPadding.top / height / 2) * 100}%`
|
||||
: `${(chartPadding.top / height) * 100}%`;
|
||||
? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
: `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`;
|
||||
}
|
||||
if (chartPadding.bottom) {
|
||||
padding.top = donut
|
||||
? `${50 - (chartPadding.bottom / height / 2) * 100}%`
|
||||
? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
: '0';
|
||||
}
|
||||
if (chartPadding.left) {
|
||||
// When legend is on the left, shift text right to center it in the available space
|
||||
const leftPaddingPercent = (chartPadding.left / width) * 100;
|
||||
const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
padding.left = `${
|
||||
50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
}
|
||||
if (chartPadding.right) {
|
||||
// When legend is on the right, shift text left to center it in the available space
|
||||
const rightPaddingPercent = (chartPadding.right / width) * 100;
|
||||
const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
padding.left = `${
|
||||
50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
}
|
||||
return padding;
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export default function transformProps(
|
||||
name: otherName,
|
||||
value: otherSum,
|
||||
itemStyle: {
|
||||
color: theme.colorText,
|
||||
color: theme.colors.grayscale.dark1,
|
||||
opacity:
|
||||
filterState.selectedValues &&
|
||||
!filterState.selectedValues.includes(otherName)
|
||||
@@ -368,7 +368,7 @@ export default function transformProps(
|
||||
const defaultLabel = {
|
||||
formatter,
|
||||
show: showLabels,
|
||||
color: theme.colorText,
|
||||
color: theme.colors.grayscale.dark2,
|
||||
};
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
@@ -403,7 +403,7 @@ export default function transformProps(
|
||||
label: {
|
||||
show: true,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
backgroundColor: theme.colors.grayscale.light5,
|
||||
},
|
||||
},
|
||||
data: transformedData,
|
||||
@@ -445,7 +445,6 @@ export default function transformProps(
|
||||
text: t('Total: %s', numberFormatter(totalValue)),
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: theme.colorText,
|
||||
},
|
||||
z: 10,
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
width,
|
||||
echartOptions,
|
||||
setDataMask,
|
||||
labelMap,
|
||||
selectedValues,
|
||||
formData,
|
||||
onContextMenu,
|
||||
@@ -51,47 +52,45 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(treePathInfo: TreePathInfo[]) => {
|
||||
const treePath = extractTreePathInfo(treePathInfo);
|
||||
const joinedTreePath = treePath.join(',');
|
||||
const value = treePath[treePath.length - 1];
|
||||
|
||||
const isCurrentValueSelected =
|
||||
Object.values(selectedValues).includes(joinedTreePath);
|
||||
|
||||
if (!columns?.length || isCurrentValueSelected) {
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
value: null,
|
||||
selectedValues: [],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
const name = treePath.join(',');
|
||||
const selected = Object.values(selectedValues);
|
||||
let values: string[];
|
||||
if (selected.includes(name)) {
|
||||
values = selected.filter(v => v !== name);
|
||||
} else {
|
||||
values = [name];
|
||||
}
|
||||
const labels = values.map(value => labelMap[value]);
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [
|
||||
{
|
||||
col: columns[treePath.length - 1],
|
||||
op: '==' as const,
|
||||
val: value,
|
||||
},
|
||||
],
|
||||
filters:
|
||||
values.length === 0 || !columns
|
||||
? []
|
||||
: columns.slice(0, treePath.length).map((col, idx) => {
|
||||
const val = labels.map(v => v[idx]);
|
||||
if (val === null || val === undefined)
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IN' as const,
|
||||
val: val as (string | number | boolean)[],
|
||||
};
|
||||
}),
|
||||
},
|
||||
filterState: {
|
||||
value,
|
||||
selectedValues: [joinedTreePath],
|
||||
value: labels.length ? labels : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
isCurrentValueSelected: selected.includes(name),
|
||||
};
|
||||
},
|
||||
[columns, selectedValues],
|
||||
[columns, labelMap, selectedValues],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -102,7 +101,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
|
||||
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
||||
},
|
||||
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
|
||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
|
||||
@@ -71,7 +71,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
truncateXAxis: true,
|
||||
truncateYAxis: false,
|
||||
yAxisBounds: [null, null],
|
||||
|
||||
@@ -31,7 +31,7 @@ import { merge } from 'lodash';
|
||||
|
||||
import { useSelector } from 'react-redux';
|
||||
|
||||
import { styled, useTheme, logging } from '@superset-ui/core';
|
||||
import { styled, useTheme } from '@superset-ui/core';
|
||||
import { use, init, EChartsType, registerLocale } from 'echarts/core';
|
||||
import {
|
||||
SankeyChart,
|
||||
@@ -117,7 +117,7 @@ const loadLocale = async (locale: string) => {
|
||||
try {
|
||||
lang = await import(`echarts/lib/i18n/lang${locale}`);
|
||||
} catch (e) {
|
||||
logging.error(`Locale ${locale} not supported in ECharts`, e);
|
||||
console.error(`Locale ${locale} not supported in ECharts`, e);
|
||||
}
|
||||
return lang?.default;
|
||||
};
|
||||
|
||||
@@ -116,48 +116,49 @@ const chartPropsConfig = {
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: false,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
// Check that series IDs don't include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
expect(transformed).toEqual(
|
||||
expect.objectContaining({
|
||||
echartOptions: expect.objectContaining({
|
||||
series: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query A), boy',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query A), girl',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
// Query B — Bar series
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query B), boy',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query B), girl',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num, girl');
|
||||
expect(seriesIds).toContain('sum__num, boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), boy');
|
||||
});
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||
const chartPropsConfigWithIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
// Check that series IDs include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query B), boy');
|
||||
expect(seriesIds).not.toContain('sum__num, girl');
|
||||
expect(seriesIds).not.toContain('sum__num, boy');
|
||||
});
|
||||
|
||||
@@ -221,157 +221,6 @@ describe('Pie label string template', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Total value positioning with legends', () => {
|
||||
const getChartPropsWithLegend = (
|
||||
showTotal = true,
|
||||
showLegend = true,
|
||||
legendOrientation = 'right',
|
||||
donut = true,
|
||||
): EchartsPieChartProps => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['category'],
|
||||
viz_type: 'pie',
|
||||
show_total: showTotal,
|
||||
show_legend: showLegend,
|
||||
legend_orientation: legendOrientation,
|
||||
donut,
|
||||
};
|
||||
|
||||
return new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
|
||||
{ category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
}) as EchartsPieChartProps;
|
||||
};
|
||||
|
||||
it('should center total text when legend is on the right', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
style: expect.objectContaining({
|
||||
text: expect.stringContaining('Total:'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be less than 50% (shifted left)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeLessThan(50);
|
||||
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on the left', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be greater than 50% (shifted right)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeGreaterThan(50);
|
||||
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on top', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for top legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||
});
|
||||
|
||||
it('should center total text when legend is on bottom', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for bottom legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||
});
|
||||
|
||||
it('should use default positioning when no legend is shown', () => {
|
||||
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle regular pie chart (non-donut) positioning', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
top: '0', // Non-donut charts use '0' as default top position
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show total graphic when showTotal is false', () => {
|
||||
const props = getChartPropsWithLegend(false, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other category', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel';
|
||||
|
||||
describe('Bar Chart Control Panel', () => {
|
||||
describe('x_axis_time_format control', () => {
|
||||
it('should include x_axis_time_format control in the panel', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Look for x_axis_time_format control in all sections and rows
|
||||
let foundTimeFormatControl = false;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
foundTimeFormatControl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundTimeFormatControl).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct default value for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toBeDefined();
|
||||
expect(timeFormatControl.config.default).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have visibility function for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config.visibility).toBeDefined();
|
||||
expect(typeof timeFormatControl.config.visibility).toBe('function');
|
||||
|
||||
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||
// The important part is that the control has proper visibility configuration
|
||||
});
|
||||
|
||||
it('should have proper control configuration', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toMatchObject({
|
||||
default: 'smart_date',
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
});
|
||||
|
||||
// Should have a description that includes D3 time format docs
|
||||
expect(timeFormatControl.config.description).toContain('D3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control panel structure for bar charts', () => {
|
||||
it('should have Chart Orientation section', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const orientationSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Orientation',
|
||||
);
|
||||
|
||||
expect(orientationSection).toBeDefined();
|
||||
expect(orientationSection!.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it('should have Chart Options section with X Axis controls', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const chartOptionsSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Options',
|
||||
);
|
||||
|
||||
expect(chartOptionsSection).toBeDefined();
|
||||
expect(chartOptionsSection!.expanded).toBe(true);
|
||||
|
||||
// Should contain X Axis subsection header - this is sufficient proof
|
||||
expect(chartOptionsSection!.controlSetRows).toBeDefined();
|
||||
expect(chartOptionsSection!.controlSetRows!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper form data overrides', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
expect(config.formDataOverrides).toBeDefined();
|
||||
expect(typeof config.formDataOverrides).toBe('function');
|
||||
|
||||
// Test the form data override function
|
||||
const mockFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
metrics: ['test_metric'],
|
||||
groupby: ['test_column'],
|
||||
other_field: 'test',
|
||||
};
|
||||
|
||||
const result = config.formDataOverrides!(mockFormData);
|
||||
|
||||
expect(result).toHaveProperty('metrics');
|
||||
expect(result).toHaveProperty('groupby');
|
||||
expect(result).toHaveProperty('other_field', 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,353 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, SqlaFormData, supersetTheme } from '@superset-ui/core';
|
||||
import { EchartsTimeseriesChartProps } from '../../../src/types';
|
||||
import transformProps from '../../../src/Timeseries/transformProps';
|
||||
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||
import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
|
||||
|
||||
describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: SqlaFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: '__timestamp',
|
||||
metric: ['Sales', 'Marketing', 'Operations'],
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
orientation: 'vertical',
|
||||
};
|
||||
|
||||
const timeseriesData = [
|
||||
{
|
||||
data: [
|
||||
{ Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01
|
||||
{ Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01
|
||||
{ Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01
|
||||
],
|
||||
colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'],
|
||||
coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: timeseriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('Default xAxisTimeFormat', () => {
|
||||
it('should use smart_date as default xAxisTimeFormat', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Check that the x-axis has a formatter applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => {
|
||||
const formDataWithoutTimeFormat = {
|
||||
...baseFormData,
|
||||
};
|
||||
delete formDataWithoutTimeFormat.xAxisTimeFormat;
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithoutTimeFormat,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom xAxisTimeFormat', () => {
|
||||
it('should respect custom xAxisTimeFormat when explicitly set', () => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify the formatter function exists and is applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
|
||||
// The key test is that a formatter exists - the actual formatting is handled by d3-time-format
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle different time format options', () => {
|
||||
const timeFormats = [
|
||||
'%Y-%m-%d',
|
||||
'%Y/%m/%d',
|
||||
'%m/%d/%Y',
|
||||
'%b %d, %Y',
|
||||
'smart_date',
|
||||
];
|
||||
|
||||
timeFormats.forEach(timeFormat => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: timeFormat,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Orientation-specific behavior', () => {
|
||||
it('should apply time formatting to x-axis in vertical bar charts', () => {
|
||||
const verticalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'vertical',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: verticalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In vertical orientation, time should be on x-axis
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply time formatting to y-axis in horizontal bar charts', () => {
|
||||
const horizontalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'horizontal',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: horizontalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In horizontal orientation, axes are swapped, so time should be on y-axis
|
||||
const yAxis = transformedProps.echartOptions.yAxis as any;
|
||||
expect(yAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof yAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with existing features', () => {
|
||||
it('should work with axis bounds', () => {
|
||||
const formDataWithBounds = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
truncateXAxis: true,
|
||||
xAxisBounds: [null, null] as [number | null, number | null],
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithBounds,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
// The xAxis should be configured with the time formatting
|
||||
expect(transformedProps.echartOptions.xAxis).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with label rotation', () => {
|
||||
const formDataWithRotation = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
xAxisLabelRotation: 45,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithRotation,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(xAxis.axisLabel).toHaveProperty('rotate', 45);
|
||||
});
|
||||
|
||||
it('should maintain time formatting consistency with tooltip', () => {
|
||||
const formDataWithTooltip = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
tooltipTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithTooltip,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both axis and tooltip should have formatters
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(transformedProps.xValueFormatter).toBeDefined();
|
||||
expect(typeof transformedProps.xValueFormatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression test for Issue #30373', () => {
|
||||
it('should not be stuck on adaptive formatting', () => {
|
||||
// Test the exact scenario described in the issue
|
||||
const issueFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: issueFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
|
||||
// The important part is that the xAxisTimeFormat is being used from formData
|
||||
// The actual formatting is handled by the underlying time formatter
|
||||
});
|
||||
|
||||
it('should allow changing from smart_date to other formats', () => {
|
||||
// First create with smart_date (default)
|
||||
const smartDateFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
};
|
||||
|
||||
const smartDateChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: smartDateFormData,
|
||||
});
|
||||
|
||||
const smartDateProps = transformProps(
|
||||
smartDateChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Then change to a different format
|
||||
const customFormatFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%b %Y',
|
||||
};
|
||||
|
||||
const customFormatChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormatFormData,
|
||||
});
|
||||
|
||||
const customFormatProps = transformProps(
|
||||
customFormatChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both should have formatters - the key is that they're not undefined
|
||||
const smartDateXAxis = smartDateProps.echartOptions.xAxis as any;
|
||||
const customFormatXAxis = customFormatProps.echartOptions.xAxis as any;
|
||||
|
||||
expect(smartDateXAxis.axisLabel.formatter).toBeDefined();
|
||||
expect(customFormatXAxis.axisLabel.formatter).toBeDefined();
|
||||
|
||||
// Both should be functions that can format time
|
||||
expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function');
|
||||
expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should have xAxisTimeFormat in formData by default', () => {
|
||||
// This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBeDefined();
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
|
||||
|
||||
describe('Timeseries constants', () => {
|
||||
describe('DEFAULT_FORM_DATA', () => {
|
||||
it('should include xAxisTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('xAxisTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should include tooltipTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('tooltipTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.tooltipTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have consistent time format defaults', () => {
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe(
|
||||
DEFAULT_FORM_DATA.tooltipTimeFormat,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have vertical orientation as default', () => {
|
||||
expect(DEFAULT_FORM_DATA.orientation).toBe('vertical');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
Space,
|
||||
RawAntdSelect as Select,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Tooltip,
|
||||
} from '@superset-ui/core/components';
|
||||
import {
|
||||
@@ -563,62 +564,52 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<div
|
||||
overlay={
|
||||
<Menu
|
||||
multiple
|
||||
onClick={handleOnClick}
|
||||
onBlur={handleOnBlur}
|
||||
selectedKeys={selectedComparisonColumns}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
max-width: 242px;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorText};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</div>
|
||||
{comparisonColumns.map(column => (
|
||||
<Menu.Item key={column.key}>
|
||||
<span
|
||||
css={css`
|
||||
max-width: 242px;
|
||||
padding: 0 ${theme.sizeUnit * 2}px;
|
||||
color: ${theme.colorText};
|
||||
`}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
<span
|
||||
css={css`
|
||||
float: right;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map(
|
||||
(column: { key: string; label: string }) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<span
|
||||
css={css`
|
||||
color: ${theme.colorText};
|
||||
`}
|
||||
>
|
||||
{column.label}
|
||||
</span>
|
||||
<span
|
||||
css={css`
|
||||
float: right;
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
`}
|
||||
>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</span>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
),
|
||||
},
|
||||
],
|
||||
}}
|
||||
</span>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -646,7 +646,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars'),
|
||||
label: t('Show Cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -674,7 +674,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
label: t('add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -688,7 +688,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'comparison_color_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Basic conditional formatting'),
|
||||
label: t('basic conditional formatting'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!isEmpty(controls?.time_compare?.value),
|
||||
@@ -729,7 +729,7 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'ConditionalFormattingControl',
|
||||
renderTrigger: true,
|
||||
label: t('Custom conditional formatting'),
|
||||
label: t('Custom Conditional Formatting'),
|
||||
extraColorChoices: [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
|
||||
@@ -22,13 +22,12 @@ import React from 'react';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { configure as configureTestingLibrary } from '@testing-library/react';
|
||||
import { matchers } from '@emotion/jest';
|
||||
import { DEFAULT_BOOTSTRAP_DATA } from 'src/constants';
|
||||
|
||||
configureTestingLibrary({
|
||||
testIdAttribute: 'data-test',
|
||||
});
|
||||
|
||||
document.body.innerHTML = `<div id="app" data-bootstrap="${JSON.stringify(DEFAULT_BOOTSTRAP_DATA).replace(/"/g, '"')}"></div>`;
|
||||
document.body.innerHTML = '<div id="app" data-bootstrap=""></div>';
|
||||
expect.extend(matchers);
|
||||
|
||||
// Allow JSX tests to have React import readily available
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
isFeatureEnabled,
|
||||
COMMON_ERR_MESSAGES,
|
||||
getClientErrorObject,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import { invert, mapKeys } from 'lodash';
|
||||
|
||||
@@ -870,7 +869,8 @@ export function updateSavedQuery(query, clientId) {
|
||||
})
|
||||
.catch(e => {
|
||||
const message = t('Your query could not be updated');
|
||||
logging.error(message, e);
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(message, e);
|
||||
dispatch(addDangerToast(message));
|
||||
})
|
||||
.then(() => dispatch(updateQueryEditor(query)));
|
||||
|
||||
@@ -63,7 +63,7 @@ export enum ContextMenuItem {
|
||||
export interface ChartContextMenuProps {
|
||||
id: number;
|
||||
formData: QueryFormData;
|
||||
onSelection: (args?: any) => void;
|
||||
onSelection: () => void;
|
||||
onClose: () => void;
|
||||
additionalConfig?: {
|
||||
crossFilter?: Record<string, any>;
|
||||
@@ -123,12 +123,6 @@ const ChartContextMenu = (
|
||||
const [dataset, setDataset] = useState<Dataset>();
|
||||
const verboseMap = useVerboseMap(dataset);
|
||||
|
||||
const closeContextMenu = useCallback(() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
|
||||
setDrillByColumn(column);
|
||||
setDataset(dataset); // Save dataset when drilling
|
||||
@@ -270,7 +264,6 @@ const ChartContextMenu = (
|
||||
<DrillByMenuItems
|
||||
drillByConfig={filters?.drillBy}
|
||||
onSelection={onSelection}
|
||||
onCloseMenu={closeContextMenu}
|
||||
formData={formData}
|
||||
contextMenuY={clientY}
|
||||
submenuIndex={submenuIndex}
|
||||
@@ -318,7 +311,6 @@ const ChartContextMenu = (
|
||||
onOpenChange={setOpenKeys}
|
||||
onClick={() => {
|
||||
setVisible(false);
|
||||
setOpenKeys([]);
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -166,12 +166,8 @@ test('render menu item with submenu without searchbox', async () => {
|
||||
renderMenu({});
|
||||
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Check that each column appears in the drill-by submenu
|
||||
slicedColumns.forEach(column => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0]; // Use the first submenu
|
||||
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -190,19 +186,15 @@ test('render menu item with submenu and searchbox', async () => {
|
||||
// Wait for all columns to be visible
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const searchbox = await waitFor(
|
||||
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||
() => screen.getAllByPlaceholderText('Search columns')[1],
|
||||
);
|
||||
expect(searchbox).toBeInTheDocument();
|
||||
|
||||
@@ -212,26 +204,19 @@ test('render menu item with submenu and searchbox', async () => {
|
||||
|
||||
// Wait for filtered results
|
||||
await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
|
||||
defaultColumns
|
||||
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
||||
.forEach(col => {
|
||||
expect(
|
||||
within(submenu).queryByText(col.column_name),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
expectedFilteredColumnNames.forEach(colName => {
|
||||
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -253,23 +238,17 @@ test('Do not display excluded column in the menu', async () => {
|
||||
// Wait for menu items to be loaded
|
||||
await waitFor(
|
||||
() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
defaultColumns
|
||||
.filter(column => !excludedColNames.includes(column.column_name))
|
||||
.forEach(column => {
|
||||
expect(
|
||||
within(submenu).getByText(column.column_name),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
||||
});
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
excludedColNames.forEach(colName => {
|
||||
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -290,11 +269,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
||||
await expectDrillByEnabled();
|
||||
|
||||
// Wait for col1 to be visible before clicking
|
||||
const col1Element = await waitFor(() => {
|
||||
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||
const submenu = submenus[0];
|
||||
return within(submenu).getByText('col1');
|
||||
});
|
||||
const col1Element = await waitFor(() => screen.getByText('col1'));
|
||||
userEvent.click(col1Element);
|
||||
|
||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||
|
||||
@@ -54,7 +54,7 @@ import {
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
@@ -68,7 +68,6 @@ export interface DrillByMenuItemsProps {
|
||||
submenuIndex?: number;
|
||||
onSelection?: (...args: any) => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
onCloseMenu?: () => void;
|
||||
openNewModal?: boolean;
|
||||
excludedColumns?: Column[];
|
||||
open: boolean;
|
||||
@@ -101,7 +100,6 @@ export const DrillByMenuItems = ({
|
||||
submenuIndex = 0,
|
||||
onSelection = () => {},
|
||||
onClick = () => {},
|
||||
onCloseMenu = () => {},
|
||||
excludedColumns,
|
||||
openNewModal = true,
|
||||
open,
|
||||
@@ -126,7 +124,6 @@ export const DrillByMenuItems = ({
|
||||
if (openNewModal && onDrillBy && dataset) {
|
||||
onDrillBy(column, dataset);
|
||||
}
|
||||
onCloseMenu();
|
||||
},
|
||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||
);
|
||||
@@ -267,14 +264,15 @@ export const DrillByMenuItems = ({
|
||||
const { columns, ...rest } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
<MenuItemWithTruncation
|
||||
menuKey={`drill-by-item-${column.column_name}`}
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
{...rest}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
</MenuItemWithTruncation>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -18,4 +18,3 @@
|
||||
*/
|
||||
|
||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
Dispatch,
|
||||
ReactNode,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
Behavior,
|
||||
BinaryQueryObjectFilterClause,
|
||||
css,
|
||||
extractQueryFields,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
removeHTMLTags,
|
||||
styled,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { getSubmenuYOffset } from '../utils';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||
|
||||
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||
const DISABLED_REASONS = {
|
||||
DATABASE: t(
|
||||
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||
),
|
||||
NO_AGGREGATIONS: t(
|
||||
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||
),
|
||||
NO_FILTERS: t(
|
||||
'Right-click on a dimension value to drill to detail by that value.',
|
||||
),
|
||||
NOT_SUPPORTED: t(
|
||||
'Drill to detail by value is not yet supported for this chart type.',
|
||||
),
|
||||
};
|
||||
|
||||
function getDisabledMenuItem(
|
||||
children: ReactNode,
|
||||
menuKey: string,
|
||||
...rest: unknown[]
|
||||
): MenuItem {
|
||||
return {
|
||||
disabled: true,
|
||||
key: menuKey,
|
||||
label: (
|
||||
<div
|
||||
css={css`
|
||||
white-space: normal;
|
||||
max-width: 160px;
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
...rest,
|
||||
};
|
||||
}
|
||||
|
||||
const Filter = ({
|
||||
children,
|
||||
stripHTML = false,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
stripHTML: boolean;
|
||||
}) => {
|
||||
const content =
|
||||
stripHTML && typeof children === 'string'
|
||||
? removeHTMLTags(children)
|
||||
: children;
|
||||
return <span>{content}</span>;
|
||||
};
|
||||
|
||||
const StyledFilter = styled(Filter)`
|
||||
${({ theme }) => `
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
color: ${theme.colorPrimary};
|
||||
`}
|
||||
`;
|
||||
|
||||
export type DrillDetailMenuItemsArgs = {
|
||||
formData: QueryFormData;
|
||||
filters?: BinaryQueryObjectFilterClause[];
|
||||
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||
isContextMenu?: boolean;
|
||||
contextMenuY?: number;
|
||||
onSelection?: () => void;
|
||||
onClick?: (event: MouseEvent) => void;
|
||||
submenuIndex?: number;
|
||||
setShowModal: (show: boolean) => void;
|
||||
key?: string;
|
||||
forceSubmenuRender?: boolean;
|
||||
};
|
||||
|
||||
export const useDrillDetailMenuItems = ({
|
||||
formData,
|
||||
filters = [],
|
||||
isContextMenu = false,
|
||||
contextMenuY = 0,
|
||||
onSelection = () => null,
|
||||
onClick = () => null,
|
||||
submenuIndex = 0,
|
||||
setFilters,
|
||||
setShowModal,
|
||||
key,
|
||||
...props
|
||||
}: DrillDetailMenuItemsArgs) => {
|
||||
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||
({ datasources }) =>
|
||||
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||
);
|
||||
|
||||
const openModal = useCallback(
|
||||
(filters, event) => {
|
||||
onClick(event);
|
||||
onSelection();
|
||||
setFilters(filters);
|
||||
setShowModal(true);
|
||||
},
|
||||
[onClick, onSelection],
|
||||
);
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||
// event for dimensions. If it doesn't, tell the user that drill to detail by
|
||||
// dimension is not supported. If it does, and the `contextmenu` handler didn't
|
||||
// pass any filters, tell the user that they didn't select a dimension.
|
||||
const handlesDimensionContextMenu = useMemo(
|
||||
() =>
|
||||
getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
|
||||
[formData.viz_type],
|
||||
);
|
||||
|
||||
// Check metrics to see if chart's current configuration lacks
|
||||
// aggregations, in which case Drill to Detail should be disabled.
|
||||
const noAggregations = useMemo(() => {
|
||||
const { metrics } = extractQueryFields(formData);
|
||||
return isEmpty(metrics);
|
||||
}, [formData]);
|
||||
|
||||
// Ensure submenu doesn't appear offscreen
|
||||
const submenuYOffset = useMemo(
|
||||
() =>
|
||||
getSubmenuYOffset(
|
||||
contextMenuY,
|
||||
filters.length > 1 ? filters.length + 1 : filters.length,
|
||||
submenuIndex,
|
||||
),
|
||||
[contextMenuY, filters.length, submenuIndex],
|
||||
);
|
||||
|
||||
let drillDisabled;
|
||||
let drillByDisabled;
|
||||
if (drillToDetailDisabled) {
|
||||
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||
} else if (handlesDimensionContextMenu) {
|
||||
if (noAggregations) {
|
||||
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||
} else if (!filters?.length) {
|
||||
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
|
||||
}
|
||||
} else {
|
||||
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||
}
|
||||
|
||||
const drillToDetailMenuItem: MenuItem = drillDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL}
|
||||
<MenuItemTooltip title={drillDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: 'drill-to-detail',
|
||||
label: DRILL_TO_DETAIL,
|
||||
onClick: openModal.bind(null, []),
|
||||
...props,
|
||||
};
|
||||
|
||||
const getMenuItemWithTruncation = useMenuItemWithTruncation();
|
||||
|
||||
const drillToDetailByMenuItem: MenuItem = drillByDisabled
|
||||
? getDisabledMenuItem(
|
||||
<>
|
||||
{DRILL_TO_DETAIL_BY}
|
||||
<MenuItemTooltip title={drillByDisabled} />
|
||||
</>,
|
||||
'drill-to-detail-by-disabled',
|
||||
props,
|
||||
)
|
||||
: {
|
||||
key: key || 'drill-to-detail-by',
|
||||
label: DRILL_TO_DETAIL_BY,
|
||||
children: [
|
||||
...filters.map((filter, i) => ({
|
||||
key: `drill-detail-filter-${i}`,
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
|
||||
onClick: openModal.bind(null, [filter]),
|
||||
key: `drill-detail-filter-${i}`,
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
})),
|
||||
filters.length > 1 && {
|
||||
key: 'drill-detail-filter-all',
|
||||
label: getMenuItemWithTruncation({
|
||||
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
|
||||
onClick: openModal.bind(null, filters),
|
||||
key: 'drill-detail-filter-all',
|
||||
children: (
|
||||
<>
|
||||
{`${DRILL_TO_DETAIL_BY} `}
|
||||
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||
</>
|
||||
),
|
||||
}),
|
||||
},
|
||||
].filter(Boolean) as MenuItem[],
|
||||
onClick: openModal.bind(null, filters),
|
||||
forceSubmenuRender: true,
|
||||
popupOffset: [0, submenuYOffset],
|
||||
popupClassName: 'chart-context-submenu',
|
||||
...props,
|
||||
};
|
||||
if (isContextMenu) {
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
drillToDetailByMenuItem,
|
||||
};
|
||||
}
|
||||
return {
|
||||
drillToDetailMenuItem,
|
||||
};
|
||||
};
|
||||
@@ -18,14 +18,9 @@
|
||||
*/
|
||||
|
||||
import { ReactNode, CSSProperties, useCallback } from 'react';
|
||||
import {
|
||||
css,
|
||||
truncationCSS,
|
||||
useCSSTextTruncation,
|
||||
useTheme,
|
||||
} from '@superset-ui/core';
|
||||
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
|
||||
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
|
||||
import { Flex, Tooltip } from '@superset-ui/core/components';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
import { MenuItemProps } from 'antd';
|
||||
|
||||
export type MenuItemWithTruncationProps = {
|
||||
@@ -118,12 +113,7 @@ export const MenuItemWithTruncation = ({
|
||||
onClick={onClick}
|
||||
style={style}
|
||||
>
|
||||
<Tooltip
|
||||
title={itemIsTruncated ? tooltipText : null}
|
||||
css={css`
|
||||
max-width: 200px;
|
||||
`}
|
||||
>
|
||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||
<div
|
||||
ref={itemRef}
|
||||
css={css`
|
||||
@@ -137,50 +127,3 @@ export const MenuItemWithTruncation = ({
|
||||
</Menu.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export const VirtualizedMenuItem = ({
|
||||
tooltipText,
|
||||
children,
|
||||
onClick,
|
||||
style,
|
||||
}: {
|
||||
tooltipText: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
style?: CSSProperties;
|
||||
}) => {
|
||||
const theme = useTheme();
|
||||
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<Flex
|
||||
role="menuitem"
|
||||
tabIndex={0}
|
||||
onClick={onClick}
|
||||
align="center"
|
||||
style={style}
|
||||
css={css`
|
||||
cursor: pointer;
|
||||
padding-left: ${theme.paddingXS}px;
|
||||
&:hover {
|
||||
background-color: ${theme.colorBgTextHover};
|
||||
}
|
||||
&:active {
|
||||
background-color: ${theme.colorBgTextActive};
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||
<div
|
||||
ref={itemRef}
|
||||
css={css`
|
||||
max-width: 100%;
|
||||
${truncationCSS};
|
||||
`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,29 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
within,
|
||||
cleanup,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
|
||||
import { store } from 'src/views/store';
|
||||
import { isFeatureEnabled } from '@superset-ui/core';
|
||||
import { FacePile } from '.';
|
||||
import { getRandomColor } from './utils';
|
||||
|
||||
// Mock the feature flag
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
isFeatureEnabled: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||
typeof isFeatureEnabled
|
||||
>;
|
||||
|
||||
const users = [...new Array(10)].map((_, i) => ({
|
||||
first_name: 'user',
|
||||
last_name: `${i}`,
|
||||
@@ -47,99 +29,37 @@ const users = [...new Array(10)].map((_, i) => ({
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
// Default to Slack avatars disabled
|
||||
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
mockIsFeatureEnabled.mockReset();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
describe('FacePile', () => {
|
||||
it('renders empty state with no users', () => {
|
||||
const { container } = render(<FacePile users={[]} />, { store });
|
||||
let container: HTMLElement;
|
||||
|
||||
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
|
||||
beforeEach(() => {
|
||||
({ container } = render(<FacePile users={users} />, { store }));
|
||||
});
|
||||
|
||||
it('renders single user without truncation', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
|
||||
store,
|
||||
});
|
||||
it('is a valid element', () => {
|
||||
const exposedFaces = screen.getAllByText(/U/);
|
||||
expect(exposedFaces).toHaveLength(4);
|
||||
const overflownFaces = screen.getByText('+6');
|
||||
expect(overflownFaces).toBeVisible();
|
||||
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(1);
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple users no truncation', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 4)} />, {
|
||||
store,
|
||||
});
|
||||
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(4);
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple users with truncation', () => {
|
||||
const { container } = render(<FacePile users={users} />, { store });
|
||||
|
||||
// Should show 4 avatars + 1 overflow indicator = 5 total elements
|
||||
const avatars = container.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(5);
|
||||
|
||||
// Should show first 4 users
|
||||
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||
|
||||
// Should show overflow count (+6 because 10 total - 4 shown)
|
||||
expect(within(container).getByText('+6')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays user tooltip on hover', () => {
|
||||
const { container } = render(<FacePile users={users.slice(0, 2)} />, {
|
||||
store,
|
||||
});
|
||||
|
||||
const firstAvatar = within(container).getByText('U0');
|
||||
fireEvent.mouseEnter(firstAvatar);
|
||||
// Display user info when hovering over one of exposed face in the pile.
|
||||
fireEvent.mouseEnter(exposedFaces[0]);
|
||||
act(() => jest.runAllTimers());
|
||||
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
|
||||
});
|
||||
|
||||
it('displays avatar images when Slack avatars are enabled', () => {
|
||||
// Enable Slack avatars feature flag
|
||||
mockIsFeatureEnabled.mockImplementation(
|
||||
feature => feature === 'SLACK_ENABLE_AVATARS',
|
||||
);
|
||||
it('renders an Avatar', () => {
|
||||
expect(container.querySelector('.ant-avatar')).toBeVisible();
|
||||
});
|
||||
|
||||
const { container: testContainer } = render(
|
||||
<FacePile users={users.slice(0, 2)} />,
|
||||
{
|
||||
store,
|
||||
},
|
||||
);
|
||||
|
||||
const avatars = testContainer.querySelectorAll('.ant-avatar');
|
||||
expect(avatars).toHaveLength(2);
|
||||
|
||||
// Should have img elements with correct src attributes
|
||||
const imgs = testContainer.querySelectorAll('.ant-avatar img');
|
||||
expect(imgs).toHaveLength(2);
|
||||
expect(imgs[0]).toHaveAttribute('src', '/api/v1/user/0/avatar.png');
|
||||
expect(imgs[1]).toHaveAttribute('src', '/api/v1/user/1/avatar.png');
|
||||
it('hides overflow', () => {
|
||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,9 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import fetchMock from 'fetch-mock';
|
||||
import rison from 'rison';
|
||||
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
|
||||
import { tagToSelectOption } from 'src/components/Tag/utils';
|
||||
|
||||
describe('tagToSelectOption', () => {
|
||||
test('converts a Tag object with table_name to a SelectTagsValue', () => {
|
||||
@@ -37,166 +35,3 @@ describe('tagToSelectOption', () => {
|
||||
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadTags', () => {
|
||||
beforeEach(() => {
|
||||
fetchMock.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.restore();
|
||||
});
|
||||
|
||||
test('constructs correct API query with custom tag filter', async () => {
|
||||
const mockTags = [
|
||||
{ id: 1, name: 'analytics', type: 1 },
|
||||
{ id: 2, name: 'finance', type: 1 },
|
||||
];
|
||||
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: mockTags,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
await loadTags('analytics', 0, 25);
|
||||
|
||||
// Verify the API was called with correct parameters
|
||||
const calls = fetchMock.calls();
|
||||
expect(calls).toHaveLength(1);
|
||||
|
||||
const [url] = calls[0];
|
||||
expect(url).toContain('/api/v1/tag/?q=');
|
||||
|
||||
// Extract and decode the query parameter
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Verify the query structure
|
||||
expect(decodedQuery).toEqual({
|
||||
filters: [
|
||||
{ col: 'name', opr: 'ct', value: 'analytics' },
|
||||
{ col: 'type', opr: 'custom_tag', value: true },
|
||||
],
|
||||
page: 0,
|
||||
page_size: 25,
|
||||
order_column: 'name',
|
||||
order_direction: 'asc',
|
||||
});
|
||||
});
|
||||
|
||||
test('returns correctly transformed data', async () => {
|
||||
const mockTags = [
|
||||
{ id: 1, name: 'analytics', type: 1 },
|
||||
{ id: 2, name: 'finance', type: 1 },
|
||||
];
|
||||
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: mockTags,
|
||||
count: 2,
|
||||
});
|
||||
|
||||
const result = await loadTags('', 0, 25);
|
||||
|
||||
expect(result).toEqual({
|
||||
data: [
|
||||
{ value: 1, label: 'analytics', key: 1 },
|
||||
{ value: 2, label: 'finance', key: 2 },
|
||||
],
|
||||
totalCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test('handles search parameter correctly', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('financial-data', 0, 25);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Should include the search term in the name filter
|
||||
expect(decodedQuery.filters[0]).toEqual({
|
||||
col: 'name',
|
||||
opr: 'ct',
|
||||
value: 'financial-data',
|
||||
});
|
||||
});
|
||||
|
||||
test('handles pagination parameters correctly', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('', 2, 10);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
expect(decodedQuery.page).toBe(2);
|
||||
expect(decodedQuery.page_size).toBe(10);
|
||||
});
|
||||
|
||||
test('always includes custom tag filter regardless of other parameters', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
// Test with different combinations of parameters
|
||||
await loadTags('', 0, 25);
|
||||
await loadTags('search-term', 1, 50);
|
||||
await loadTags('another-search', 5, 100);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
|
||||
// Verify all calls include the custom tag filter
|
||||
calls.forEach(call => {
|
||||
const [url] = call;
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Every call should have the custom tag filter
|
||||
expect(decodedQuery.filters).toContainEqual({
|
||||
col: 'type',
|
||||
opr: 'custom_tag',
|
||||
value: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test('maintains correct order specification', async () => {
|
||||
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||
result: [],
|
||||
count: 0,
|
||||
});
|
||||
|
||||
await loadTags('test', 0, 25);
|
||||
|
||||
const calls = fetchMock.calls();
|
||||
const [url] = calls[0];
|
||||
const urlObj = new URL(url);
|
||||
const queryParam = urlObj.searchParams.get('q');
|
||||
expect(queryParam).not.toBeNull();
|
||||
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||
|
||||
// Should always order by name ascending
|
||||
expect(decodedQuery.order_column).toBe('name');
|
||||
expect(decodedQuery.order_direction).toBe('asc');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,129 +78,3 @@ test('should render 3 elements when maxTags is set to 3', async () => {
|
||||
expect(tagsListItems).toHaveLength(3);
|
||||
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||
});
|
||||
|
||||
describe('Tag type filtering', () => {
|
||||
test('should render only custom type tags (type: 1)', async () => {
|
||||
const mixedTypeTags = [
|
||||
{ name: 'custom-tag', type: 1, id: 1 }, // Custom - should show
|
||||
{ name: 'type:chart', type: 2, id: 2 }, // Type - should be filtered out
|
||||
{ name: 'owner:admin', type: 3, id: 3 }, // Owner - should be filtered out
|
||||
{ name: 'another-custom', type: 1, id: 4 }, // Custom - should show
|
||||
];
|
||||
|
||||
// Filter tags like ChartList does - only custom types
|
||||
const filteredTags = mixedTypeTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should only show 2 custom tags, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('another-custom');
|
||||
expect(tagsListItems[1]).toHaveTextContent('custom-tag');
|
||||
});
|
||||
|
||||
test('should show tags when type is undefined (fallback case)', async () => {
|
||||
const undefinedTypeTags = [
|
||||
{ name: 'legacy-tag', id: 1 }, // No type property - should show due to fallback
|
||||
{ name: 'custom-tag', type: 1, id: 2 }, // Custom - should show
|
||||
{ name: 'system-tag', type: 2, id: 3 }, // System - should be filtered out
|
||||
];
|
||||
|
||||
// Apply ChartList filtering logic - undefined type defaults to true
|
||||
const filteredTags = undefinedTypeTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show both tags, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('custom-tag');
|
||||
expect(tagsListItems[1]).toHaveTextContent('legacy-tag');
|
||||
});
|
||||
|
||||
test('should handle legacy TagTypes.custom string format', async () => {
|
||||
const legacyFormatTags = [
|
||||
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 1 }, // Legacy string format - should show
|
||||
{ name: 'modern-custom', type: 1, id: 2 }, // Modern enum - should show
|
||||
{ name: 'other-type', type: 'TagTypes.other', id: 3 }, // Other legacy type - should be filtered out
|
||||
];
|
||||
|
||||
// Apply ChartList filtering logic - supports both numeric and legacy string
|
||||
const filteredTags = legacyFormatTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show both custom formats, sorted alphabetically
|
||||
expect(tagsListItems).toHaveLength(2);
|
||||
expect(tagsListItems[0]).toHaveTextContent('legacy-custom');
|
||||
expect(tagsListItems[1]).toHaveTextContent('modern-custom');
|
||||
});
|
||||
|
||||
test('should show empty list when all tags are filtered out', async () => {
|
||||
const nonCustomTags = [
|
||||
{ name: 'type:chart', type: 2, id: 1 }, // Type tag
|
||||
{ name: 'owner:admin', type: 3, id: 2 }, // Owner tag
|
||||
{ name: 'favoritedBy:user', type: 4, id: 3 }, // FavoritedBy tag
|
||||
];
|
||||
|
||||
// Apply ChartList filtering - all should be filtered out
|
||||
const filteredTags = nonCustomTags.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
setup({ tags: filteredTags, maxTags: 5 });
|
||||
|
||||
// Should render container but with no tags
|
||||
const container = document.querySelector('.tag-list');
|
||||
expect(container).toBeInTheDocument();
|
||||
|
||||
// No tags should be rendered
|
||||
const tagsListItems = document.querySelectorAll('.ant-tag');
|
||||
expect(tagsListItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should handle mixed scenarios with truncation', async () => {
|
||||
const largeMixedTagSet = [
|
||||
{ name: 'custom-1', type: 1, id: 1 }, // Custom - should show
|
||||
{ name: 'system-1', type: 2, id: 2 }, // System - filtered out
|
||||
{ name: 'custom-2', type: 1, id: 3 }, // Custom - should show
|
||||
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 4 }, // Legacy custom - should show
|
||||
{ name: 'custom-3', type: 1, id: 5 }, // Custom - should show
|
||||
{ name: 'owner-tag', type: 3, id: 6 }, // Owner - filtered out
|
||||
{ name: 'custom-4', type: 1, id: 7 }, // Custom - should show (but truncated)
|
||||
];
|
||||
|
||||
// Apply ChartList filtering - should get 5 custom tags
|
||||
const filteredTags = largeMixedTagSet.filter(tag =>
|
||||
tag.type
|
||||
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||
: true,
|
||||
);
|
||||
|
||||
// Set maxTags to 3 to test truncation of filtered results
|
||||
setup({ tags: filteredTags, maxTags: 3 });
|
||||
const tagsListItems = await findAllTags();
|
||||
|
||||
// Should show 3 tags: 2 custom tags (alphabetically sorted) + 1 "+3..." truncation indicator
|
||||
expect(tagsListItems).toHaveLength(3);
|
||||
expect(tagsListItems[0]).toHaveTextContent('custom-1');
|
||||
expect(tagsListItems[1]).toHaveTextContent('custom-2');
|
||||
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
getClientErrorObject,
|
||||
getCategoricalSchemeRegistry,
|
||||
promiseTimeout,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
addChart,
|
||||
@@ -888,7 +887,7 @@ export const applyDashboardLabelsColorOnLoad = metadata => async dispatch => {
|
||||
dispatch(setDashboardLabelsColorMapSync());
|
||||
}
|
||||
} catch (e) {
|
||||
logging.error('Failed to update dashboard color on load:', e);
|
||||
console.error('Failed to update dashboard color on load:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1055,6 +1054,6 @@ export const updateDashboardLabelsColor = renderedChartIds => (_, getState) => {
|
||||
// re-apply the color map first to get fresh maps accordingly
|
||||
applyColors(metadata, shouldGoFresh, shouldMerge);
|
||||
} catch (e) {
|
||||
logging.error('Failed to update colors for new charts and labels:', e);
|
||||
console.error('Failed to update colors for new charts and labels:', e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
t,
|
||||
css,
|
||||
getExtensionsRegistry,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
Button,
|
||||
@@ -87,7 +86,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
addInfoToast(t('Changes saved.'));
|
||||
},
|
||||
err => {
|
||||
logging.error(err);
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
t('Sorry, something went wrong. The changes could not be saved.'),
|
||||
@@ -116,7 +115,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
||||
onHide();
|
||||
},
|
||||
err => {
|
||||
logging.error(err);
|
||||
console.error(err);
|
||||
addDangerToast(
|
||||
t(
|
||||
'Sorry, something went wrong. Embedding could not be deactivated.',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { t, logging } from '@superset-ui/core';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { Charts, Layout, RootState, Slice } from 'src/dashboard/types';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import {
|
||||
@@ -46,7 +46,7 @@ export function useFilterScopeTree(
|
||||
|
||||
const sliceEntities = useSelector<RootState, Slice>(state => {
|
||||
if (!state.sliceEntities) {
|
||||
logging.warn('sliceEntities not found in state');
|
||||
console.warn('sliceEntities not found in state');
|
||||
return {};
|
||||
}
|
||||
return state.sliceEntities.slices || {};
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
ExtraFormDataOverride,
|
||||
TimeGranularity,
|
||||
ExtraFormDataAppend,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import { LayoutItem } from 'src/dashboard/types';
|
||||
import extractUrlParams from 'src/dashboard/util/extractUrlParams';
|
||||
@@ -243,7 +242,7 @@ export function getFilterScope(
|
||||
if (target) {
|
||||
targets.push(target);
|
||||
} else {
|
||||
logging.warn(`Invalid filter scope key format: ${scopeKey}`);
|
||||
console.warn(`Invalid filter scope key format: ${scopeKey}`);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
JsonValue,
|
||||
QueryFormData,
|
||||
usePrevious,
|
||||
logging,
|
||||
} from '@superset-ui/core';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
import { ExploreActions } from 'src/explore/actions/exploreActions';
|
||||
@@ -108,7 +107,8 @@ export default function Control(props: ControlProps) {
|
||||
? controlMap[type as keyof typeof controlMap]
|
||||
: type;
|
||||
if (!ControlComponent) {
|
||||
logging.warn(`Unknown controlType: ${type}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`Unknown controlType: ${type}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { css, JsonValue, styled, t, logging } from '@superset-ui/core';
|
||||
import { css, JsonValue, styled, t } from '@superset-ui/core';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Button } from '@superset-ui/core/components/Button';
|
||||
import { Form } from '@superset-ui/core/components/Form';
|
||||
@@ -331,7 +331,7 @@ export const LayerConfigsPopoverContent: FC<
|
||||
});
|
||||
setGeoStylerData(gsData);
|
||||
} catch {
|
||||
logging.warn('Could not read geostyler data');
|
||||
console.warn('Could not read geostyler data');
|
||||
setGeoStylerData(undefined);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ControlHeader } from '@superset-ui/chart-controls';
|
||||
import { css, styled, t, logging } from '@superset-ui/core';
|
||||
import { css, styled, t } from '@superset-ui/core';
|
||||
import { Form } from '@superset-ui/core/components';
|
||||
import { Tag } from 'src/components';
|
||||
import { FC, useState } from 'react';
|
||||
@@ -65,7 +65,7 @@ export const ZoomConfigControl: FC<ZoomConfigsControlProps> = ({
|
||||
};
|
||||
|
||||
const onBaseWidthChange = (width: number) => {
|
||||
logging.log('now in onbasewidthcahnge');
|
||||
console.log('now in onbasewidthcahnge');
|
||||
setBaseWidth(width);
|
||||
if (!value) {
|
||||
return;
|
||||
|
||||
@@ -34,7 +34,7 @@ import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import { getChartPermalink } from 'src/utils/urlUtils';
|
||||
import copyTextToClipboard from 'src/utils/copy';
|
||||
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||
import { logEvent } from 'src/logger/actions';
|
||||
import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
|
||||
@@ -123,18 +123,12 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const theme = useTheme();
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const dispatch = useDispatch();
|
||||
const [showReportSubMenu, setShowReportSubMenu] = useState(null);
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const chart = useSelector(
|
||||
state => state.charts?.[getChartKey(state.explore)],
|
||||
);
|
||||
|
||||
// Use the updated report menu items hook
|
||||
const reportMenuItem = useHeaderReportMenuItems({
|
||||
chart,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
});
|
||||
|
||||
const { datasource } = latestQueryFormData;
|
||||
|
||||
const shareByEmail = useCallback(async () => {
|
||||
@@ -209,106 +203,14 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
||||
|
||||
const menu = useMemo(() => {
|
||||
const menuItems = [];
|
||||
|
||||
// Edit chart properties
|
||||
if (slice) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.EDIT_PROPERTIES,
|
||||
label: t('Edit chart properties'),
|
||||
onClick: () => {
|
||||
const handleMenuClick = useCallback(
|
||||
({ key, domEvent }) => {
|
||||
switch (key) {
|
||||
case MENU_KEYS.EDIT_PROPERTIES:
|
||||
onOpenPropertiesModal();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// On dashboards submenu
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
|
||||
type: 'submenu',
|
||||
label: t('On dashboards'),
|
||||
children: [
|
||||
{
|
||||
key: 'dashboards-content',
|
||||
label: (
|
||||
<DashboardsSubMenu
|
||||
chartId={slice?.slice_id}
|
||||
dashboards={dashboards}
|
||||
/>
|
||||
),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Download submenu
|
||||
const downloadChildren = [];
|
||||
|
||||
if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) {
|
||||
downloadChildren.push(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||
label: t('Export to original .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportCSV();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
|
||||
label: t('Export to pivoted .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportCSVPivoted();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
|
||||
label: t('Export to Pivoted Excel'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
exportPivotExcel(
|
||||
'.pvtTable',
|
||||
slice?.slice_name ?? t('pivoted_xlsx'),
|
||||
);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
} else {
|
||||
downloadChildren.push({
|
||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||
label: t('Export to .CSV'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_CSV:
|
||||
exportCSV();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -317,17 +219,18 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
downloadChildren.push(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_JSON,
|
||||
label: t('Export to .JSON'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_CSV_PIVOTED:
|
||||
exportCSVPivoted();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_JSON:
|
||||
exportJson();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -336,33 +239,8 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.DOWNLOAD_AS_IMAGE,
|
||||
label: t('Download as image'),
|
||||
icon: <Icons.FileImageOutlined />,
|
||||
onClick: e => {
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
true,
|
||||
)(e.domEvent);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_XLSX,
|
||||
label: t('Export to Excel'),
|
||||
icon: <Icons.FileOutlined />,
|
||||
disabled: !canDownloadCSV,
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_XLSX:
|
||||
exportExcel();
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
@@ -371,128 +249,225 @@ export const useExploreAdditionalActionsMenu = (
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DOWNLOAD_SUBMENU,
|
||||
type: 'submenu',
|
||||
label: t('Download'),
|
||||
children: downloadChildren,
|
||||
});
|
||||
|
||||
// Share submenu
|
||||
const shareChildren = [
|
||||
{
|
||||
key: MENU_KEYS.COPY_PERMALINK,
|
||||
label: t('Copy permalink to clipboard'),
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
|
||||
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
// eslint-disable-next-line camelcase
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
true,
|
||||
)(domEvent);
|
||||
setIsDropdownVisible(false);
|
||||
dispatch(
|
||||
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
|
||||
chartId: slice?.slice_id,
|
||||
chartName: slice?.slice_name,
|
||||
}),
|
||||
);
|
||||
break;
|
||||
case MENU_KEYS.COPY_PERMALINK:
|
||||
copyLink();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: MENU_KEYS.SHARE_BY_EMAIL,
|
||||
label: t('Share chart by email'),
|
||||
onClick: () => {
|
||||
break;
|
||||
case MENU_KEYS.EMBED_CODE:
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.SHARE_BY_EMAIL:
|
||||
shareByEmail();
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
},
|
||||
];
|
||||
break;
|
||||
case MENU_KEYS.VIEW_QUERY:
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
case MENU_KEYS.RUN_IN_SQL_LAB:
|
||||
onOpenInEditor(latestQueryFormData, domEvent.metaKey);
|
||||
setIsDropdownVisible(false);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
},
|
||||
[
|
||||
copyLink,
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportJson,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
shareByEmail,
|
||||
slice?.slice_name,
|
||||
],
|
||||
);
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) {
|
||||
shareChildren.push({
|
||||
key: MENU_KEYS.EMBED_CODE,
|
||||
label: (
|
||||
const menu = useMemo(
|
||||
() => (
|
||||
<Menu onClick={handleMenuClick} selectable={false} {...rest}>
|
||||
<>
|
||||
{slice && (
|
||||
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
|
||||
{t('Edit chart properties')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.SubMenu
|
||||
title={t('On dashboards')}
|
||||
key={MENU_KEYS.DASHBOARDS_ADDED_TO}
|
||||
>
|
||||
<DashboardsSubMenu
|
||||
chartId={slice?.slice_id}
|
||||
dashboards={dashboards}
|
||||
/>
|
||||
</Menu.SubMenu>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
<Menu.SubMenu title={t('Download')} key={MENU_KEYS.DOWNLOAD_SUBMENU}>
|
||||
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
|
||||
<>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to original .CSV')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to pivoted .CSV')}
|
||||
</Menu.Item>
|
||||
</>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_CSV}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to .CSV')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_JSON}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to .JSON')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
|
||||
icon={<Icons.FileImageOutlined />}
|
||||
>
|
||||
{t('Download as image')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_XLSX}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to Excel')}
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
|
||||
icon={<Icons.FileOutlined />}
|
||||
disabled={!canDownloadCSV}
|
||||
>
|
||||
{t('Export to Pivoted Excel')}
|
||||
</Menu.Item>
|
||||
</Menu.SubMenu>
|
||||
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
|
||||
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
|
||||
{t('Copy permalink to clipboard')}
|
||||
</Menu.Item>
|
||||
<Menu.Item key={MENU_KEYS.SHARE_BY_EMAIL}>
|
||||
{t('Share chart by email')}
|
||||
</Menu.Item>
|
||||
{isFeatureEnabled(FeatureFlag.EmbeddableCharts) ? (
|
||||
<Menu.Item key={MENU_KEYS.EMBED_CODE}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="embed-code-button">{t('Embed code')}</div>
|
||||
}
|
||||
modalTitle={t('Embed code')}
|
||||
modalBody={
|
||||
<EmbedCodeContent
|
||||
formData={latestQueryFormData}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
}
|
||||
maxWidth={`${theme.sizeUnit * 100}px`}
|
||||
destroyOnHidden
|
||||
responsive
|
||||
/>
|
||||
</Menu.Item>
|
||||
) : null}
|
||||
</Menu.SubMenu>
|
||||
<Menu.Divider />
|
||||
{showReportSubMenu ? (
|
||||
<>
|
||||
<HeaderReportDropDown
|
||||
submenuTitle={t('Manage email report')}
|
||||
chart={chart}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportSubMenu={showReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
<Menu.Divider />
|
||||
</>
|
||||
) : (
|
||||
<HeaderReportDropDown
|
||||
chart={chart}
|
||||
setShowReportSubMenu={setShowReportSubMenu}
|
||||
showReportModal={showReportModal}
|
||||
setCurrentReportDeleting={setCurrentReportDeleting}
|
||||
useTextMenu
|
||||
/>
|
||||
)}
|
||||
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="embed-code-button">{t('Embed code')}</div>
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('Embed code')}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={
|
||||
<EmbedCodeContent
|
||||
formData={latestQueryFormData}
|
||||
addDangerToast={addDangerToast}
|
||||
/>
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
}
|
||||
maxWidth={`${theme.sizeUnit * 100}px`}
|
||||
destroyOnHidden
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
/>
|
||||
),
|
||||
onClick: () => setIsDropdownVisible(false),
|
||||
});
|
||||
}
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.SHARE_SUBMENU,
|
||||
type: 'submenu',
|
||||
label: t('Share'),
|
||||
children: shareChildren,
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
|
||||
// Report menu item
|
||||
if (reportMenuItem) {
|
||||
menuItems.push(reportMenuItem);
|
||||
}
|
||||
|
||||
// View query
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.VIEW_QUERY,
|
||||
label: (
|
||||
<ModalTrigger
|
||||
triggerNode={
|
||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
responsive
|
||||
/>
|
||||
),
|
||||
onClick: () => setIsDropdownVisible(false),
|
||||
});
|
||||
|
||||
// Run in SQL Lab
|
||||
if (datasource) {
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.RUN_IN_SQL_LAB,
|
||||
label: t('Run in SQL Lab'),
|
||||
onClick: e => {
|
||||
onOpenInEditor(latestQueryFormData, e.domEvent.metaKey);
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return <Menu selectable={false} items={menuItems} {...rest} />;
|
||||
}, [
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
copyLink,
|
||||
dashboards,
|
||||
datasource,
|
||||
dispatch,
|
||||
exportCSV,
|
||||
exportCSVPivoted,
|
||||
exportExcel,
|
||||
exportJson,
|
||||
latestQueryFormData,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
reportMenuItem,
|
||||
shareByEmail,
|
||||
slice,
|
||||
theme.sizeUnit,
|
||||
]);
|
||||
|
||||
</Menu.Item>
|
||||
{datasource && (
|
||||
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
|
||||
{t('Run in SQL Lab')}
|
||||
</Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
),
|
||||
[
|
||||
addDangerToast,
|
||||
canDownloadCSV,
|
||||
chart,
|
||||
dashboards,
|
||||
handleMenuClick,
|
||||
isDropdownVisible,
|
||||
latestQueryFormData,
|
||||
showReportSubMenu,
|
||||
slice,
|
||||
theme.sizeUnit,
|
||||
],
|
||||
);
|
||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||
};
|
||||
|
||||
@@ -1087,27 +1087,18 @@ const DatabaseModal: FunctionComponent<DatabaseModalProps> = ({
|
||||
onChange={setDatabaseModel}
|
||||
placeholder={t('Choose a database...')}
|
||||
options={[
|
||||
...(availableDbs?.databases || []).map(
|
||||
(database: DatabaseForm, index: number) => ({
|
||||
...(availableDbs?.databases || [])
|
||||
.sort((a: DatabaseForm, b: DatabaseForm) =>
|
||||
a.name.localeCompare(b.name),
|
||||
)
|
||||
.map((database: DatabaseForm, index: number) => ({
|
||||
value: database.name,
|
||||
label: database.name,
|
||||
key: `database-${index}`,
|
||||
}),
|
||||
),
|
||||
})),
|
||||
{ value: 'Other', label: t('Other'), key: 'Other' },
|
||||
]}
|
||||
showSearch
|
||||
sortComparator={(a, b) => {
|
||||
// Always put "Other" at the end
|
||||
if (a.value === 'Other') return 1;
|
||||
if (b.value === 'Other') return -1;
|
||||
// For all other options, sort alphabetically
|
||||
return String(a.label).localeCompare(String(b.label));
|
||||
}}
|
||||
getPopupContainer={triggerNode =>
|
||||
triggerNode.parentElement || document.body
|
||||
}
|
||||
dropdownStyle={{ maxHeight: 400, overflow: 'auto' }}
|
||||
/>
|
||||
<Alert
|
||||
showIcon
|
||||
|
||||
@@ -17,8 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { logging } from '@superset-ui/core';
|
||||
|
||||
/**
|
||||
* Interface for table columns dataset
|
||||
*/
|
||||
@@ -44,13 +42,15 @@ export const isITableColumn = (item: any): boolean => {
|
||||
'The object provided to isITableColumn does match the interface.';
|
||||
if (typeof item?.name !== 'string') {
|
||||
match = false;
|
||||
logging.error(
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${BASE_ERROR} The property 'name' is required and must be a string`,
|
||||
);
|
||||
}
|
||||
if (match && typeof item?.type !== 'string') {
|
||||
match = false;
|
||||
logging.error(
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`${BASE_ERROR} The property 'type' is required and must be a string`,
|
||||
);
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user