Compare commits

..

24 Commits

Author SHA1 Message Date
Maxime Beauchemin
375fe42a68 pointing link to master 2025-07-29 12:01:05 -07:00
Maxime Beauchemin
e6e0c3c47e docs 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
1d6617d809 improve startup script 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
4ff2a85b11 gh 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
f1a3bdd878 tweak utilities 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
4b5dbf3dcf public port 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
458db68929 tmux 2025-07-29 11:19:58 -07:00
Maxime Beauchemin
d4463078ad only 9001 2025-07-29 11:19:57 -07:00
Maxime Beauchemin
7ad10ac1a9 ssh 2025-07-29 11:19:57 -07:00
Maxime Beauchemin
f580f6159e ok 2025-07-29 11:19:57 -07:00
Maxime Beauchemin
a26e0ea0fe fix: Use Python 3.11 Bookworm image to match current standard
- Switch to pre-built Python 3.11 image (no compilation)
- Bookworm base matches Superset Docker images
- Python 3.11 is the current tested standard
- Faster startup, no building from source
2025-07-29 11:19:57 -07:00
Maxime Beauchemin
4eef7a65c1 fix: Remove Python feature to avoid building from source
- Ubuntu 24.04 already includes Python 3.12
- No need to build Python from source (saves ~10min)
- System Python is sufficient for host environment
- Actual Superset Python runs in Docker containers
2025-07-29 11:19:57 -07:00
Maxime Beauchemin
ba3388bf94 feat: Add Claude Code CLI to devcontainer setup
- Install Claude Code for AI-assisted development
- Perfect for using 'claude --yes' safely in Codespaces
- No risk to local machine when running automated commands
2025-07-29 11:19:57 -07:00
Maxime Beauchemin
ca57bbc1e2 feat: Add uv package installer to devcontainer setup
- Install uv via official installer script
- Provides 10-100x faster Python package operations
- Matches what CI uses for package installation
2025-07-29 11:19:56 -07:00
Maxime Beauchemin
19f414b217 fix: Update Node version to 20 to match package.json requirements
- package.json specifies Node ^20.18.1
- Update devcontainer to use Node 20 instead of 18
2025-07-29 11:19:56 -07:00
Maxime Beauchemin
bc604d54e4 fix: Use Ubuntu 24.04 base to match CI with Python 3.11
- Switch to ubuntu-24.04 to match CI environment
- Add Python 3.11 explicitly
- Keep lean setup with only needed features
2025-07-29 11:19:56 -07:00
Maxime Beauchemin
e922e51e6b fix: Use lean Python base image instead of bloated universal
- Switch from 10GB universal to ~2GB Python base
- Add only needed features: Docker, Node, Git
- Much faster Codespace startup
- Same functionality, less bloat
2025-07-29 11:19:56 -07:00
Maxime Beauchemin
8bf2e4ea3a fix: Simplify devcontainer to avoid docker-compose conflicts
- Remove all features (universal image has everything)
- Simplified config to just image + scripts
- No dockerComposeFile reference
- Plain container that runs docker-compose internally
2025-07-29 11:19:56 -07:00
Maxime Beauchemin
cf8183b67e fix: Force rebuild with clean devcontainer config 2025-07-29 11:19:56 -07:00
Maxime Beauchemin
02f90f4321 feat: Use devcontainers/universal image for better tooling
- Switch to universal:2 image which includes vim, curl, jq, tmux, etc.
- Remove redundant features (already in universal image)
- Simplify setup script - only install Superset-specific libs
- Keeps SSH feature for remote access
2025-07-29 11:19:55 -07:00
Maxime Beauchemin
a007b3020d fix: Refactor devcontainer to use base Ubuntu with Docker-in-Docker
- Switch from docker-compose service to base Ubuntu container
- Add Docker-in-Docker to run docker-compose inside Codespace
- This provides git access and full dev environment
- Superset services run via docker-compose from within the container
2025-07-29 11:19:55 -07:00
Maxime Beauchemin
26e5e637f9 feat: Add SSH support to Codespaces configuration 2025-07-29 11:19:55 -07:00
Maxime Beauchemin
8de420ec8e fix: Correct workspace paths for Codespaces
- Use /workspaces instead of /app for Codespaces compatibility
- Fix postCreateCommand and postStartCommand paths
- Make startup script more flexible with directory detection
2025-07-29 11:19:55 -07:00
Maxime Beauchemin
fd51cc65a2 feat: Add GitHub Codespaces support with docker-compose-light
## Summary

Adds full GitHub Codespaces development environment configuration leveraging the new `docker-compose-light.yml` for efficient cloud development.

## Key Features

- **Lightweight Setup**: Uses `docker-compose-light.yml` which removes Redis/nginx for faster startup and lower resource usage
- **Multi-Instance Support**: Each Codespace gets isolated database volumes, perfect for testing multiple branches
- **Auto-Configuration**: Includes VS Code extensions, Python/TypeScript settings, and auto-start script
- **Developer Friendly**: Comprehensive README with SSH, VS Code, and browser connection instructions

## Implementation Details

### Files Added
- `.devcontainer/devcontainer.json` - Main configuration with:
  - Docker-in-Docker support for compose
  - Optimized VS Code extensions for Superset development
  - Smart port forwarding (9001 for frontend, 8088 for API)
  - 4-core/8GB recommended resources

- `.devcontainer/start-superset.sh` - Auto-start script that:
  - Uses unique project names per Codespace
  - Handles Docker daemon startup
  - Shows clear status and credentials

- `.devcontainer/README.md` - Developer guide covering:
  - Multiple connection methods (SSH, VS Code, browser)
  - Port forwarding instructions
  - Cost optimization tips
  - Integration with `claude --yes` workflows

## Benefits

1. **Isolated Development**: No risk to local machine when using `claude --yes`
2. **Resource Efficiency**: Laptop stays cool, Codespaces handles the load
3. **Parallel Testing**: Spin up multiple instances for different features
4. **Quick Pause/Resume**: Auto-stops when idle, resumes in ~30 seconds

## Testing

Push to fork and create a Codespace to test. The environment auto-starts Superset and forwards port 9001 with HTTPS.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-29 11:19:55 -07:00
79 changed files with 6834 additions and 6702 deletions

View File

@@ -1,20 +0,0 @@
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
FROM python:3.11.13-bookworm AS base
# Install system dependencies that Superset needs
# This layer will be cached across Codespace sessions
RUN apt-get update && apt-get install -y \
libsasl2-dev \
libldap2-dev \
libpq-dev \
tmux \
gh \
&& rm -rf /var/lib/apt/lists/*
# Install uv for fast Python package management
# This will also be cached in the image
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
# Set the cargo/bin directory in PATH for all users
ENV PATH="/root/.cargo/bin:${PATH}"

View File

@@ -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.

View File

@@ -1,62 +0,0 @@
# Superset Codespaces environment setup
# This file is appended to ~/.bashrc during Codespace setup
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
# Check if virtual environment exists
if [ -d "$WORKSPACE_DIR/.venv" ]; then
# Activate the virtual environment
source "$WORKSPACE_DIR/.venv/bin/activate"
echo "✅ Python virtual environment activated"
# Verify pre-commit is installed and set up
if command -v pre-commit &> /dev/null; then
echo "✅ pre-commit is available ($(pre-commit --version))"
# Install git hooks if not already installed
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
echo "🪝 Installing pre-commit hooks..."
cd "$WORKSPACE_DIR" && pre-commit install
fi
else
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
fi
else
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
fi
# Always cd to the workspace directory for convenience
cd "$WORKSPACE_DIR"
fi
# Add helpful aliases for Superset development
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
# Show helpful message on login
echo ""
echo "🚀 Superset Codespaces Environment"
echo "=================================="
# Check if Superset is running
if docker ps 2>/dev/null | grep -q "superset"; then
echo "✅ Superset is running!"
echo " - Check the 'Ports' tab for your live Superset URL"
echo " - Initial startup takes 10-20 minutes"
echo " - Login: admin/admin"
else
echo "⚠️ Superset is not running. Use: start-superset"
# Check if there's a startup log
if [ -f "/tmp/superset-startup.log" ]; then
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
fi
fi
echo ""
echo "Quick commands:"
echo " start-superset - Start Superset with Docker Compose"
echo " setup-dev - Set up Python environment (if not already done)"
echo " pre-commit run - Run pre-commit checks on staged files"
echo ""

View File

@@ -1,20 +0,0 @@
#!/bin/bash
# Script to build and push the devcontainer image to GitHub Container Registry
# This allows caching the image between Codespace sessions
# You'll need to run this with appropriate GitHub permissions
# gh auth login --scopes write:packages
REGISTRY="ghcr.io"
OWNER="apache"
REPO="superset"
TAG="devcontainer-base"
echo "Building devcontainer image..."
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
echo "Pushing to GitHub Container Registry..."
docker push $REGISTRY/$OWNER/$REPO:$TAG
echo "Done! Update .devcontainer/devcontainer.json to use:"
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""

View File

@@ -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": {

View File

@@ -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"

View File

@@ -1,11 +1,6 @@
#!/bin/bash
# Startup script for Superset in Codespaces
# Log to a file for debugging
LOG_FILE="/tmp/superset-startup.log"
echo "[$(date)] Starting Superset startup script" >> "$LOG_FILE"
echo "[$(date)] User: $(whoami), PWD: $(pwd)" >> "$LOG_FILE"
echo "🚀 Starting Superset in Codespaces..."
echo "🌐 Frontend will be available at port 9001"
@@ -18,37 +13,10 @@ else
echo "📁 Using current directory: $(pwd)"
fi
# Wait for Docker to be available
echo "⏳ Waiting for Docker to start..."
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
max_attempts=30
attempt=0
while ! docker info > /dev/null 2>&1; do
if [ $attempt -eq $max_attempts ]; then
echo "❌ Docker failed to start after $max_attempts attempts"
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
echo "🔄 Please restart the Codespace or run this script manually later"
exit 1
fi
echo " Attempt $((attempt + 1))/$max_attempts..."
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
sleep 2
attempt=$((attempt + 1))
done
echo "✅ Docker is ready!"
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
# Check if Superset containers are already running
if docker ps | grep -q "superset"; then
echo "✅ Superset containers are already running!"
echo ""
echo "🌐 To access Superset:"
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
echo " 2. Find port 9001 and click the globe icon to open"
echo " 3. Wait 10-20 minutes for initial startup"
echo ""
echo "📝 Login credentials: admin/admin"
exit 0
# Check if docker is running
if ! docker info > /dev/null 2>&1; then
echo " Waiting for Docker to start..."
sleep 5
fi
# Clean up any existing containers
@@ -56,33 +24,16 @@ echo "🧹 Cleaning up existing containers..."
docker-compose -f docker-compose-light.yml down
# Start services
echo "🏗️ Starting Superset in background (daemon mode)..."
echo "🏗️ Building and starting services..."
echo ""
echo "📝 Once started, login with:"
echo " Username: admin"
echo " Password: admin"
echo ""
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
# Start in detached mode
docker-compose -f docker-compose-light.yml up -d
echo ""
echo "✅ Docker Compose started successfully!"
echo ""
echo "📋 Important information:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⏱️ Initial startup takes 10-20 minutes"
echo "🌐 Check the 'Ports' tab for your Superset URL (port 9001)"
echo "👤 Login: admin / admin"
echo ""
echo "📊 Useful commands:"
echo " docker-compose -f docker-compose-light.yml logs -f # Follow logs"
echo " docker-compose -f docker-compose-light.yml ps # Check status"
echo " docker-compose -f docker-compose-light.yml down # Stop services"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "💤 Keeping terminal open for 60 seconds to test persistence..."
sleep 60
echo "✅ Test complete - check if this terminal is still visible!"
# Show final status
docker-compose -f docker-compose-light.yml ps
# Run docker-compose and capture exit code
docker-compose -f docker-compose-light.yml up
EXIT_CODE=$?
# If it failed, provide helpful instructions

View File

@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
COPY superset-frontend /app/superset-frontend
######################################################################
# superset-node is used for compiling frontend assets
# superset-node used for compile frontend assets
######################################################################
FROM superset-node-ci AS superset-node
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
# Copy translation files
COPY superset/translations /app/superset/translations
# Build translations if enabled, then cleanup localization files
# Build the frontend if not in dev mode
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
npm run build-translation; \
fi; \

View File

@@ -23,57 +23,25 @@ MIN_MEM_FREE_GB=3
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
echo_mem_warn() {
# Check if running in Codespaces first
if [[ -n "${CODESPACES}" ]]; then
echo "Memory available: Codespaces managed"
return
fi
MEM_FREE_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_FREE_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
# Check platform and get memory accordingly
if [[ -f /proc/meminfo ]]; then
# Linux
if grep -q MemAvailable /proc/meminfo; then
MEM_AVAIL_KB=$(awk '/MemAvailable/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_AVAIL_GB=$(awk '/MemAvailable/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
else
MEM_AVAIL_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
MEM_AVAIL_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
fi
elif [[ "$(uname)" == "Darwin" ]]; then
# macOS - use vm_stat to get free memory
# vm_stat reports in pages, typically 4096 bytes per page
PAGE_SIZE=$(pagesize)
FREE_PAGES=$(vm_stat | awk '/Pages free:/ {print $3}' | tr -d '.')
INACTIVE_PAGES=$(vm_stat | awk '/Pages inactive:/ {print $3}' | tr -d '.')
# Free + inactive pages give us available memory (similar to MemAvailable on Linux)
AVAIL_PAGES=$((FREE_PAGES + INACTIVE_PAGES))
MEM_AVAIL_KB=$((AVAIL_PAGES * PAGE_SIZE / 1024))
MEM_AVAIL_GB=$(echo "scale=2; $MEM_AVAIL_KB / 1024 / 1024" | bc)
else
# Other platforms
echo "Memory available: Unable to determine"
return
fi
if [[ "${MEM_AVAIL_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
cat <<EOF
===============================================
======== Memory Insufficient Warning =========
===============================================
It looks like you only have ${MEM_AVAIL_GB}GB of
memory ${MEM_TYPE}. Please increase your Docker
It looks like you only have ${MEM_FREE_GB}GB of
memory free. Please increase your Docker
resources to at least ${MIN_MEM_FREE_GB}GB
Note: During builds, available memory may be
temporarily low due to caching and compilation.
===============================================
======== Memory Insufficient Warning =========
===============================================
EOF
else
echo "Memory available: ${MEM_AVAIL_GB} GB"
echo "Memory check Ok [${MEM_FREE_GB}GB free]"
fi
}

View File

@@ -87,66 +87,8 @@ Restart Superset to apply changes.
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
## Custom Fonts
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
### Configuring Custom Fonts
Add font URLs to your `superset_config.py`:
```python
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
CUSTOM_FONT_URLS = [
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
]
# Update CSP to allow font sources
TALISMAN_CONFIG = {
"content_security_policy": {
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
}
}
```
### Using Custom Fonts in Themes
Once configured, reference the fonts in your theme configuration:
```python
THEME_DEFAULT = {
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
# ... other theme tokens
}
}
```
Or in the CRUD interface theme JSON:
```json
{
"token": {
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
}
}
```
### Font Sources
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
This feature works with the stock Docker image - no custom build required!
## Advanced Features
- **System Themes**: Superset includes built-in light and dark themes
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
- **JSON Editor**: Edit theme configurations directly within Superset's interface
- **Custom Fonts**: Load external fonts via configuration without rebuilding

View File

@@ -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=master&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).

View File

@@ -403,7 +403,6 @@ module.exports = {
'theme-colors/no-literal-colors': 'error',
'icons/no-fa-icons-usage': 'error',
'i18n-strings/no-template-vars': ['error', true],
'i18n-strings/no-title-case': 'error',
camelcase: [
'error',
{

View File

@@ -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');
}

View File

@@ -41,7 +41,7 @@ module.exports = {
context.report({
node,
message:
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it cant handle strings that include variables",
});
}
}
@@ -52,134 +52,5 @@ module.exports = {
};
},
},
'no-title-case': {
create(context) {
function checkTitleCase(str) {
// Skip strings with placeholders like %s, %d, %(name)s, etc.
if (/%[sdf]|%\([^)]+\)[sdf]/.test(str)) {
return false;
}
// Skip strings that are all uppercase (likely acronyms)
if (str === str.toUpperCase()) {
return false;
}
// Skip strings with periods (likely multiple sentences)
if (str.includes('.')) {
return false;
}
// Skip single words
const words = str.trim().split(/\s+/);
if (words.length <= 1) {
return false;
}
// Whitelist of words that are commonly capitalized in product names
// but should not trigger title case warnings
const productWords = [
'Lab',
'Server',
'Studio',
'Pro',
'Plus',
'Max',
'Mini',
];
// Common prepositions and articles that should be lowercase (unless at start)
const lowercaseWords = [
'a',
'an',
'the',
'and',
'or',
'but',
'for',
'with',
'to',
'from',
'in',
'on',
'at',
'by',
'of',
];
// Check if the string uses title case (multiple words with first letter capitalized)
const hasTitleCase = words.some((word, index) => {
// Skip first word
if (index === 0) {
return false;
}
// Skip acronyms (all uppercase)
if (word === word.toUpperCase()) {
return false;
}
// Skip whitelisted product words when preceded by an uppercase word
if (
productWords.includes(word) &&
index > 0 &&
words[index - 1] === words[index - 1].toUpperCase()
) {
return false;
}
// Check if it's a lowercase word that's incorrectly capitalized
if (
lowercaseWords.includes(word.toLowerCase()) &&
/^[A-Z]/.test(word)
) {
return true;
}
// For other words, check if they start with capital letter
return (
word.length > 1 &&
/^[A-Z]/.test(word) &&
!productWords.includes(word)
);
});
return hasTitleCase;
}
function handler(node) {
if (node.arguments.length) {
const firstArg = node.arguments[0];
let stringValue = null;
// Extract string value based on node type
if (
firstArg.type === 'Literal' &&
typeof firstArg.value === 'string'
) {
stringValue = firstArg.value;
} else if (
firstArg.type === 'TemplateLiteral' &&
firstArg.quasis.length === 1
) {
// Handle template literals without expressions
stringValue = firstArg.quasis[0].value.raw;
}
if (stringValue && checkTitleCase(stringValue)) {
context.report({
node: firstArg,
message: `Avoid title case in i18n strings: "${stringValue}". Use sentence case instead.`,
});
}
}
}
return {
"CallExpression[callee.name='t']": handler,
"CallExpression[callee.name='tn']": handler,
};
},
},
},
};

View File

@@ -1,152 +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.
*/
const { RuleTester } = require('eslint');
const plugin = require('./index');
const ruleTester = new RuleTester({
parserOptions: {
ecmaVersion: 6,
},
});
const rule = plugin.rules['no-title-case'];
ruleTester.run('no-title-case', rule, {
valid: [
// Sentence case (correct)
{
code: "t('Add a divider')",
},
{
code: "t('Create new dashboard')",
},
{
code: "t('Save and continue')",
},
// Single words
{
code: "t('Save')",
},
{
code: "t('Delete')",
},
// All uppercase (acronyms)
{
code: "t('SQL')",
},
{
code: "t('API KEY')",
},
// With placeholders
{
code: "t('Deleted: %s', name)",
},
{
code: "t('User %(username)s added', { username })",
},
// Template literals without expressions
{
code: 't(`Add a new filter`)',
},
// Mixed case but not title case
{
code: "t('Use SQL Lab')",
},
// tn function
{
code: "tn('Add a filter', 'Add filters', count)",
},
// Multiple sentences with period
{
code: "t('Welcome Back. Please Login.')",
},
{
code: "t('Save Changes. This Will Update All Records.')",
},
],
invalid: [
// Title case (incorrect)
{
code: "t('Add Divider')",
errors: [
{
message:
'Avoid title case in i18n strings: "Add Divider". Use sentence case instead.',
},
],
},
{
code: "t('Create New Dashboard')",
errors: [
{
message:
'Avoid title case in i18n strings: "Create New Dashboard". Use sentence case instead.',
},
],
},
{
code: "t('Save And Continue')",
errors: [
{
message:
'Avoid title case in i18n strings: "Save And Continue". Use sentence case instead.',
},
],
},
{
code: "t('Add Filter')",
errors: [
{
message:
'Avoid title case in i18n strings: "Add Filter". Use sentence case instead.',
},
],
},
{
code: "t('Edit User')",
errors: [
{
message:
'Avoid title case in i18n strings: "Edit User". Use sentence case instead.',
},
],
},
// Template literals
{
code: 't(`Add Layer`)',
errors: [
{
message:
'Avoid title case in i18n strings: "Add Layer". Use sentence case instead.',
},
],
},
// tn function
{
code: "tn('Delete Item', 'Delete Items', count)",
errors: [
{
message:
'Avoid title case in i18n strings: "Delete Item". Use sentence case instead.',
},
],
},
],
});

View File

@@ -10877,12 +10877,6 @@
"url": "https://opencollective.com/immer"
}
},
"node_modules/@reduxjs/toolkit/node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
"node_modules/@rjsf/core": {
"version": "5.24.1",
"resolved": "https://registry.npmjs.org/@rjsf/core/-/core-5.24.1.tgz",
@@ -24611,12 +24605,6 @@
"d3-time": "1 - 2"
}
},
"node_modules/encodable/node_modules/reselect": {
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -50641,9 +50629,9 @@
"license": "MIT"
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"version": "4.1.8",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-4.1.8.tgz",
"integrity": "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ==",
"license": "MIT"
},
"node_modules/resize-observer-polyfill": {
@@ -59097,7 +59085,7 @@
"csstype": "^3.1.3",
"d3-format": "^1.3.2",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale": "^3.0.0",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.13",
@@ -59120,7 +59108,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.1.1",
"reselect": "^4.0.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"xss": "^1.0.14"
@@ -59199,19 +59187,16 @@
"license": "BSD-3-Clause"
},
"packages/superset-ui-core/node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-3.3.0.tgz",
"integrity": "sha512-1JGp44NQCt5d1g+Yy+GeOnZP7xHo0ii8zsQp6PGzd+C1/dl0KGsp9A7Mxwp+1D1o4unbTTxVdU/ZOIEBoeZPbQ==",
"license": "BSD-3-Clause",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
"d3-array": "^2.3.0",
"d3-format": "1 - 2",
"d3-interpolate": "1.2.0 - 2",
"d3-time": "^2.1.1",
"d3-time-format": "2 - 3"
}
},
"packages/superset-ui-core/node_modules/d3-scale/node_modules/d3-interpolate": {

View File

@@ -30,10 +30,10 @@ export const DEFAULT_MAX_ROW_TABLE_SERVER = 500000;
// eslint-disable-next-line import/prefer-default-export
export const TIME_FILTER_LABELS = {
time_range: t('Time range'),
granularity_sqla: t('Time column'),
time_grain_sqla: t('Time grain'),
granularity: t('Time granularity'),
time_range: t('Time Range'),
granularity_sqla: t('Time Column'),
time_grain_sqla: t('Time Grain'),
granularity: t('Time Granularity'),
};
export const COLUMN_NAME_ALIASES: Record<string, string> = {

View File

@@ -41,53 +41,6 @@ import {
import { checkColumnType } from '../utils/checkColumnType';
import { isSortable } from '../utils/isSortable';
// Aggregation choices with computation methods for plugins and controls
export const aggregationChoices = {
raw: {
label: 'Overall value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => {
if (!data.length) return null;
return data[0];
},
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) : null,
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.length ? data.reduce((a, b) => a + b, 0) / data.length : null,
},
min: {
label: 'Minimum',
compute: (data: number[]) => (data.length ? Math.min(...data) : null),
},
max: {
label: 'Maximum',
compute: (data: number[]) => (data.length ? Math.max(...data) : null),
},
median: {
label: 'Median',
compute: (data: number[]) => {
if (!data.length) return null;
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
} as const;
export const contributionModeControl = {
name: 'contributionMode',
config: {
@@ -116,12 +69,17 @@ export const aggregationControl = {
default: 'LAST_VALUE',
clearable: false,
renderTrigger: false,
choices: Object.entries(aggregationChoices).map(([value, { label }]) => [
value,
t(label),
]),
choices: [
['raw', t('None')],
['LAST_VALUE', t('Last Value')],
['sum', t('Total (Sum)')],
['mean', t('Average (Mean)')],
['min', t('Minimum')],
['max', t('Maximum')],
['median', t('Median')],
],
description: t(
'Method to compute the displayed value. "Overall value" calculates a single metric across the entire filtered time period, ideal for non-additive metrics like ratios, averages, or distinct counts. Other methods operate over the time series data points.',
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
),
provideFormDataToProps: true,
mapStateToProps: ({ form_data }: ControlPanelState) => ({

View File

@@ -37,7 +37,7 @@
"d3-format": "^1.3.2",
"dayjs": "^1.11.13",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-scale": "^3.0.0",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dompurify": "^3.2.4",
@@ -59,7 +59,7 @@
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"reselect": "^5.1.1",
"reselect": "^4.0.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
"@visx/responsive": "^3.12.0",

View File

@@ -17,8 +17,11 @@
* under the License.
*/
/** Type checking is disabled for this file due to reselect only supporting
* TS declarations for selectors with up to 12 arguments. */
// @ts-nocheck
import { RefObject } from 'react';
import { createSelector, lruMemoize } from 'reselect';
import { createSelector } from 'reselect';
import {
AppSection,
Behavior,
@@ -34,7 +37,7 @@ import {
SetDataMaskHook,
} from '../types/Base';
import { QueryData, DataRecordFilters } from '..';
import { supersetTheme, SupersetTheme } from '../../theme';
import { SupersetTheme } from '../../theme';
// TODO: more specific typing for these fields of ChartProps
type AnnotationData = PlainObject;
@@ -106,8 +109,6 @@ export interface ChartPropsConfig {
theme: SupersetTheme;
/* legend index */
legendIndex?: number;
inContextMenu?: boolean;
emitCrossFilters?: boolean;
}
const DEFAULT_WIDTH = 800;
@@ -160,11 +161,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
theme: SupersetTheme;
constructor(
config: ChartPropsConfig & { formData?: FormData } = {
theme: supersetTheme,
},
) {
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
const {
annotationData = {},
datasource = {},
@@ -279,16 +276,5 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
emitCrossFilters,
theme,
}),
// Below config is to retain usage of 1-sized `lruMemoize` object in Reselect v4
// Reselect v5 introduces `weakMapMemoize` which is more performant but potentially memory-leaky
// due to infinite cache size.
// Source: https://github.com/reduxjs/reselect/releases/tag/v5.0.1
{
memoize: lruMemoize,
argsMemoize: lruMemoize,
memoizeOptions: {
maxSize: 10,
},
},
);
};

View File

@@ -16,8 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t } from '@superset-ui/core';
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
import { t, css, useTheme } from '@superset-ui/core';
import {
Icons,
Modal,
Typography,
Button,
Flex,
} from '@superset-ui/core/components';
import type { FC, ReactElement } from 'react';
export type UnsavedChangesModalProps = {
@@ -36,30 +42,66 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
onConfirmNavigation,
title = 'Unsaved Changes',
body = "If you don't save, changes will be lost.",
}: UnsavedChangesModalProps): ReactElement => (
<Modal
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<>
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
{title}
</>
}
footer={
<>
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
{t('Discard')}
</Button>
<Button buttonStyle="primary" onClick={handleSave}>
{t('Save')}
</Button>
</>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
}): ReactElement => {
const theme = useTheme();
return (
<Modal
name={title}
centered
responsive
onHide={onHide}
show={showModal}
width="444px"
title={
<Flex>
<Icons.WarningOutlined
iconColor={theme.colorWarning}
css={css`
margin-right: ${theme.sizeUnit * 2}px;
`}
iconSize="l"
/>
<Typography.Title
css={css`
&& {
margin: 0;
margin-bottom: 0;
}
`}
level={5}
>
{title}
</Typography.Title>
</Flex>
}
footer={
<Flex
justify="flex-end"
css={css`
width: 100%;
`}
>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="secondary"
onClick={onConfirmNavigation}
>
{t('Discard')}
</Button>
<Button
htmlType="button"
buttonSize="small"
buttonStyle="primary"
onClick={handleSave}
>
{t('Save')}
</Button>
</Flex>
}
>
<Typography.Text>{body}</Typography.Text>
</Modal>
);
};

View File

@@ -119,7 +119,7 @@ describe('ChartProps', () => {
});
expect(props1).not.toBe(props2);
});
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
it('selector returns a new chartProps if some input fields change', () => {
const props1 = selector({
width: 800,
height: 600,
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
theme: supersetTheme,
});
expect(props1).not.toBe(props2);
expect(props1).toBe(props3);
expect(props1).not.toBe(props3);
});
});
});

View File

@@ -1385,7 +1385,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
((dims = dimensionsForPoint(p)),
(dims = dimensionsForPoint(p)),
(strum = {
p1: p,
dims: dims,
@@ -1393,7 +1393,7 @@ export default function (config) {
maxX: xscale(dims.right),
minY: 0,
maxY: h(),
}));
});
strums[dims.i] = strum;
strums.active = dims.i;
@@ -1942,7 +1942,7 @@ export default function (config) {
p[0] = p[0] - __.margin.left;
p[1] = p[1] - __.margin.top;
((dims = dimensionsForPoint(p)),
(dims = dimensionsForPoint(p)),
(arc = {
p1: p,
dims: dims,
@@ -1953,7 +1953,7 @@ export default function (config) {
startAngle: undefined,
endAngle: undefined,
arc: d3.svg.arc().innerRadius(0),
}));
});
arcs[dims.i] = arc;
arcs.active = dims.i;

View File

@@ -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>

View File

@@ -49,53 +49,38 @@ describe('BigNumberWithTrendline buildQuery', () => {
aggregation: null,
};
it('creates raw metric query when aggregation is "raw"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
it('creates raw metric query when aggregation is null', () => {
const queryContext = buildQuery({ ...baseFormData });
const bigNumberQuery = queryContext.queries[1];
expect(bigNumberQuery.post_processing).toEqual([]);
expect(bigNumberQuery.is_timeseries).toBe(false);
expect(bigNumberQuery.columns).toEqual([]);
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns single query for aggregation methods that can be computed client-side', () => {
it('adds aggregation operator when aggregation is "sum"', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
const bigNumberQuery = queryContext.queries[1];
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
expect(bigNumberQuery.post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
{ operation: 'aggregation', options: { operator: 'sum' } },
]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns single query for LAST_VALUE aggregation', () => {
it('skips aggregation when aggregation is LAST_VALUE', () => {
const queryContext = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
const bigNumberQuery = queryContext.queries[1];
expect(queryContext.queries.length).toBe(1);
expect(queryContext.queries[0].post_processing).toEqual([
{ operation: 'pivot' },
{ operation: 'rolling' },
{ operation: 'resample' },
{ operation: 'flatten' },
]);
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
expect(bigNumberQuery.is_timeseries).toBe(true);
});
it('returns two queries only for raw aggregation', () => {
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
it('always returns two queries', () => {
const queryContext = buildQuery({ ...baseFormData });
expect(queryContext.queries.length).toBe(2);
const queryContextLastValue = buildQuery({
...baseFormData,
aggregation: 'LAST_VALUE',
});
expect(queryContextLastValue.queries.length).toBe(1);
const queryContextSum = buildQuery({ ...baseFormData, aggregation: 'sum' });
expect(queryContextSum.queries.length).toBe(1);
});
});

View File

@@ -39,37 +39,28 @@ export default function buildQuery(formData: QueryFormData) {
? ensureIsArray(getXAxisColumn(formData))
: [];
return buildQueryContext(formData, baseQueryObject => {
const queries = [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
].filter(Boolean),
},
];
// Only add second query for raw metrics which need different query structure
// All other aggregations (sum, mean, min, max, median, LAST_VALUE) can be computed client-side from trendline data
if (formData.aggregation === 'raw') {
queries.push({
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: ([
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
].filter(Boolean) as any[]),
});
}
return queries;
});
return buildQueryContext(formData, baseQueryObject => [
{
...baseQueryObject,
columns: [...timeColumn],
...(timeColumn.length ? {} : { is_timeseries: true }),
post_processing: [
pivotOperator(formData, baseQueryObject),
rollingWindowOperator(formData, baseQueryObject),
resampleOperator(formData, baseQueryObject),
flattenOperator(formData, baseQueryObject),
],
},
{
...baseQueryObject,
columns: [...(isRawMetric ? [] : timeColumn)],
is_timeseries: !isRawMetric,
post_processing: isRawMetric
? []
: [
pivotOperator(formData, baseQueryObject),
aggregationOperator(formData, baseQueryObject),
],
},
]);
}

View File

@@ -20,41 +20,6 @@ import { GenericDataType } from '@superset-ui/core';
import transformProps from './transformProps';
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
// Mock chart-controls to avoid styled-components issues in Jest
jest.mock('@superset-ui/chart-controls', () => ({
aggregationChoices: {
raw: {
label: 'Force server-side aggregation',
compute: (data: number[]) => data[0] ?? null,
},
LAST_VALUE: {
label: 'Last Value',
compute: (data: number[]) => data[0] ?? null,
},
sum: {
label: 'Total (Sum)',
compute: (data: number[]) => data.reduce((a, b) => a + b, 0),
},
mean: {
label: 'Average (Mean)',
compute: (data: number[]) =>
data.reduce((a, b) => a + b, 0) / data.length,
},
min: { label: 'Minimum', compute: (data: number[]) => Math.min(...data) },
max: { label: 'Maximum', compute: (data: number[]) => Math.max(...data) },
median: {
label: 'Median',
compute: (data: number[]) => {
const sorted = [...data].sort((a, b) => a - b);
const mid = Math.floor(sorted.length / 2);
return sorted.length % 2 === 0
? (sorted[mid - 1] + sorted[mid]) / 2
: sorted[mid];
},
},
},
}));
jest.mock('@superset-ui/core', () => ({
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
@@ -253,7 +218,7 @@ describe('BigNumberWithTrendline transformProps', () => {
coltypes: ['NUMERIC'],
},
],
formData: { ...baseFormData, aggregation: 'sum' },
formData: { ...baseFormData, aggregation: 'SUM' },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,

View File

@@ -29,7 +29,6 @@ import {
tooltipHtml,
} from '@superset-ui/core';
import { EChartsCoreOption, graphic } from 'echarts/core';
import { aggregationChoices } from '@superset-ui/chart-controls';
import {
BigNumberVizProps,
BigNumberDatum,
@@ -44,31 +43,6 @@ const formatPercentChange = getNumberFormatter(
NumberFormats.PERCENT_SIGNED_1_POINT,
);
// Client-side aggregation function using shared aggregationChoices
function computeClientSideAggregation(
data: [number | null, number | null][],
aggregation: string | undefined | null,
): number | null {
if (!data.length) return null;
// Find the aggregation method, handling case variations
const methodKey = Object.keys(aggregationChoices).find(
key => key.toLowerCase() === (aggregation || '').toLowerCase(),
);
// Use the compute method from aggregationChoices, fallback to LAST_VALUE
const selectedMethod = methodKey
? aggregationChoices[methodKey as keyof typeof aggregationChoices]
: aggregationChoices.LAST_VALUE;
// Extract values from tuple array and filter out nulls
const values = data
.map(([, value]) => value)
.filter((v): v is number => v !== null);
return selectedMethod.compute(values);
}
export default function transformProps(
chartProps: BigNumberWithTrendlineChartProps,
): BigNumberVizProps {
@@ -152,33 +126,27 @@ export default function transformProps(
// sort in time descending order
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
}
if (sortedData.length > 0) {
timestamp = sortedData[0][0];
// Raw aggregation uses server-side data, all others use client-side
if (aggregation === 'raw' && hasAggregatedData && aggregatedData) {
// Use server-side aggregation for raw
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber =
metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
if (hasAggregatedData && aggregatedData) {
if (
aggregatedData[metricName] !== null &&
aggregatedData[metricName] !== undefined
) {
bigNumber = aggregatedData[metricName];
} else {
// Use client-side aggregation for all other methods
bigNumber = computeClientSideAggregation(sortedData, aggregation);
const metricKeys = Object.keys(aggregatedData).filter(
key =>
key !== xAxisLabel &&
aggregatedData[key] !== null &&
typeof aggregatedData[key] === 'number',
);
bigNumber = metricKeys.length > 0 ? aggregatedData[metricKeys[0]] : null;
}
// Handle null bigNumber case
timestamp = sortedData.length > 0 ? sortedData[0][0] : null;
} else if (sortedData.length > 0) {
bigNumber = sortedData[0][1];
timestamp = sortedData[0][0];
if (bigNumber === null) {
bigNumberFallback = sortedData.find(d => d[1] !== null);
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;

View File

@@ -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>],
[

View File

@@ -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[]),

View File

@@ -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,

View File

@@ -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 = {

View File

@@ -128,10 +128,9 @@ describe('BigNumberWithTrendline', () => {
expect(lastDatum?.[0]).toStrictEqual(100);
expect(lastDatum?.[1]).toBeNull();
// should get the last non-null value
// should note this is a fallback
expect(transformed.bigNumber).toStrictEqual(1.2345);
// bigNumberFallback is only set when bigNumber is null after aggregation
expect(transformed.bigNumberFallback).toBeNull();
expect(transformed.bigNumberFallback).not.toBeNull();
// should successfully formatTime by granularity
// @ts-ignore

View File

@@ -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');
});

View File

@@ -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>

View File

@@ -46,7 +46,7 @@ const ExploreResultsButton = ({
tooltip={t('Explore the result set in the data exploration view')}
data-test="explore-results-button"
>
{t('Create chart')}
{t('Create Chart')}
</Button>
);
};

View File

@@ -51,7 +51,7 @@ export const KEY_MAP: Record<KeyboardShortcut, string | undefined> = {
[KeyboardShortcut.CtrlE]: userOS !== 'MacOS' ? t('Stop query') : undefined,
[KeyboardShortcut.CtrlQ]: userOS === 'Windows' ? t('New tab') : undefined,
[KeyboardShortcut.CtrlT]: userOS !== 'Windows' ? t('New tab') : undefined,
[KeyboardShortcut.CtrlP]: t('Previous line'),
[KeyboardShortcut.CtrlP]: t('Previous Line'),
[KeyboardShortcut.CtrlShiftF]: t('Format SQL'),
[KeyboardShortcut.CtrlLeft]: t('Switch to the previous tab'),
[KeyboardShortcut.CtrlRight]: t('Switch to the next tab'),

View File

@@ -158,7 +158,7 @@ const updateDataset = async (
return data.json.result;
};
const UNTITLED = t('Untitled dataset');
const UNTITLED = t('Untitled Dataset');
export const SaveDatasetModal = ({
visible,
@@ -374,10 +374,10 @@ export const SaveDatasetModal = ({
return (
<Modal
show={visible}
name={t('Save or overwrite dataset')}
name={t('Save or Overwrite Dataset')}
title={
<ModalTitleWithIcon
title={t('Save or overwrite dataset')}
title={t('Save or Overwrite Dataset')}
icon={<Icons.SaveOutlined />}
data-test="save-or-overwrite-dataset-title"
/>
@@ -394,7 +394,7 @@ export const SaveDatasetModal = ({
}
/>
<span style={{ marginLeft: '5px' }}>
{t('Include template parameters')}
{t('Include Template Parameters')}
</span>
</div>
)}

View File

@@ -18,4 +18,3 @@
*/
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';

View File

@@ -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,
};
};

View File

@@ -164,7 +164,7 @@ const v1ChartDataRequest = async (
ownState,
parseMethod,
) => {
const payload = await buildV1ChartDataPayload({
const payload = buildV1ChartDataPayload({
formData,
resultType,
resultFormat,
@@ -255,7 +255,7 @@ export function runAnnotationQuery({
isDashboardRequest = false,
force = false,
}) {
return async function (dispatch, getState) {
return function (dispatch, getState) {
const { charts, common } = getState();
const sliceKey = key || Object.keys(charts)[0];
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
@@ -310,19 +310,17 @@ export function runAnnotationQuery({
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
}
const payload = await buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
});
return SupersetClient.post({
url,
signal,
timeout: queryTimeout * 1000,
headers: { 'Content-Type': 'application/json' },
jsonPayload: payload,
jsonPayload: buildV1ChartDataPayload({
formData: fd,
force,
resultFormat: 'json',
resultType: 'full',
}),
})
.then(({ json }) => {
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
@@ -422,8 +420,6 @@ export function exploreJSON(
const setDataMask = dataMask => {
dispatch(updateDataMask(formData.slice_id, dataMask));
};
dispatch(chartUpdateStarted(controller, formData, key));
const chartDataRequest = getChartDataRequest({
setDataMask,
formData,
@@ -435,6 +431,8 @@ export function exploreJSON(
ownState,
});
dispatch(chartUpdateStarted(controller, formData, key));
const [useLegacyApi] = getQuerySettings(formData);
const chartDataRequestCaught = chartDataRequest
.then(({ response, json }) =>

View File

@@ -64,7 +64,6 @@ describe('chart actions', () => {
let dispatch;
let getExploreUrlStub;
let getChartDataUriStub;
let buildV1ChartDataPayloadStub;
let waitForAsyncDataStub;
let fakeMetadata;
@@ -86,13 +85,6 @@ describe('chart actions', () => {
getChartDataUriStub = sinon
.stub(exploreUtils, 'getChartDataUri')
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
buildV1ChartDataPayloadStub = sinon
.stub(exploreUtils, 'buildV1ChartDataPayload')
.resolves({
some_param: 'fake query!',
result_type: 'full',
result_format: 'json',
});
fakeMetadata = { useLegacyApi: true };
getChartMetadataRegistry.mockImplementation(() => ({
get: () => fakeMetadata,
@@ -112,7 +104,6 @@ describe('chart actions', () => {
afterEach(() => {
getExploreUrlStub.restore();
getChartDataUriStub.restore();
buildV1ChartDataPayloadStub.restore();
fetchMock.resetHistory();
waitForAsyncDataStub.restore();
@@ -371,7 +362,7 @@ describe('chart actions timeout', () => {
jest.clearAllMocks();
});
it('should use the timeout from arguments when given', async () => {
it('should use the timeout from arguments when given', () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const timeout = 10; // Set the timeout value here
@@ -379,7 +370,7 @@ describe('chart actions timeout', () => {
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
await store.dispatch(
store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',
@@ -403,14 +394,14 @@ describe('chart actions timeout', () => {
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
});
it('should use the timeout from common.conf when not passed as an argument', async () => {
it('should use the timeout from common.conf when not passed as an argument', () => {
const postSpy = jest.spyOn(SupersetClient, 'post');
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
const formData = { datasource: 'table__1' }; // Set the formData here
const key = 'chartKey'; // Set the chart key here
const store = mockStore(initialState);
await store.dispatch(
store.dispatch(
actions.runAnnotationQuery({
annotation: {
value: 'annotationValue',

View File

@@ -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];

View File

@@ -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>;

View File

@@ -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"

View File

@@ -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();
});

View File

@@ -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;

View File

@@ -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 },
);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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>

View File

@@ -637,7 +637,7 @@ function ExploreViewContainer(props) {
}
>
<div className="title-container">
<span className="horizontal-text">{t('Chart source')}</span>
<span className="horizontal-text">{t('Chart Source')}</span>
<span
role="button"
tabIndex={0}
@@ -672,7 +672,7 @@ function ExploreViewContainer(props) {
tabIndex={0}
>
<span role="button" tabIndex={0} className="action-button">
<Tooltip title={t('Open datasource tab')}>
<Tooltip title={t('Open Datasource tab')}>
<Icons.VerticalAlignTopOutlined
iconSize="xl"
css={css`

View File

@@ -370,7 +370,7 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
/>
</FormItem>
{this.props.datasource?.type === 'query' && (
<FormItem label={t('Dataset name')} required>
<FormItem label={t('Dataset Name')} required>
<InfoTooltip
tooltip={t('A reusable dataset will be saved with your chart.')}
placement="right"

View File

@@ -91,7 +91,7 @@ afterEach(() => {
});
const getFormatSwitch = () =>
screen.getByRole('switch', { name: 'formatted original' });
screen.getByRole('switch', { name: 'Show original SQL' });
test('renders the component with Formatted SQL and buttons', async () => {
const { container } = setup(mockProps);

View File

@@ -26,17 +26,11 @@ import {
} from 'react';
import { useSelector } from 'react-redux';
import rison from 'rison';
import { styled, SupersetClient, t, useTheme } from '@superset-ui/core';
import {
Icons,
Switch,
Button,
Skeleton,
Card,
Space,
} from '@superset-ui/core/components';
import { styled, SupersetClient, t } from '@superset-ui/core';
import { Icons, Switch, Button, Skeleton } from '@superset-ui/core/components';
import { CopyToClipboard } from 'src/components';
import { RootState } from 'src/dashboard/types';
import { CopyButton } from 'src/explore/components/DataTableControl';
import { findPermission } from 'src/utils/findPermission';
import CodeSyntaxHighlighter, {
SupportedLanguage,
@@ -44,6 +38,14 @@ import CodeSyntaxHighlighter, {
} from '@superset-ui/core/components/CodeSyntaxHighlighter';
import { useHistory } from 'react-router-dom';
const CopyButtonViewQuery = styled(CopyButton)`
${({ theme }) => `
&& {
margin: 0 0 ${theme.sizeUnit}px;
}
`}
`;
export interface ViewQueryProps {
sql: string;
datasource: string;
@@ -56,14 +58,26 @@ const StyledSyntaxContainer = styled.div`
flex-direction: column;
`;
const StyledHeaderMenuContainer = styled.div`
display: flex;
flex-direction: row;
justify-content: space-between;
margin-top: ${({ theme }) => -theme.sizeUnit * 4}px;
align-items: flex-end;
`;
const StyledHeaderActionContainer = styled.div`
display: flex;
flex-direction: row;
column-gap: ${({ theme }) => theme.sizeUnit * 2}px;
`;
const StyledThemedSyntaxHighlighter = styled(CodeSyntaxHighlighter)`
flex: 1;
`;
const StyledFooter = styled.div`
display: flex;
justify-content: space-between;
align-items: center;
const StyledLabel = styled.label`
font-size: ${({ theme }) => theme.fontSize}px;
`;
const DATASET_BACKEND_QUERY = {
@@ -73,7 +87,6 @@ const DATASET_BACKEND_QUERY = {
const ViewQuery: FC<ViewQueryProps> = props => {
const { sql, language = 'sql', datasource } = props;
const theme = useTheme();
const datasetId = datasource.split('__')[0];
const [formattedSQL, setFormattedSQL] = useState<string>();
const [showFormatSQL, setShowFormatSQL] = useState(true);
@@ -140,57 +153,46 @@ const ViewQuery: FC<ViewQueryProps> = props => {
}, [sql]);
return (
<Card bodyStyle={{ padding: theme.sizeUnit * 4 }}>
<StyledSyntaxContainer key={sql}>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1, marginBottom: theme.sizeUnit * 3 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
<StyledFooter>
<Space size={theme.sizeUnit * 2}>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<Button
buttonStyle="secondary"
buttonSize="small"
icon={<Icons.CopyOutlined />}
>
{t('Copy')}
</Button>
}
/>
{canAccessSQLLab && (
<Button
buttonStyle="secondary"
<StyledSyntaxContainer key={sql}>
<StyledHeaderMenuContainer>
<StyledHeaderActionContainer>
<CopyToClipboard
text={currentSQL}
shouldShowText={false}
copyNode={
<CopyButtonViewQuery
buttonSize="small"
onClick={navToSQLLab}
icon={<Icons.CopyOutlined />}
>
{t('View in SQL Lab')}
</Button>
)}
</Space>
<Space size={theme.sizeUnit * 2} align="center">
<Icons.ConsoleSqlOutlined />
<Switch
id="formatSwitch"
checked={showFormatSQL}
onChange={formatCurrentQuery}
checkedChildren={t('formatted')}
unCheckedChildren={t('original')}
/>
</Space>
</StyledFooter>
</StyledSyntaxContainer>
</Card>
{t('Copy')}
</CopyButtonViewQuery>
}
/>
{canAccessSQLLab && (
<Button onClick={navToSQLLab}>{t('View in SQL Lab')}</Button>
)}
</StyledHeaderActionContainer>
<StyledHeaderActionContainer>
<Switch
id="formatSwitch"
checked={!showFormatSQL}
onChange={formatCurrentQuery}
/>
<StyledLabel htmlFor="formatSwitch">
{t('Show original SQL')}
</StyledLabel>
</StyledHeaderActionContainer>
</StyledHeaderMenuContainer>
{!formattedSQL && <Skeleton active />}
{formattedSQL && (
<StyledThemedSyntaxHighlighter
language={language}
customStyle={{ flex: 1 }}
>
{currentSQL}
</StyledThemedSyntaxHighlighter>
)}
</StyledSyntaxContainer>
);
};

View File

@@ -42,7 +42,6 @@ const ViewQueryModalContainer = styled.div`
height: 100%;
display: flex;
flex-direction: column;
gap: ${({ theme }) => theme.sizeUnit * 4}px;
`;
const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
@@ -87,10 +86,9 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
return (
<ViewQueryModalContainer>
{result.map((item, index) =>
{result.map(item =>
item.query ? (
<ViewQuery
key={`query-${index}`}
datasource={latestQueryFormData.datasource}
sql={item.query}
language="sql"

View File

@@ -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];
};

View File

@@ -191,8 +191,8 @@ describe('exploreUtils', () => {
});
describe('buildV1ChartDataPayload', () => {
it('generate valid request payload despite no registered buildQuery', async () => {
const v1RequestPayload = await buildV1ChartDataPayload({
it('generate valid request payload despite no registered buildQuery', () => {
const v1RequestPayload = buildV1ChartDataPayload({
formData: { ...formData, viz_type: 'my_custom_viz' },
});
expect(v1RequestPayload.hasOwnProperty('queries')).toBeTruthy();

View File

@@ -207,7 +207,7 @@ export const getQuerySettings = formData => {
];
};
export const buildV1ChartDataPayload = async ({
export const buildV1ChartDataPayload = ({
formData,
force,
resultFormat,
@@ -242,7 +242,7 @@ export const buildV1ChartDataPayload = async ({
export const getLegacyEndpointType = ({ resultType, resultFormat }) =>
resultFormat === 'csv' ? resultFormat : resultType;
export const exportChart = async ({
export const exportChart = ({
formData,
resultFormat = 'json',
resultType = 'full',
@@ -262,7 +262,7 @@ export const exportChart = async ({
payload = formData;
} else {
url = ensureAppRoot('/api/v1/chart/data');
payload = await buildV1ChartDataPayload({
payload = buildV1ChartDataPayload({
formData,
force,
resultFormat,

View File

@@ -16,12 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
waitFor,
userEvent,
} from 'spec/helpers/testing-library';
import { render, screen } from 'spec/helpers/testing-library';
import Footer from 'src/features/datasets/AddDataset/Footer';
const mockHistoryPush = jest.fn();
@@ -32,14 +27,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
// Mock the API call
const mockCreateResource = jest.fn();
jest.mock('src/views/CRUD/hooks', () => ({
useSingleViewResource: () => ({
createResource: mockCreateResource,
}),
}));
const mockedProps = {
url: 'realwebsite.com',
};
@@ -47,7 +34,7 @@ const mockedProps = {
const mockPropsWithDataset = {
url: 'realwebsite.com',
datasetObject: {
db: {
database: {
id: '1',
database_name: 'examples',
},
@@ -60,10 +47,6 @@ const mockPropsWithDataset = {
};
describe('Footer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders a Footer with a cancel button and a disabled create button', () => {
render(<Footer {...mockedProps} />, { useRedux: true });
@@ -72,28 +55,21 @@ describe('Footer', () => {
});
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
name: /Create/i,
});
expect(saveButton).toBeVisible();
expect(createButton).toBeDisabled();
});
test('renders a Create Dataset dropdown button when a table is selected', () => {
test('renders a Create Dataset button when a table is selected', () => {
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
name: /Create/i,
});
expect(createButton).toBeEnabled();
// Check that it's a dropdown button with the correct text
expect(createButton).toHaveTextContent('Create dataset and create chart');
// Check for the dropdown arrow
const dropdownArrow = screen.getByRole('img', { hidden: true });
expect(dropdownArrow).toBeInTheDocument();
});
test('create button becomes disabled when table already has a dataset', () => {
@@ -102,119 +78,9 @@ describe('Footer', () => {
});
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
name: /Create/i,
});
expect(createButton).toBeDisabled();
});
test('shows dropdown menu when dropdown arrow is clicked', async () => {
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
// Find and click the dropdown trigger (the arrow part)
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
userEvent.click(dropdownTrigger);
// Check that the dropdown menu option is visible
await waitFor(() => {
expect(screen.getByText('Create dataset only')).toBeVisible();
});
});
test('navigates to chart creation when main button is clicked', async () => {
mockCreateResource.mockResolvedValue(123); // Mock successful dataset creation
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: undefined,
schema: 'public',
table_name: 'real_info',
});
expect(mockHistoryPush).toHaveBeenCalledWith(
'/chart/add/?dataset=real_info',
);
});
});
test('navigates to dataset list when "Create dataset only" menu option is clicked', async () => {
mockCreateResource.mockResolvedValue(123);
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
// Open dropdown menu
const dropdownTrigger = screen.getByRole('button', { name: 'down' });
userEvent.click(dropdownTrigger);
// Click the "Create dataset only" option
await waitFor(() => {
const datasetOnlyOption = screen.getByText('Create dataset only');
userEvent.click(datasetOnlyOption);
});
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: undefined,
schema: 'public',
table_name: 'real_info',
});
expect(mockHistoryPush).toHaveBeenCalledWith('/tablemodelview/list/');
});
});
test('handles dataset creation failure gracefully', async () => {
mockCreateResource.mockResolvedValue(null); // Mock failed dataset creation
render(<Footer {...mockPropsWithDataset} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalled();
// Should not navigate if creation failed
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
test('passes correct data to createResource with catalog', async () => {
const mockPropsWithCatalog = {
...mockPropsWithDataset,
datasetObject: {
...mockPropsWithDataset.datasetObject,
catalog: 'test_catalog',
},
};
mockCreateResource.mockResolvedValue(456);
render(<Footer {...mockPropsWithCatalog} />, { useRedux: true });
const createButton = screen.getByRole('button', {
name: /Create dataset and create chart/i,
});
userEvent.click(createButton);
await waitFor(() => {
expect(mockCreateResource).toHaveBeenCalledWith({
database: '1',
catalog: 'test_catalog',
schema: 'public',
table_name: 'real_info',
});
});
});
});

View File

@@ -17,14 +17,8 @@
* under the License.
*/
import { useHistory } from 'react-router-dom';
import {
Button,
DropdownButton,
Menu,
Flex,
} from '@superset-ui/core/components';
import { t, useTheme } from '@superset-ui/core';
import { Icons } from '@superset-ui/core/components/Icons';
import { Button } from '@superset-ui/core/components';
import { t } from '@superset-ui/core';
import { useSingleViewResource } from 'src/views/CRUD/hooks';
import { logEvent } from 'src/logger/actions';
import withToasts from 'src/components/MessageToasts/withToasts';
@@ -61,7 +55,6 @@ function Footer({
datasets,
}: FooterProps) {
const history = useHistory();
const theme = useTheme();
const { createResource } = useSingleViewResource<Partial<DatasetObject>>(
'dataset',
t('dataset'),
@@ -92,7 +85,7 @@ function Footer({
const tooltipText = t('Select a database table.');
const onSave = (createChart: boolean = true) => {
const onSave = () => {
if (datasetObject) {
const data = {
database: datasetObject.db?.id,
@@ -107,57 +100,32 @@ function Footer({
if (typeof response === 'number') {
logEvent(LOG_ACTIONS_DATASET_CREATION_SUCCESS, datasetObject);
// When a dataset is created the response we get is its ID number
if (createChart) {
history.push(`/chart/add/?dataset=${datasetObject.table_name}`);
} else {
history.push('/tablemodelview/list/');
}
history.push(`/chart/add/?dataset=${datasetObject.table_name}`);
}
});
}
};
const onSaveOnly = () => {
onSave(false);
};
const CREATE_DATASET_TEXT = t('Create dataset and create chart');
const CREATE_DATASET_ONLY_TEXT = t('Create dataset only');
const disabledCheck =
!datasetObject?.table_name ||
!hasColumns ||
datasets?.includes(datasetObject?.table_name);
const dropdownMenu = (
<Menu>
<Menu.Item key="create-only" onClick={onSaveOnly}>
{CREATE_DATASET_ONLY_TEXT}
</Menu.Item>
</Menu>
);
return (
<Flex align="center" justify="flex-end" gap="8px">
<>
<Button buttonStyle="secondary" onClick={cancelButtonOnClick}>
{t('Cancel')}
</Button>
<DropdownButton
type="primary"
<Button
buttonStyle="primary"
disabled={disabledCheck}
tooltip={!datasetObject?.table_name ? tooltipText : undefined}
onClick={() => onSave(true)}
popupRender={() => dropdownMenu}
icon={
<Icons.DownOutlined
iconSize="xs"
iconColor={theme.colors.grayscale.light5}
/>
}
trigger={['click']}
onClick={onSave}
>
{CREATE_DATASET_TEXT}
</DropdownButton>
</Flex>
</Button>
</>
);
}

View File

@@ -43,7 +43,6 @@ interface MenuProps {
const StyledHeader = styled.header`
${({ theme }) => `
background-color: ${theme.colorBgContainer};
border-bottom: 1px solid ${theme.colorBorderSecondary};
z-index: 10;
&:nth-last-of-type(2) nav {

View File

@@ -18,11 +18,12 @@
*/
import { act, render, screen, userEvent } from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { useHeaderReportMenuItems, HeaderReportProps } from './index';
import { Menu } from '@superset-ui/core/components/Menu';
import HeaderReportDropdown, { HeaderReportProps } from '.';
const createProps = () => ({
dashboardId: 1,
useTextMenu: false,
setShowReportSubMenu: jest.fn,
showReportModal: jest.fn,
setCurrentReportDeleting: jest.fn,
@@ -114,14 +115,13 @@ const stateWithUserAndReport = {
},
};
const MenuWrapper = (props: HeaderReportProps) => {
const reportMenuItems = useHeaderReportMenuItems(props);
const menuItems: MenuItem[] = [reportMenuItems];
return <Menu items={menuItems} forceSubMenuRender />;
};
function setup(props: HeaderReportProps, initialState = {}) {
render(<MenuWrapper {...props} />, { useRedux: true, initialState });
render(
<Menu>
<HeaderReportDropdown {...props} />
</Menu>,
{ useRedux: true, initialState },
);
}
jest.mock('@superset-ui/core', () => ({
@@ -147,7 +147,7 @@ describe('Header Report Dropdown', () => {
act(() => {
setup(mockedProps, stateWithUserAndReport);
});
expect(screen.getAllByRole('menuitem')[0]).toBeInTheDocument();
expect(screen.getByRole('menuitem')).toBeInTheDocument();
});
it('renders the dropdown correctly', async () => {
@@ -155,6 +155,8 @@ describe('Header Report Dropdown', () => {
act(() => {
setup(mockedProps, stateWithUserAndReport);
});
const emailReportModalButton = screen.getByRole('menuitem');
userEvent.hover(emailReportModalButton);
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
expect(screen.getByText('Edit email report')).toBeInTheDocument();
expect(screen.getByText('Delete email report')).toBeInTheDocument();
@@ -166,6 +168,8 @@ describe('Header Report Dropdown', () => {
act(() => {
setup(mockedProps, stateWithUserAndReport);
});
const emailReportModalButton = screen.getByRole('menuitem');
userEvent.click(emailReportModalButton);
const editModal = await screen.findByText('Edit email report');
userEvent.click(editModal);
expect(mockedProps.showReportModal).toHaveBeenCalled();
@@ -177,34 +181,49 @@ describe('Header Report Dropdown', () => {
act(() => {
setup(mockedProps, stateWithUserAndReport);
});
const emailReportModalButton = screen.getByRole('menuitem');
userEvent.click(emailReportModalButton);
const deleteModal = await screen.findByText('Delete email report');
userEvent.click(deleteModal);
expect(mockedProps.setCurrentReportDeleting).toHaveBeenCalled();
});
it('renders Manage Email Reports Menu if there is a report', async () => {
const mockedProps = createProps();
it('renders Manage Email Reports Menu if textMenu is set to true and there is a report', async () => {
let mockedProps = createProps();
mockedProps = {
...mockedProps,
useTextMenu: true,
};
act(() => {
setup(mockedProps, stateWithUserAndReport);
});
userEvent.click(screen.getByRole('menuitem'));
expect(await screen.findByText('Email reports active')).toBeInTheDocument();
expect(screen.getByText('Edit email report')).toBeInTheDocument();
expect(screen.getByText('Delete email report')).toBeInTheDocument();
});
it('renders Schedule Email Reports if there is a report', async () => {
const mockedProps = createProps();
it('renders Schedule Email Reports if textMenu is set to true and there is a report', async () => {
let mockedProps = createProps();
mockedProps = {
...mockedProps,
useTextMenu: true,
};
act(() => {
setup(mockedProps, stateWithOnlyUser);
});
userEvent.click(screen.getByRole('menuitem'));
expect(
await screen.findByText('Set up an email report'),
).toBeInTheDocument();
});
it('renders Schedule Email Reports as long as user has permission through any role', async () => {
const mockedProps = createProps();
let mockedProps = createProps();
mockedProps = {
...mockedProps,
useTextMenu: true,
};
act(() => {
setup(mockedProps, stateWithNonAdminUser);
});
@@ -215,8 +234,11 @@ describe('Header Report Dropdown', () => {
});
it('do not render Schedule Email Reports if user no permission', () => {
const mockedProps = createProps();
let mockedProps = createProps();
mockedProps = {
...mockedProps,
useTextMenu: true,
};
act(() => {
setup(mockedProps, stateWithNonMenuAccessOnManage);
});

View File

@@ -16,20 +16,24 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useEffect } from 'react';
import { ReactNode, useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { isEmpty } from 'lodash';
import {
t,
SupersetTheme,
css,
styled,
FeatureFlag,
isFeatureEnabled,
getExtensionsRegistry,
usePrevious,
css,
} from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { Checkbox } from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
import { Switch } from '@superset-ui/core/components/Switch';
import { AlertObject } from 'src/features/alerts/types';
import { Menu } from '@superset-ui/core/components/Menu';
import { Checkbox } from '@superset-ui/core/components';
import { noOp } from 'src/utils/common';
import { ChartState } from 'src/explore/types';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
@@ -37,14 +41,35 @@ import {
fetchUISpecificReport,
toggleActive,
} from 'src/features/reports/ReportModal/actions';
import { reportSelector } from 'src/views/CRUD/hooks';
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
const extensionsRegistry = getExtensionsRegistry();
export enum CreationMethod {
Charts = 'charts',
Dashboards = 'dashboards',
}
const deleteColor = (theme: SupersetTheme) => css`
color: ${theme.colorError};
`;
const onMenuHover = (theme: SupersetTheme) => css`
& .ant-menu-item {
padding: 5px 12px;
margin-top: 0px;
margin-bottom: 4px;
:hover {
color: ${theme.colorText};
}
}
:hover {
background-color: ${theme.colorPrimaryBg};
}
`;
const onMenuItemHover = (theme: SupersetTheme) => css`
&:hover {
color: ${theme.colorText};
background-color: ${theme.colorPrimaryBg};
}
`;
const StyledDropdownItemWithIcon = styled.div`
display: flex;
@@ -60,59 +85,63 @@ const DropdownItemExtension = extensionsRegistry.get(
'report-modal.dropdown.item.icon',
);
export enum CreationMethod {
Charts = 'charts',
Dashboards = 'dashboards',
}
export interface HeaderReportProps {
dashboardId?: number;
chart?: ChartState;
useTextMenu?: boolean;
setShowReportSubMenu?: (show: boolean) => void;
showReportSubMenu?: boolean;
submenuTitle?: string;
showReportModal: () => void;
setCurrentReportDeleting: (report: AlertObject | null) => void;
}
export const useHeaderReportMenuItems = ({
// Same instance to be used in useEffects
const EMPTY_OBJECT = {};
export default function HeaderReportDropDown({
dashboardId,
chart,
useTextMenu = false,
setShowReportSubMenu,
submenuTitle,
showReportModal,
setCurrentReportDeleting,
}: HeaderReportProps): MenuItem | null => {
}: HeaderReportProps) {
const dispatch = useDispatch();
const resourceId = dashboardId || chart?.id;
const resourceType = dashboardId
? CreationMethod.Dashboards
: CreationMethod.Charts;
// Select the reports state and specific report with proper reactivity
const report = useSelector<any, AlertObject | null>(state => {
if (!resourceId) return null;
// Select directly from the reports state to ensure reactivity
const reportsState = state.reports || {};
const resourceTypeReports = reportsState[resourceType] || {};
const reportData = resourceTypeReports[resourceId];
// Debug logging to understand what's happening
console.log('Report selector called:', {
resourceId,
resourceType,
reportsState: Object.keys(reportsState),
resourceTypeReports: Object.keys(resourceTypeReports),
reportData: reportData
? { id: reportData.id, name: reportData.name }
: null,
});
return reportData || null;
const report = useSelector<any, AlertObject>(state => {
const resourceType = dashboardId
? CreationMethod.Dashboards
: CreationMethod.Charts;
return (
reportSelector(state, resourceType, dashboardId || chart?.id) ||
EMPTY_OBJECT
);
});
const isReportActive: boolean = report?.active || false;
const user: UserWithPermissionsAndRoles = useSelector<
any,
UserWithPermissionsAndRoles
>(state => state.user);
const prevDashboard = usePrevious(dashboardId);
// Check if user can add reports
const canAddReports = () => {
if (!isFeatureEnabled(FeatureFlag.AlertReports)) return false;
if (!user?.userId) return false;
if (!resourceId) return false;
if (!isFeatureEnabled(FeatureFlag.AlertReports)) {
return false;
}
if (!user?.userId) {
// this is in the case that there is an anonymous user.
return false;
}
// Cannot add reports if the resource is not saved
if (!(dashboardId || chart?.id)) {
return false;
}
const roles = Object.keys(user.roles || []);
const permissions = roles.map(key =>
@@ -123,11 +152,17 @@ export const useHeaderReportMenuItems = ({
return permissions.some(permission => permission.length > 0);
};
const prevDashboard = usePrevious(dashboardId);
const toggleActiveKey = async (data: AlertObject, checked: boolean) => {
if (data?.id) {
dispatch(toggleActive(data, checked));
}
};
const shouldFetch =
canAddReports() &&
!!((dashboardId && prevDashboard !== dashboardId) || chart?.id);
// Fetch report data when needed
useEffect(() => {
if (shouldFetch) {
dispatch(
@@ -135,82 +170,113 @@ export const useHeaderReportMenuItems = ({
userId: user.userId,
filterField: dashboardId ? 'dashboard_id' : 'chart_id',
creationMethod: dashboardId ? 'dashboards' : 'charts',
resourceId,
resourceId: dashboardId || chart?.id,
}),
);
}
}, [dispatch, shouldFetch, user?.userId, dashboardId, resourceId]);
}, []);
// Don't show anything if user can't add reports
if (!canAddReports()) {
return null;
}
const showReportSubMenu = report && setShowReportSubMenu && canAddReports();
// Handler functions
const handleShowModal = () => showReportModal();
const handleDeleteReport = () => setCurrentReportDeleting(report);
const handleToggleActive = () => {
if (report?.id) {
dispatch(toggleActive(report, !report.active));
useEffect(() => {
if (showReportSubMenu) {
setShowReportSubMenu(true);
} else if (!report && setShowReportSubMenu) {
setShowReportSubMenu(false);
}
}, [report]);
const handleShowMenu = () => {
showReportModal();
};
// If no report exists, show "Set up email report" option
if (!report || !report.id) {
return {
key: 'email-report-setup',
type: 'submenu',
label: t('Manage email report'),
children: [
{
key: 'set-up-report',
label: DropdownItemExtension ? (
const handleDeleteMenuClick = () => {
setCurrentReportDeleting(report);
};
const textMenu = () =>
isEmpty(report) ? (
<Menu.SubMenu title={submenuTitle} css={onMenuHover}>
<Menu.Item onClick={handleShowMenu}>
{DropdownItemExtension ? (
<StyledDropdownItemWithIcon>
<div>{t('Set up an email report')}</div>
<DropdownItemExtension />
</StyledDropdownItemWithIcon>
) : (
t('Set up an email report')
),
onClick: handleShowModal,
},
],
};
}
// If report exists, show management options
return {
key: 'email-report-manage',
type: 'submenu',
label: t('Manage email report'),
children: [
{
key: 'toggle-active',
label: (
)}
</Menu.Item>
<Menu.Divider />
</Menu.SubMenu>
) : (
<Menu.SubMenu
title={submenuTitle}
css={css`
border: none;
`}
>
<Menu.Item
css={onMenuItemHover}
onClick={() => toggleActiveKey(report, !isReportActive)}
>
<MenuItemWithCheckboxContainer>
<Checkbox
checked={report.active || false}
onChange={noOp}
css={theme => css`
margin-right: ${theme.sizeUnit}px;
`}
/>
<Checkbox checked={isReportActive} onChange={noOp} />
{t('Email reports active')}
</MenuItemWithCheckboxContainer>
),
onClick: handleToggleActive,
},
{
key: 'edit-report',
label: t('Edit email report'),
onClick: handleShowModal,
},
{
key: 'delete-report',
label: t('Delete email report'),
onClick: handleDeleteReport,
danger: true,
},
],
};
};
</Menu.Item>
<Menu.Item css={onMenuItemHover} onClick={handleShowMenu}>
{t('Edit email report')}
</Menu.Item>
<Menu.Item css={onMenuItemHover} onClick={handleDeleteMenuClick}>
{t('Delete email report')}
</Menu.Item>
</Menu.SubMenu>
);
const menu = (title: ReactNode) => (
<Menu.SubMenu
title={title}
css={css`
width: 200px;
`}
>
<Menu.Item>
{t('Email reports active')}
<Switch
data-test="toggle-active"
checked={isReportActive}
onClick={(checked: boolean) => toggleActiveKey(report, checked)}
size="small"
css={theme => css`
margin-left: ${theme.sizeUnit * 2}px;
`}
/>
</Menu.Item>
<Menu.Item onClick={() => showReportModal()}>
{t('Edit email report')}
</Menu.Item>
<Menu.Item
onClick={() => setCurrentReportDeleting(report)}
css={deleteColor}
>
{t('Delete email report')}
</Menu.Item>
</Menu.SubMenu>
);
const iconMenu = () =>
isEmpty(report) ? (
<span
role="button"
title={t('Schedule email report')}
tabIndex={0}
className="action-button action-schedule-report"
onClick={() => showReportModal()}
>
<Icons.CalendarOutlined />
</span>
) : (
menu(<Icons.CalendarOutlined />)
);
return <>{canAddReports() && (useTextMenu ? textMenu() : iconMenu())}</>;
}

View File

@@ -575,8 +575,8 @@ function ChartList(props: ChartListProps) {
operator: FilterOperator.ChartIsFav,
unfilteredLabel: t('Any'),
selects: [
{ label: t('yes'), value: true },
{ label: t('no'), value: false },
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
}),
[],

View File

@@ -530,8 +530,8 @@ function DashboardList(props: DashboardListProps) {
operator: FilterOperator.DashboardIsFav,
unfilteredLabel: t('Any'),
selects: [
{ label: t('yes'), value: true },
{ label: t('no'), value: false },
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
}),
[],
@@ -604,8 +604,8 @@ function DashboardList(props: DashboardListProps) {
operator: FilterOperator.DashboardIsCertified,
unfilteredLabel: t('Any'),
selects: [
{ label: t('yes'), value: true },
{ label: t('no'), value: false },
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
},
{

View File

@@ -530,8 +530,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
operator: FilterOperator.DatasetIsNullOrEmpty,
unfilteredLabel: 'All',
selects: [
{ label: t('virtual'), value: false },
{ label: t('physical'), value: true },
{ label: t('Virtual'), value: false },
{ label: t('Physical'), value: true },
],
},
{
@@ -598,8 +598,8 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
operator: FilterOperator.DatasetIsCertified,
unfilteredLabel: t('Any'),
selects: [
{ label: t('yes'), value: true },
{ label: t('no'), value: false },
{ label: t('Yes'), value: true },
{ label: t('No'), value: false },
],
},
{
@@ -764,7 +764,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
</p>
{datasetCurrentlyDeleting.dashboards.count >= 1 && (
<>
<h4>{t('Affected dashboards')}</h4>
<h4>{t('Affected Dashboards')}</h4>
<List
split={false}
size="small"
@@ -807,7 +807,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
)}
{datasetCurrentlyDeleting.charts.count >= 1 && (
<>
<h4>{t('Affected charts')}</h4>
<h4>{t('Affected Charts')}</h4>
<List
split={false}
size="small"
@@ -860,7 +860,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}}
onHide={closeDatasetDeleteModal}
open
title={t('Delete dataset?')}
title={t('Delete Dataset?')}
/>
)}
{datasetCurrentlyEditing && (
@@ -931,7 +931,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
);
if (!selected.length) {
return t('0 selected');
return t('0 Selected');
}
if (virtualCount && !physicalCount) {
return t(

View File

@@ -65,7 +65,7 @@ function RowLevelSecurityList(props: RLSProps) {
toggleBulkSelect,
} = useListViewResource<RLSObject>(
'rowlevelsecurity',
t('Row level security'),
t('Row Level Security'),
addDangerToast,
true,
undefined,
@@ -130,13 +130,13 @@ function RowLevelSecurityList(props: RLSProps) {
},
{
accessor: 'filter_type',
Header: t('Filter type'),
Header: t('Filter Type'),
size: 'xl',
id: 'filter_type',
},
{
accessor: 'group_key',
Header: t('Group key'),
Header: t('Group Key'),
size: 'xl',
id: 'group_key',
},
@@ -246,7 +246,7 @@ function RowLevelSecurityList(props: RLSProps) {
);
const emptyState = {
title: t('No rules yet'),
title: t('No Rules yet'),
image: 'filter-results.svg',
buttonAction: () => handleRuleEdit(null),
buttonIcon: canEdit ? (
@@ -265,7 +265,7 @@ function RowLevelSecurityList(props: RLSProps) {
operator: FilterOperator.StartsWith,
},
{
Header: t('Filter type'),
Header: t('Filter Type'),
key: 'filter_type',
id: 'filter_type',
input: 'select',
@@ -277,7 +277,7 @@ function RowLevelSecurityList(props: RLSProps) {
],
},
{
Header: t('Group key'),
Header: t('Group Key'),
key: 'search',
id: 'group_key',
input: 'search',
@@ -329,7 +329,7 @@ function RowLevelSecurityList(props: RLSProps) {
return (
<>
<SubMenu name={t('Row level security')} buttons={subMenuButtons} />
<SubMenu name={t('Row Level Security')} buttons={subMenuButtons} />
<ConfirmStatusChange
title={t('Please confirm')}
description={t('Are you sure you want to delete the selected rules?')}

View File

@@ -338,22 +338,6 @@ function ThemesList({
const subMenuButtons: SubMenuProps['buttons'] = [];
if (canImport) {
subMenuButtons.push({
name: (
<Tooltip
id="import-tooltip"
title={t('Import themes')}
placement="bottomRight"
>
<Icons.DownloadOutlined iconSize="l" data-test="import-button" />
</Tooltip>
),
buttonStyle: 'link',
onClick: openThemeImportModal,
});
}
if (canDelete || canExport) {
subMenuButtons.push({
name: t('Bulk select'),
@@ -374,6 +358,22 @@ function ThemesList({
});
}
if (canImport) {
subMenuButtons.push({
name: (
<Tooltip
id="import-tooltip"
title={t('Import themes')}
placement="bottomRight"
>
<Icons.DownloadOutlined iconSize="l" data-test="import-button" />
</Tooltip>
),
buttonStyle: 'link',
onClick: openThemeImportModal,
});
}
menuData.buttons = subMenuButtons;
const filters: ListViewFilters = useMemo(

View File

@@ -519,7 +519,7 @@ function UsersList({ user }: UsersListProps) {
return (
<>
<SubMenu name={t('List users')} buttons={subMenuButtons} />
<SubMenu name={t('List Users')} buttons={subMenuButtons} />
<UserListAddModal
onHide={() => closeModal(ModalType.ADD)}
show={modalState.add}
@@ -554,7 +554,7 @@ function UsersList({ user }: UsersListProps) {
}}
onHide={() => setUserCurrentlyDeleting(null)}
open
title={t('Delete user?')}
title={t('Delete User?')}
/>
)}
<ConfirmStatusChange

View File

@@ -42,7 +42,7 @@ class GetSqlLabPermalinkCommand(BaseSqlLabPermalinkCommand):
def run(self) -> Optional[SqlLabPermalinkValue]:
self.validate()
if self.key.startswith("kv:"):
id = int(self.key[3:])
id = int(self.key[3])
try:
kv = db.session.query(models.KeyValue).filter_by(id=id).scalar()
if not kv:

View File

@@ -762,16 +762,6 @@ THEME_SETTINGS = {
"allowSwitching": True, # Allows user to switch between themes (default and dark) # noqa: E501
"allowOSPreference": True, # Allows the app to Auto-detect and set system theme preference # noqa: E501
}
# Custom font configuration
# Load external fonts at runtime without rebuilding the application
# Example:
# CUSTOM_FONT_URLS = [
# "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
# "https://fonts.googleapis.com/css2?family=Roboto+Mono:wght@400;500&display=swap",
# ]
CUSTOM_FONT_URLS: list[str] = []
# ---------------------------------------------------
# EXTRA_SEQUENTIAL_COLOR_SCHEMES is used for adding custom sequential color schemes
# EXTRA_SEQUENTIAL_COLOR_SCHEMES = [

View File

@@ -494,8 +494,7 @@ class SupersetAppInitializer: # pylint: disable=too-many-public-methods
# Seed system themes from configuration
from superset.commands.theme.seed import SeedSystemThemesCommand
if inspector.has_table("themes"):
SeedSystemThemesCommand().run()
SeedSystemThemesCommand().run()
def init_app_in_ctx(self) -> None:
"""

View File

@@ -1290,7 +1290,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
condition = condition_factory(expr.name, expr)
# Create CASE expression: condition true -> original, else "Others"
case_expr = sa.case([(condition, expr)], else_=sa.literal("Others"))
case_expr = sa.case(
[(condition, expr)], else_=sa.literal_column("Others")
)
case_expr = self.make_sqla_column_compatible(case_expr, expr.name)
modified_select_exprs.append(case_expr)
else:
@@ -1306,13 +1308,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
# Create CASE expression for groupby
case_expr = sa.case(
[(condition, gby_expr)],
else_=sa.literal("Others"),
else_=sa.literal_column("Others"),
)
# Don't apply make_sqla_column_compatible to GROUP BY expressions.
# When make_sqla_column_compatible adds a label to the expression,
# it can cause SQLAlchemy to incorrectly render string literals
# without quotes in the GROUP BY clause (e.g., "ELSE Others"
# instead of "ELSE 'Others'")
case_expr = self.make_sqla_column_compatible(case_expr, col_name)
modified_groupby_all_columns[col_name] = case_expr
else:
modified_groupby_all_columns[col_name] = gby_expr

View File

@@ -654,16 +654,6 @@ class SQLStatement(BaseSQLStatement[exp.Expression]):
if isinstance(self._parsed, exp.Command) and self._parsed.name == "ALTER":
return True # pragma: no cover
if (
self._dialect == Dialects.POSTGRES
and isinstance(self._parsed, exp.Command)
and self._parsed.name == "DO"
):
# anonymous blocks can be written in many different languages (the default
# is PL/pgSQL), so parsing them it out of scope of this class; we just
# assume the anonymous block is mutating
return True
# Postgres runs DMLs prefixed by `EXPLAIN ANALYZE`, see
# https://www.postgresql.org/docs/current/sql-explain.html
if (

View File

@@ -47,13 +47,6 @@
as="style"
/>
{# Load custom fonts from configuration #}
{% if appbuilder.app.config.get('CUSTOM_FONT_URLS') %}
{% for font_url in appbuilder.app.config['CUSTOM_FONT_URLS'] %}
<link rel="stylesheet" href="{{ font_url }}" />
{% endfor %}
{% endif %}
{% endblock %}
{{ js_bundle(assets_prefix, 'theme') }}

File diff suppressed because it is too large Load Diff

View File

@@ -296,10 +296,7 @@ def test_apply_series_others_grouping(database: Database) -> None:
# Category (series column) should be replaced with CASE expression
assert "category" in result_groupby_columns
category_groupby_result = result_groupby_columns["category"]
# After our fix, GROUP BY expressions are NOT wrapped with
# make_sqla_column_compatible, so it should be a raw CASE expression,
# not a Mock with .name attribute. Verify it's different from the original
assert category_groupby_result != category_expr
assert category_groupby_result.name == "category" # Should be made compatible
# Other (non-series column) should remain unchanged
assert result_groupby_columns["other_col"] == other_expr
@@ -362,165 +359,4 @@ def test_apply_series_others_grouping_with_false_condition(database: Database) -
assert len(result_groupby_columns) == 1
assert "category" in result_groupby_columns
# GROUP BY expression should be a CASE expression, not the original
assert result_groupby_columns["category"] != category_expr
def test_apply_series_others_grouping_sql_compilation(database: Database) -> None:
"""
Test that the `_apply_series_others_grouping` method properly quotes
the 'Others' literal in both SELECT and GROUP BY clauses.
This test verifies the fix for the bug where 'Others' was not quoted
in the GROUP BY clause, causing SQL syntax errors.
"""
import sqlalchemy as sa
from superset.connectors.sqla.models import SqlaTable, TableColumn
# Create a real table instance
table = SqlaTable(
database=database,
schema=None,
table_name="test_table",
columns=[
TableColumn(column_name="name", type="TEXT"),
TableColumn(column_name="value", type="INTEGER"),
],
)
# Create real SQLAlchemy expressions
name_col = sa.column("name")
value_col = sa.column("value")
select_exprs = [name_col, value_col]
groupby_all_columns = {"name": name_col}
groupby_series_columns = {"name": name_col}
# Condition factory that checks if a subquery column is not null
def condition_factory(col_name: str, expr):
return sa.column("series_limit.name__").is_not(None)
# Call the method
result_select_exprs, result_groupby_columns = table._apply_series_others_grouping(
select_exprs,
groupby_all_columns,
groupby_series_columns,
condition_factory,
)
# Get the database dialect from the actual database
with database.get_sqla_engine() as engine:
dialect = engine.dialect
# Test SELECT expression compilation
select_case_expr = result_select_exprs[0]
select_sql = str(
select_case_expr.compile(
dialect=dialect, compile_kwargs={"literal_binds": True}
)
)
# Test GROUP BY expression compilation
groupby_case_expr = result_groupby_columns["name"]
groupby_sql = str(
groupby_case_expr.compile(
dialect=dialect, compile_kwargs={"literal_binds": True}
)
)
# Different databases may use different quote characters
# PostgreSQL/MySQL use single quotes, some might use double quotes
# The key is that Others should be quoted, not bare
# Check that 'Others' appears with some form of quotes
# and not as a bare identifier
assert " Others " not in select_sql, "Found unquoted 'Others' in SELECT"
assert " Others " not in groupby_sql, "Found unquoted 'Others' in GROUP BY"
# Check for common quoting patterns
has_single_quotes = "'Others'" in select_sql and "'Others'" in groupby_sql
has_double_quotes = '"Others"' in select_sql and '"Others"' in groupby_sql
assert has_single_quotes or has_double_quotes, (
"Others literal should be quoted with either single or double quotes"
)
# Verify the structure of the generated SQL
assert "CASE WHEN" in select_sql
assert "CASE WHEN" in groupby_sql
# Check that ELSE is followed by a quoted value
assert "ELSE " in select_sql
assert "ELSE " in groupby_sql
# The key test is that GROUP BY expression doesn't have a label
# while SELECT might or might not have one depending on the database
# What matters is that GROUP BY should NOT have label
assert " AS " not in groupby_sql # GROUP BY should NOT have label
# Also verify that if SELECT has a label, it's different from GROUP BY
if " AS " in select_sql:
# If labeled, SELECT and GROUP BY should be different
assert select_sql != groupby_sql
def test_apply_series_others_grouping_no_label_in_groupby(database: Database) -> None:
"""
Test that GROUP BY expressions don't get wrapped with make_sqla_column_compatible.
This is a specific test for the bug fix where make_sqla_column_compatible
was causing issues with literal quoting in GROUP BY clauses.
"""
from unittest.mock import ANY, call, Mock, patch
from superset.connectors.sqla.models import SqlaTable, TableColumn
# Create a table instance
table = SqlaTable(
database=database,
schema=None,
table_name="test_table",
columns=[TableColumn(column_name="category", type="TEXT")],
)
# Mock expressions
category_expr = Mock()
category_expr.name = "category"
select_exprs = [category_expr]
groupby_all_columns = {"category": category_expr}
groupby_series_columns = {"category": category_expr}
def condition_factory(col_name: str, expr):
return True
# Track calls to make_sqla_column_compatible
with patch.object(
table, "make_sqla_column_compatible", side_effect=lambda expr, name: expr
) as mock_make_compatible:
result_select_exprs, result_groupby_columns = (
table._apply_series_others_grouping(
select_exprs,
groupby_all_columns,
groupby_series_columns,
condition_factory,
)
)
# Verify make_sqla_column_compatible was called for SELECT expressions
# but NOT for GROUP BY expressions
calls = mock_make_compatible.call_args_list
# Should have exactly one call (for the SELECT expression)
assert len(calls) == 1
# The call should be for the SELECT expression with the column name
# Using unittest.mock.ANY to match any CASE expression
assert calls[0] == call(ANY, "category")
# Verify the GROUP BY expression was NOT passed through
# make_sqla_column_compatible - it should be the raw CASE expression
assert "category" in result_groupby_columns
# The GROUP BY expression should be different from the SELECT expression
# because only SELECT gets make_sqla_column_compatible applied
assert result_groupby_columns["category"].name == "category"

View File

@@ -1189,43 +1189,6 @@ def test_is_mutating(sql: str, engine: str, expected: bool) -> None:
assert SQLStatement(sql, engine).is_mutating() == expected
@pytest.mark.parametrize(
"sql, expected",
[
(
"""
DO $$
BEGIN
INSERT INTO public.users (name, real_name)
VALUES ('SQLLab bypass DML', 'SQLLab bypass DML');
END;
$$;
""",
True,
),
(
"""
DO $$
BEGIN
IF (SELECT COUNT(*) FROM orders WHERE status = 'pending') > 100 THEN
RAISE NOTICE 'High pending order volume detected';
END IF;
END;
$$;
""",
True,
),
],
)
def test_is_mutating_anonymous_block(sql: str, expected: bool) -> None:
"""
Test for `is_mutating` with a Postgres anonymous block.
Since we can't parse the PL/pgSQL inside the block we always assume it is mutating.
"""
assert SQLStatement(sql, "postgresql").is_mutating() == expected
def test_optimize() -> None:
"""
Test that the `optimize` method works as expected.