mirror of
https://github.com/apache/superset.git
synced 2026-09-01 13:01:33 +00:00
Compare commits
55
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f3f4e36d6 | ||
|
|
646b67faa0 | ||
|
|
7a59c256ed | ||
|
|
4bc9c844c3 | ||
|
|
0216548e5d | ||
|
|
fee6ab61d7 | ||
|
|
49a3966104 | ||
|
|
df6975ecbd | ||
|
|
00da9b8c10 | ||
|
|
9a5f7779f1 | ||
|
|
99ac6822ae | ||
|
|
991a928c92 | ||
|
|
6da0aa8b51 | ||
|
|
7c2ec4ca5f | ||
|
|
6a83b6fd87 | ||
|
|
659cd33749 | ||
|
|
cb27d5fe8d | ||
|
|
6c9cda758a | ||
|
|
967134f540 | ||
|
|
25bb353f9d | ||
|
|
9cf2472291 | ||
|
|
cf5b976659 | ||
|
|
70394e79ef | ||
|
|
ea64f3122e | ||
|
|
50197fc33e | ||
|
|
c480fa7fcf | ||
|
|
6fc734da51 | ||
|
|
762a11b0bb | ||
|
|
f168dd69a8 | ||
|
|
becd0b8883 | ||
|
|
fd4570625a | ||
|
|
54a5b58e40 | ||
|
|
a611278e04 | ||
|
|
5c2eb0a68c | ||
|
|
0cbf4d5d4d | ||
|
|
6006a21378 | ||
|
|
bf967d6ba4 | ||
|
|
131ae5aa9d | ||
|
|
eca28582b6 | ||
|
|
14e90a0f52 | ||
|
|
a1c39d4906 | ||
|
|
0964a8bb7a | ||
|
|
8de8f95a3c | ||
|
|
16db999067 | ||
|
|
972be15dda | ||
|
|
c9e06714f8 | ||
|
|
32626ab707 | ||
|
|
a9cd58508b | ||
|
|
122bb68e5a | ||
|
|
914ce9aa4f | ||
|
|
bb572983cd | ||
|
|
ff76ab647f | ||
|
|
f554848c9f | ||
|
|
dc0c389488 | ||
|
|
22b3cc0480 |
@@ -0,0 +1,20 @@
|
|||||||
|
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
|
||||||
|
FROM python:3.11.13-bookworm AS base
|
||||||
|
|
||||||
|
# Install system dependencies that Superset needs
|
||||||
|
# This layer will be cached across Codespace sessions
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
libsasl2-dev \
|
||||||
|
libldap2-dev \
|
||||||
|
libpq-dev \
|
||||||
|
tmux \
|
||||||
|
gh \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install uv for fast Python package management
|
||||||
|
# This will also be cached in the image
|
||||||
|
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||||
|
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
|
||||||
|
|
||||||
|
# Set the cargo/bin directory in PATH for all users
|
||||||
|
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Superset Development with GitHub Codespaces
|
||||||
|
|
||||||
|
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
|
||||||
|
|
||||||
|
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**
|
||||||
|
|
||||||
|
## Pre-installed Development Environment
|
||||||
|
|
||||||
|
When you create a new Codespace from this repository, it automatically:
|
||||||
|
|
||||||
|
1. **Creates a Python virtual environment** using `uv venv`
|
||||||
|
2. **Installs all development dependencies** via `uv pip install -r requirements/development.txt`
|
||||||
|
3. **Sets up pre-commit hooks** with `pre-commit install`
|
||||||
|
4. **Activates the virtual environment** automatically in all terminals
|
||||||
|
|
||||||
|
The virtual environment is located at `/workspaces/{repository-name}/.venv` and is automatically activated through environment variables set in the devcontainer configuration.
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
# Superset Codespaces environment setup
|
||||||
|
# This file is appended to ~/.bashrc during Codespace setup
|
||||||
|
|
||||||
|
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
|
||||||
|
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||||
|
|
||||||
|
if [ -n "$WORKSPACE_DIR" ]; then
|
||||||
|
# Check if virtual environment exists
|
||||||
|
if [ -d "$WORKSPACE_DIR/.venv" ]; then
|
||||||
|
# Activate the virtual environment
|
||||||
|
source "$WORKSPACE_DIR/.venv/bin/activate"
|
||||||
|
echo "✅ Python virtual environment activated"
|
||||||
|
|
||||||
|
# Verify pre-commit is installed and set up
|
||||||
|
if command -v pre-commit &> /dev/null; then
|
||||||
|
echo "✅ pre-commit is available ($(pre-commit --version))"
|
||||||
|
# Install git hooks if not already installed
|
||||||
|
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
|
||||||
|
echo "🪝 Installing pre-commit hooks..."
|
||||||
|
cd "$WORKSPACE_DIR" && pre-commit install
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
|
||||||
|
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Always cd to the workspace directory for convenience
|
||||||
|
cd "$WORKSPACE_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Add helpful aliases for Superset development
|
||||||
|
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
|
||||||
|
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
|
||||||
|
|
||||||
|
# Show helpful message on login
|
||||||
|
echo ""
|
||||||
|
echo "🚀 Superset Codespaces Environment"
|
||||||
|
echo "=================================="
|
||||||
|
|
||||||
|
# Check if Superset is running
|
||||||
|
if docker ps 2>/dev/null | grep -q "superset"; then
|
||||||
|
echo "✅ Superset is running!"
|
||||||
|
echo " - Check the 'Ports' tab for your live Superset URL"
|
||||||
|
echo " - Initial startup takes 10-20 minutes"
|
||||||
|
echo " - Login: admin/admin"
|
||||||
|
else
|
||||||
|
echo "⚠️ Superset is not running. Use: start-superset"
|
||||||
|
# Check if there's a startup log
|
||||||
|
if [ -f "/tmp/superset-startup.log" ]; then
|
||||||
|
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "Quick commands:"
|
||||||
|
echo " start-superset - Start Superset with Docker Compose"
|
||||||
|
echo " setup-dev - Set up Python environment (if not already done)"
|
||||||
|
echo " pre-commit run - Run pre-commit checks on staged files"
|
||||||
|
echo ""
|
||||||
Executable
+20
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Script to build and push the devcontainer image to GitHub Container Registry
|
||||||
|
# This allows caching the image between Codespace sessions
|
||||||
|
|
||||||
|
# You'll need to run this with appropriate GitHub permissions
|
||||||
|
# gh auth login --scopes write:packages
|
||||||
|
|
||||||
|
REGISTRY="ghcr.io"
|
||||||
|
OWNER="apache"
|
||||||
|
REPO="superset"
|
||||||
|
TAG="devcontainer-base"
|
||||||
|
|
||||||
|
echo "Building devcontainer image..."
|
||||||
|
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
|
||||||
|
|
||||||
|
echo "Pushing to GitHub Container Registry..."
|
||||||
|
docker push $REGISTRY/$OWNER/$REPO:$TAG
|
||||||
|
|
||||||
|
echo "Done! Update .devcontainer/devcontainer.json to use:"
|
||||||
|
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
{
|
||||||
|
"name": "Apache Superset Development",
|
||||||
|
// Option 1: Use pre-built image directly
|
||||||
|
// "image": "ghcr.io/apache/superset:devcontainer-base",
|
||||||
|
|
||||||
|
// Option 2: Build from Dockerfile with cache (current approach)
|
||||||
|
"build": {
|
||||||
|
"dockerfile": "Dockerfile",
|
||||||
|
"context": ".",
|
||||||
|
// Cache from the Apache registry image
|
||||||
|
"cacheFrom": ["ghcr.io/apache/superset:devcontainer-base"]
|
||||||
|
},
|
||||||
|
|
||||||
|
"features": {
|
||||||
|
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||||
|
"moby": true,
|
||||||
|
"dockerDashComposeVersion": "v2"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/node:1": {
|
||||||
|
"version": "20"
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/git:1": {},
|
||||||
|
"ghcr.io/devcontainers/features/common-utils:2": {
|
||||||
|
"configureZshAsDefaultShell": true
|
||||||
|
},
|
||||||
|
"ghcr.io/devcontainers/features/sshd:1": {
|
||||||
|
"version": "latest"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Forward ports for development
|
||||||
|
"forwardPorts": [9001],
|
||||||
|
"portsAttributes": {
|
||||||
|
"9001": {
|
||||||
|
"label": "Superset (via Webpack Dev Server)",
|
||||||
|
"onAutoForward": "notify",
|
||||||
|
"visibility": "public"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Run commands after container is created
|
||||||
|
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
|
||||||
|
|
||||||
|
// Auto-start Superset after ensuring Docker is ready
|
||||||
|
// Run in foreground to see any errors, but don't block on failures
|
||||||
|
"postStartCommand": "bash -c 'echo \"Waiting 30s for services to initialize...\"; sleep 30; .devcontainer/start-superset.sh || echo \"⚠️ Auto-start failed - run start-superset manually\"'",
|
||||||
|
|
||||||
|
// Set environment variables
|
||||||
|
"remoteEnv": {
|
||||||
|
// Removed automatic venv activation to prevent startup issues
|
||||||
|
// The setup script will handle this
|
||||||
|
},
|
||||||
|
|
||||||
|
// VS Code customizations
|
||||||
|
"customizations": {
|
||||||
|
"vscode": {
|
||||||
|
"extensions": [
|
||||||
|
"ms-python.python",
|
||||||
|
"ms-python.vscode-pylance",
|
||||||
|
"charliermarsh.ruff",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+78
@@ -0,0 +1,78 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Setup script for Superset Codespaces development environment
|
||||||
|
|
||||||
|
echo "🔧 Setting up Superset development environment..."
|
||||||
|
|
||||||
|
# System dependencies and uv are now pre-installed in the Docker image
|
||||||
|
# This speeds up Codespace creation significantly!
|
||||||
|
|
||||||
|
# Create virtual environment using uv
|
||||||
|
echo "🐍 Creating Python virtual environment..."
|
||||||
|
if ! uv venv; then
|
||||||
|
echo "❌ Failed to create virtual environment"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Python dependencies
|
||||||
|
echo "📦 Installing Python dependencies..."
|
||||||
|
if ! uv pip install -r requirements/development.txt; then
|
||||||
|
echo "❌ Failed to install Python dependencies"
|
||||||
|
echo "💡 You may need to run this manually after the Codespace starts"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install pre-commit hooks
|
||||||
|
echo "🪝 Installing pre-commit hooks..."
|
||||||
|
if source .venv/bin/activate && pre-commit install; then
|
||||||
|
echo "✅ Pre-commit hooks installed"
|
||||||
|
else
|
||||||
|
echo "⚠️ Pre-commit hooks installation failed (non-critical)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install Claude Code CLI via npm
|
||||||
|
echo "🤖 Installing Claude Code..."
|
||||||
|
if npm install -g @anthropic-ai/claude-code; then
|
||||||
|
echo "✅ Claude Code installed"
|
||||||
|
else
|
||||||
|
echo "⚠️ Claude Code installation failed (non-critical)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Make the start script executable
|
||||||
|
chmod +x .devcontainer/start-superset.sh
|
||||||
|
|
||||||
|
# Add bashrc additions for automatic venv activation
|
||||||
|
echo "🔧 Setting up automatic environment activation..."
|
||||||
|
if [ -f ~/.bashrc ]; then
|
||||||
|
# Check if we've already added our additions
|
||||||
|
if ! grep -q "Superset Codespaces environment setup" ~/.bashrc; then
|
||||||
|
echo "" >> ~/.bashrc
|
||||||
|
cat .devcontainer/bashrc-additions >> ~/.bashrc
|
||||||
|
echo "✅ Added automatic venv activation to ~/.bashrc"
|
||||||
|
else
|
||||||
|
echo "✅ Bashrc additions already present"
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
# Create bashrc if it doesn't exist
|
||||||
|
cat .devcontainer/bashrc-additions > ~/.bashrc
|
||||||
|
echo "✅ Created ~/.bashrc with automatic venv activation"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Also add to zshrc since that's the default shell
|
||||||
|
if [ -f ~/.zshrc ] || [ -n "$ZSH_VERSION" ]; then
|
||||||
|
if ! grep -q "Superset Codespaces environment setup" ~/.zshrc; then
|
||||||
|
echo "" >> ~/.zshrc
|
||||||
|
cat .devcontainer/bashrc-additions >> ~/.zshrc
|
||||||
|
echo "✅ Added automatic venv activation to ~/.zshrc"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "✅ Development environment setup complete!"
|
||||||
|
echo ""
|
||||||
|
echo "📝 The virtual environment will be automatically activated in new terminals"
|
||||||
|
echo ""
|
||||||
|
echo "🔄 To activate in this terminal, run:"
|
||||||
|
echo " source ~/.bashrc"
|
||||||
|
echo ""
|
||||||
|
echo "🚀 To start Superset:"
|
||||||
|
echo " start-superset"
|
||||||
|
echo ""
|
||||||
Executable
+108
@@ -0,0 +1,108 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Startup script for Superset in Codespaces
|
||||||
|
|
||||||
|
# Log to a file for debugging
|
||||||
|
LOG_FILE="/tmp/superset-startup.log"
|
||||||
|
echo "[$(date)] Starting Superset startup script" >> "$LOG_FILE"
|
||||||
|
echo "[$(date)] User: $(whoami), PWD: $(pwd)" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
echo "🚀 Starting Superset in Codespaces..."
|
||||||
|
echo "🌐 Frontend will be available at port 9001"
|
||||||
|
|
||||||
|
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
|
||||||
|
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||||
|
if [ -n "$WORKSPACE_DIR" ]; then
|
||||||
|
cd "$WORKSPACE_DIR"
|
||||||
|
echo "📁 Working in: $WORKSPACE_DIR"
|
||||||
|
else
|
||||||
|
echo "📁 Using current directory: $(pwd)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Wait for Docker to be available
|
||||||
|
echo "⏳ Waiting for Docker to start..."
|
||||||
|
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
|
||||||
|
max_attempts=30
|
||||||
|
attempt=0
|
||||||
|
while ! docker info > /dev/null 2>&1; do
|
||||||
|
if [ $attempt -eq $max_attempts ]; then
|
||||||
|
echo "❌ Docker failed to start after $max_attempts attempts"
|
||||||
|
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
|
||||||
|
echo "🔄 Please restart the Codespace or run this script manually later"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo " Attempt $((attempt + 1))/$max_attempts..."
|
||||||
|
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
|
||||||
|
sleep 2
|
||||||
|
attempt=$((attempt + 1))
|
||||||
|
done
|
||||||
|
echo "✅ Docker is ready!"
|
||||||
|
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
|
||||||
|
|
||||||
|
# Check if Superset containers are already running
|
||||||
|
if docker ps | grep -q "superset"; then
|
||||||
|
echo "✅ Superset containers are already running!"
|
||||||
|
echo ""
|
||||||
|
echo "🌐 To access Superset:"
|
||||||
|
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
|
||||||
|
echo " 2. Find port 9001 and click the globe icon to open"
|
||||||
|
echo " 3. Wait 10-20 minutes for initial startup"
|
||||||
|
echo ""
|
||||||
|
echo "📝 Login credentials: admin/admin"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Clean up any existing containers
|
||||||
|
echo "🧹 Cleaning up existing containers..."
|
||||||
|
docker-compose -f docker-compose-light.yml down
|
||||||
|
|
||||||
|
# Start services
|
||||||
|
echo "🏗️ Starting Superset in background (daemon mode)..."
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Start in detached mode
|
||||||
|
docker-compose -f docker-compose-light.yml up -d
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ Docker Compose started successfully!"
|
||||||
|
echo ""
|
||||||
|
echo "📋 Important information:"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo "⏱️ Initial startup takes 10-20 minutes"
|
||||||
|
echo "🌐 Check the 'Ports' tab for your Superset URL (port 9001)"
|
||||||
|
echo "👤 Login: admin / admin"
|
||||||
|
echo ""
|
||||||
|
echo "📊 Useful commands:"
|
||||||
|
echo " docker-compose -f docker-compose-light.yml logs -f # Follow logs"
|
||||||
|
echo " docker-compose -f docker-compose-light.yml ps # Check status"
|
||||||
|
echo " docker-compose -f docker-compose-light.yml down # Stop services"
|
||||||
|
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||||
|
echo ""
|
||||||
|
echo "💤 Keeping terminal open for 60 seconds to test persistence..."
|
||||||
|
sleep 60
|
||||||
|
echo "✅ Test complete - check if this terminal is still visible!"
|
||||||
|
|
||||||
|
# Show final status
|
||||||
|
docker-compose -f docker-compose-light.yml ps
|
||||||
|
EXIT_CODE=$?
|
||||||
|
|
||||||
|
# If it failed, provide helpful instructions
|
||||||
|
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
|
||||||
|
echo ""
|
||||||
|
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
|
||||||
|
echo ""
|
||||||
|
echo "🔄 To restart Superset, run:"
|
||||||
|
echo " .devcontainer/start-superset.sh"
|
||||||
|
echo ""
|
||||||
|
echo "🔧 For troubleshooting:"
|
||||||
|
echo " # View logs:"
|
||||||
|
echo " docker-compose -f docker-compose-light.yml logs"
|
||||||
|
echo ""
|
||||||
|
echo " # Clean restart (removes volumes):"
|
||||||
|
echo " docker-compose -f docker-compose-light.yml down -v"
|
||||||
|
echo " .devcontainer/start-superset.sh"
|
||||||
|
echo ""
|
||||||
|
echo " # Common issues:"
|
||||||
|
echo " - Network timeouts: Just retry, often transient"
|
||||||
|
echo " - Port conflicts: Check 'docker ps'"
|
||||||
|
echo " - Database issues: Try clean restart with -v"
|
||||||
|
fi
|
||||||
+3
-3
@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
|
|||||||
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
|
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
|
||||||
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
|
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
|
||||||
# as the full content of these folders don't change, yielding a decent cache reuse rate.
|
# as the full content of these folders don't change, yielding a decent cache reuse rate.
|
||||||
# Note that's it's not possible selectively COPY of mount using blobs.
|
# Note that it's not possible to selectively COPY or mount using blobs.
|
||||||
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
|
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
|
||||||
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
|
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
|
||||||
--mount=type=cache,target=/root/.cache \
|
--mount=type=cache,target=/root/.cache \
|
||||||
@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
|
|||||||
COPY superset-frontend /app/superset-frontend
|
COPY superset-frontend /app/superset-frontend
|
||||||
|
|
||||||
######################################################################
|
######################################################################
|
||||||
# superset-node used for compile frontend assets
|
# superset-node is used for compiling frontend assets
|
||||||
######################################################################
|
######################################################################
|
||||||
FROM superset-node-ci AS superset-node
|
FROM superset-node-ci AS superset-node
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
# Copy translation files
|
# Copy translation files
|
||||||
COPY superset/translations /app/superset/translations
|
COPY superset/translations /app/superset/translations
|
||||||
|
|
||||||
# Build the frontend if not in dev mode
|
# Build translations if enabled, then cleanup localization files
|
||||||
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
|
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
|
||||||
npm run build-translation; \
|
npm run build-translation; \
|
||||||
fi; \
|
fi; \
|
||||||
|
|||||||
@@ -23,25 +23,57 @@ MIN_MEM_FREE_GB=3
|
|||||||
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
|
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
|
||||||
|
|
||||||
echo_mem_warn() {
|
echo_mem_warn() {
|
||||||
MEM_FREE_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
# Check if running in Codespaces first
|
||||||
MEM_FREE_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
if [[ -n "${CODESPACES}" ]]; then
|
||||||
|
echo "Memory available: Codespaces managed"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
# Check platform and get memory accordingly
|
||||||
|
if [[ -f /proc/meminfo ]]; then
|
||||||
|
# Linux
|
||||||
|
if grep -q MemAvailable /proc/meminfo; then
|
||||||
|
MEM_AVAIL_KB=$(awk '/MemAvailable/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||||
|
MEM_AVAIL_GB=$(awk '/MemAvailable/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||||
|
else
|
||||||
|
MEM_AVAIL_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||||
|
MEM_AVAIL_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||||
|
fi
|
||||||
|
elif [[ "$(uname)" == "Darwin" ]]; then
|
||||||
|
# macOS - use vm_stat to get free memory
|
||||||
|
# vm_stat reports in pages, typically 4096 bytes per page
|
||||||
|
PAGE_SIZE=$(pagesize)
|
||||||
|
FREE_PAGES=$(vm_stat | awk '/Pages free:/ {print $3}' | tr -d '.')
|
||||||
|
INACTIVE_PAGES=$(vm_stat | awk '/Pages inactive:/ {print $3}' | tr -d '.')
|
||||||
|
# Free + inactive pages give us available memory (similar to MemAvailable on Linux)
|
||||||
|
AVAIL_PAGES=$((FREE_PAGES + INACTIVE_PAGES))
|
||||||
|
MEM_AVAIL_KB=$((AVAIL_PAGES * PAGE_SIZE / 1024))
|
||||||
|
MEM_AVAIL_GB=$(echo "scale=2; $MEM_AVAIL_KB / 1024 / 1024" | bc)
|
||||||
|
else
|
||||||
|
# Other platforms
|
||||||
|
echo "Memory available: Unable to determine"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ "${MEM_AVAIL_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||||
cat <<EOF
|
cat <<EOF
|
||||||
===============================================
|
===============================================
|
||||||
======== Memory Insufficient Warning =========
|
======== Memory Insufficient Warning =========
|
||||||
===============================================
|
===============================================
|
||||||
|
|
||||||
It looks like you only have ${MEM_FREE_GB}GB of
|
It looks like you only have ${MEM_AVAIL_GB}GB of
|
||||||
memory free. Please increase your Docker
|
memory ${MEM_TYPE}. Please increase your Docker
|
||||||
resources to at least ${MIN_MEM_FREE_GB}GB
|
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 =========
|
======== Memory Insufficient Warning =========
|
||||||
===============================================
|
===============================================
|
||||||
EOF
|
EOF
|
||||||
else
|
else
|
||||||
echo "Memory check Ok [${MEM_FREE_GB}GB free]"
|
echo "Memory available: ${MEM_AVAIL_GB} GB"
|
||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,8 +87,66 @@ Restart Superset to apply changes.
|
|||||||
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
|
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
|
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
|
## Advanced Features
|
||||||
|
|
||||||
- **System Themes**: Superset includes built-in light and dark themes
|
- **System Themes**: Superset includes built-in light and dark themes
|
||||||
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
||||||
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
||||||
|
- **Custom Fonts**: Load external fonts via configuration without rebuilding
|
||||||
|
|||||||
@@ -120,6 +120,78 @@ docker volume rm superset_db_home
|
|||||||
docker-compose up
|
docker-compose up
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## GitHub Codespaces (Cloud Development)
|
||||||
|
|
||||||
|
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
|
||||||
|
- Quick contributions without local setup
|
||||||
|
- Consistent development environments across team members
|
||||||
|
- Working from devices that can't run Docker locally
|
||||||
|
- Safe experimentation in isolated environments
|
||||||
|
|
||||||
|
:::info
|
||||||
|
We're grateful to GitHub for providing this excellent cloud development service that makes
|
||||||
|
contributing to Apache Superset more accessible to developers worldwide.
|
||||||
|
:::
|
||||||
|
|
||||||
|
### Getting Started with Codespaces
|
||||||
|
|
||||||
|
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||||
|
|
||||||
|
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
|
||||||
|
|
||||||
|
:::caution
|
||||||
|
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||||
|
Smaller instances will not have sufficient resources to run Superset effectively.
|
||||||
|
:::
|
||||||
|
|
||||||
|
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
|
||||||
|
- Build the development container
|
||||||
|
- Install all dependencies
|
||||||
|
- Start all required services (PostgreSQL, Redis, etc.)
|
||||||
|
- Initialize the database with example data
|
||||||
|
|
||||||
|
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
|
||||||
|
Click the globe icon to open Superset in your browser.
|
||||||
|
- Default credentials: `admin` / `admin`
|
||||||
|
|
||||||
|
### Key Features
|
||||||
|
|
||||||
|
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
|
||||||
|
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
|
||||||
|
- **Multiple Instances**: Run multiple Codespaces for different branches/features
|
||||||
|
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
|
||||||
|
- **VS Code Integration**: Works seamlessly with VS Code desktop app
|
||||||
|
|
||||||
|
### Managing Codespaces
|
||||||
|
|
||||||
|
- **List active Codespaces**: `gh cs list`
|
||||||
|
- **SSH into a Codespace**: `gh cs ssh`
|
||||||
|
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
|
||||||
|
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
|
||||||
|
|
||||||
|
### Debugging and Logs
|
||||||
|
|
||||||
|
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stream logs from all services
|
||||||
|
docker compose -f docker-compose-light.yml logs -f
|
||||||
|
|
||||||
|
# Stream logs from a specific service
|
||||||
|
docker compose -f docker-compose-light.yml logs -f superset
|
||||||
|
|
||||||
|
# View last 100 lines and follow
|
||||||
|
docker compose -f docker-compose-light.yml logs --tail=100 -f
|
||||||
|
|
||||||
|
# List all running services
|
||||||
|
docker compose -f docker-compose-light.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
Codespaces automatically stop after 30 minutes of inactivity to save resources.
|
||||||
|
Your work is preserved and you can restart anytime.
|
||||||
|
:::
|
||||||
|
|
||||||
## Installing Development Tools
|
## Installing Development Tools
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
@@ -349,14 +421,6 @@ Then make sure you run your WSGI server using the right worker type:
|
|||||||
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
|
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
|
||||||
```
|
```
|
||||||
|
|
||||||
You can log anything to the browser console, including objects:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from superset import app
|
|
||||||
app.logger.error('An exception occurred!')
|
|
||||||
app.logger.info(form_data)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
|
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
|
||||||
|
|||||||
+1
-1
@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
|
|||||||
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
|
||||||
bigquery = [
|
bigquery = [
|
||||||
"pandas-gbq>=0.19.1",
|
"pandas-gbq>=0.19.1",
|
||||||
"sqlalchemy-bigquery>=1.6.1",
|
"sqlalchemy-bigquery>=1.15.0",
|
||||||
"google-cloud-bigquery>=3.10.0",
|
"google-cloud-bigquery>=3.10.0",
|
||||||
]
|
]
|
||||||
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
|
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]
|
||||||
|
|||||||
@@ -795,7 +795,7 @@ sqlalchemy==1.4.54
|
|||||||
# shillelagh
|
# shillelagh
|
||||||
# sqlalchemy-bigquery
|
# sqlalchemy-bigquery
|
||||||
# sqlalchemy-utils
|
# sqlalchemy-utils
|
||||||
sqlalchemy-bigquery==1.12.0
|
sqlalchemy-bigquery==1.15.0
|
||||||
# via apache-superset
|
# via apache-superset
|
||||||
sqlalchemy-utils==0.38.3
|
sqlalchemy-utils==0.38.3
|
||||||
# via
|
# via
|
||||||
|
|||||||
@@ -33,4 +33,4 @@ superset load-test-users
|
|||||||
|
|
||||||
echo "Running tests"
|
echo "Running tests"
|
||||||
|
|
||||||
pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
pytest --durations-min=2 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
||||||
|
|||||||
@@ -143,6 +143,7 @@ module.exports = {
|
|||||||
],
|
],
|
||||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'prettier'],
|
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'prettier'],
|
||||||
rules: {
|
rules: {
|
||||||
|
'no-console': 'error',
|
||||||
'@typescript-eslint/ban-ts-ignore': 0,
|
'@typescript-eslint/ban-ts-ignore': 0,
|
||||||
'@typescript-eslint/ban-ts-comment': 0, // disabled temporarily
|
'@typescript-eslint/ban-ts-comment': 0, // disabled temporarily
|
||||||
'@typescript-eslint/ban-types': 0, // disabled temporarily
|
'@typescript-eslint/ban-types': 0, // disabled temporarily
|
||||||
@@ -340,6 +341,20 @@ module.exports = {
|
|||||||
'plugin:testing-library/react',
|
'plugin:testing-library/react',
|
||||||
],
|
],
|
||||||
rules: {
|
rules: {
|
||||||
|
'no-console': 'off', // Allow console usage in test files
|
||||||
|
'no-restricted-imports': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
paths: [
|
||||||
|
{
|
||||||
|
name: '@superset-ui/core',
|
||||||
|
importNames: ['logging'],
|
||||||
|
message:
|
||||||
|
'Do not use logging in test files. Use console statements instead for testing.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
'import/no-extraneous-dependencies': [
|
'import/no-extraneous-dependencies': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@@ -373,7 +388,6 @@ module.exports = {
|
|||||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'no-restricted-imports': 0,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -397,9 +411,16 @@ module.exports = {
|
|||||||
'react/no-void-elements': 0,
|
'react/no-void-elements': 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/**/*'],
|
||||||
|
rules: {
|
||||||
|
'no-console': 'off', // Allow console usage in scripts directory
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line no-dupe-keys
|
// eslint-disable-next-line no-dupe-keys
|
||||||
rules: {
|
rules: {
|
||||||
|
'no-console': 'error',
|
||||||
'theme-colors/no-literal-colors': 'error',
|
'theme-colors/no-literal-colors': 'error',
|
||||||
'icons/no-fa-icons-usage': 'error',
|
'icons/no-fa-icons-usage': 'error',
|
||||||
'i18n-strings/no-template-vars': ['error', true],
|
'i18n-strings/no-template-vars': ['error', true],
|
||||||
|
|||||||
@@ -94,67 +94,12 @@ describe('Charts list', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('list mode', () => {
|
|
||||||
before(() => {
|
|
||||||
visitChartList();
|
|
||||||
setGridMode('list');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should load rows in list mode', () => {
|
|
||||||
cy.getBySel('listview-table').should('be.visible');
|
|
||||||
cy.getBySel('sort-header').eq(1).contains('Name');
|
|
||||||
cy.getBySel('sort-header').eq(2).contains('Type');
|
|
||||||
cy.getBySel('sort-header').eq(3).contains('Dataset');
|
|
||||||
cy.getBySel('sort-header').eq(4).contains('On dashboards');
|
|
||||||
cy.getBySel('sort-header').eq(5).contains('Owners');
|
|
||||||
cy.getBySel('sort-header').eq(6).contains('Last modified');
|
|
||||||
cy.getBySel('sort-header').eq(7).contains('Actions');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should bulk select in list mode', () => {
|
|
||||||
toggleBulkSelect();
|
|
||||||
cy.get('[aria-label="Select all"]').click();
|
|
||||||
cy.get('input[type="checkbox"]:checked').should('have.length', 26);
|
|
||||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
|
||||||
cy.getBySel('bulk-select-action')
|
|
||||||
.should('have.length', 2)
|
|
||||||
.then($btns => {
|
|
||||||
expect($btns).to.contain('Delete');
|
|
||||||
expect($btns).to.contain('Export');
|
|
||||||
});
|
|
||||||
cy.getBySel('bulk-select-deselect-all').click();
|
|
||||||
cy.get('input[type="checkbox"]:checked').should('have.length', 0);
|
|
||||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
|
||||||
cy.getBySel('bulk-select-action').should('not.exist');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('card mode', () => {
|
describe('card mode', () => {
|
||||||
before(() => {
|
before(() => {
|
||||||
visitChartList();
|
visitChartList();
|
||||||
setGridMode('card');
|
setGridMode('card');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should load rows in card mode', () => {
|
|
||||||
cy.getBySel('listview-table').should('not.exist');
|
|
||||||
cy.getBySel('styled-card').should('have.length', 25);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should bulk select in card mode', () => {
|
|
||||||
toggleBulkSelect();
|
|
||||||
cy.getBySel('styled-card').click({ multiple: true });
|
|
||||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
|
||||||
cy.getBySel('bulk-select-action')
|
|
||||||
.should('have.length', 2)
|
|
||||||
.then($btns => {
|
|
||||||
expect($btns).to.contain('Delete');
|
|
||||||
expect($btns).to.contain('Export');
|
|
||||||
});
|
|
||||||
cy.getBySel('bulk-select-deselect-all').click();
|
|
||||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
|
||||||
cy.getBySel('bulk-select-action').should('not.exist');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should preserve other filters when sorting', () => {
|
it('should preserve other filters when sorting', () => {
|
||||||
cy.getBySel('styled-card').should('have.length', 25);
|
cy.getBySel('styled-card').should('have.length', 25);
|
||||||
setFilter('Type', 'Big Number');
|
setFilter('Type', 'Big Number');
|
||||||
|
|||||||
@@ -65,11 +65,16 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
|
|||||||
)
|
)
|
||||||
.should('be.visible')
|
.should('be.visible')
|
||||||
.find('[role="menuitem"]')
|
.find('[role="menuitem"]')
|
||||||
.then($el => {
|
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||||
cy.wrap($el)
|
.click();
|
||||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
|
||||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
cy.get(
|
||||||
});
|
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||||
|
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
|
||||||
|
|
||||||
|
cy.get(
|
||||||
|
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||||
|
).should('not.exist');
|
||||||
|
|
||||||
if (isLegacy) {
|
if (isLegacy) {
|
||||||
return cy.wait('@legacyData');
|
return cy.wait('@legacyData');
|
||||||
@@ -240,7 +245,7 @@ describe('Drill by modal', () => {
|
|||||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('opens the modal from the context menu', () => {
|
it.only('opens the modal from the context menu', () => {
|
||||||
openTableContextMenu('boy');
|
openTableContextMenu('boy');
|
||||||
drillBy('state').then(intercepted => {
|
drillBy('state').then(intercepted => {
|
||||||
verifyExpectedFormData(intercepted, {
|
verifyExpectedFormData(intercepted, {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
dataTestChartName,
|
dataTestChartName,
|
||||||
} from 'cypress/support/directories';
|
} from 'cypress/support/directories';
|
||||||
|
|
||||||
|
import { waitForChartLoad } from 'cypress/utils';
|
||||||
import {
|
import {
|
||||||
addParentFilterWithValue,
|
addParentFilterWithValue,
|
||||||
applyNativeFilterValueWithIndex,
|
applyNativeFilterValueWithIndex,
|
||||||
@@ -160,6 +161,74 @@ describe('Native filters', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||||
|
prepareDashboardFilters([
|
||||||
|
{ name: 'region', column: 'region', datasetId: 2 },
|
||||||
|
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
enterNativeFilterEditModal();
|
||||||
|
|
||||||
|
selectFilter(0);
|
||||||
|
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||||
|
() => {
|
||||||
|
cy.contains('Select first filter value by default')
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||||
|
() => {
|
||||||
|
cy.contains('Can select multiple values ')
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
selectFilter(1);
|
||||||
|
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||||
|
() => {
|
||||||
|
cy.contains('Values are dependent on other filters')
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||||
|
() => {
|
||||||
|
cy.contains('Can select multiple values ')
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||||
|
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||||
|
() => {
|
||||||
|
cy.contains('Select first filter value by default')
|
||||||
|
.should('be.visible')
|
||||||
|
.click();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// cannot use saveNativeFilterSettings because there is a bug which
|
||||||
|
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
|
||||||
|
// to be saved when using dependent filters so,
|
||||||
|
// you reload the window.
|
||||||
|
cy.get(nativeFilters.modal.footer)
|
||||||
|
.contains('Save')
|
||||||
|
.should('be.visible')
|
||||||
|
.click({ force: true });
|
||||||
|
|
||||||
|
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||||
|
cy.reload();
|
||||||
|
|
||||||
|
applyNativeFilterValueWithIndex(0, 'North America');
|
||||||
|
|
||||||
|
// Check that dependent filter auto-selects the first item
|
||||||
|
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||||
|
.eq(1)
|
||||||
|
.should('contain.text', 'Bermuda');
|
||||||
|
});
|
||||||
|
|
||||||
it('User can create filter depend on 2 other filters', () => {
|
it('User can create filter depend on 2 other filters', () => {
|
||||||
prepareDashboardFilters([
|
prepareDashboardFilters([
|
||||||
{ name: 'region', column: 'region', datasetId: 2 },
|
{ name: 'region', column: 'region', datasetId: 2 },
|
||||||
|
|||||||
@@ -68,11 +68,13 @@ function verifyDashboardSearch() {
|
|||||||
function verifyDashboardLink() {
|
function verifyDashboardLink() {
|
||||||
interceptDashboardGet();
|
interceptDashboardGet();
|
||||||
openDashboardsAddedTo();
|
openDashboardsAddedTo();
|
||||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
|
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
cy.get('.ant-dropdown-menu-submenu-popup a')
|
cy.get('.ant-dropdown-menu-submenu-popup a')
|
||||||
.first()
|
.first()
|
||||||
.invoke('removeAttr', 'target')
|
.invoke('removeAttr', 'target')
|
||||||
.click();
|
.click({ force: true });
|
||||||
cy.wait('@get');
|
cy.wait('@get');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Generated
+1607
-31
File diff suppressed because it is too large
Load Diff
@@ -121,8 +121,8 @@
|
|||||||
"@visx/scale": "^3.5.0",
|
"@visx/scale": "^3.5.0",
|
||||||
"@visx/tooltip": "^3.0.0",
|
"@visx/tooltip": "^3.0.0",
|
||||||
"@visx/xychart": "^3.5.1",
|
"@visx/xychart": "^3.5.1",
|
||||||
"ag-grid-community": "33.1.1",
|
"ag-grid-community": "^34.0.2",
|
||||||
"ag-grid-react": "33.1.1",
|
"ag-grid-react": "34.0.2",
|
||||||
"antd": "^5.24.6",
|
"antd": "^5.24.6",
|
||||||
"chrono-node": "^2.7.8",
|
"chrono-node": "^2.7.8",
|
||||||
"classnames": "^2.2.5",
|
"classnames": "^2.2.5",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^7.0.3",
|
"cross-env": "^7.0.3",
|
||||||
"fs-extra": "^11.3.0",
|
"fs-extra": "^11.3.0",
|
||||||
"jest": "^30.0.2",
|
"jest": "^30.0.4",
|
||||||
"yeoman-test": "^10.1.1"
|
"yeoman-test": "^10.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+52
-10
@@ -41,6 +41,53 @@ import {
|
|||||||
import { checkColumnType } from '../utils/checkColumnType';
|
import { checkColumnType } from '../utils/checkColumnType';
|
||||||
import { isSortable } from '../utils/isSortable';
|
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 = {
|
export const contributionModeControl = {
|
||||||
name: 'contributionMode',
|
name: 'contributionMode',
|
||||||
config: {
|
config: {
|
||||||
@@ -69,17 +116,12 @@ export const aggregationControl = {
|
|||||||
default: 'LAST_VALUE',
|
default: 'LAST_VALUE',
|
||||||
clearable: false,
|
clearable: false,
|
||||||
renderTrigger: false,
|
renderTrigger: false,
|
||||||
choices: [
|
choices: Object.entries(aggregationChoices).map(([value, { label }]) => [
|
||||||
['raw', t('None')],
|
value,
|
||||||
['LAST_VALUE', t('Last Value')],
|
t(label),
|
||||||
['sum', t('Total (Sum)')],
|
]),
|
||||||
['mean', t('Average (Mean)')],
|
|
||||||
['min', t('Minimum')],
|
|
||||||
['max', t('Maximum')],
|
|
||||||
['median', t('Median')],
|
|
||||||
],
|
|
||||||
description: t(
|
description: t(
|
||||||
'Aggregation method used to compute the Big Number from the Trendline.For non-additive metrics like ratios, averages, distinct counts, etc use NONE.',
|
'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.',
|
||||||
),
|
),
|
||||||
provideFormDataToProps: true,
|
provideFormDataToProps: true,
|
||||||
mapStateToProps: ({ form_data }: ControlPanelState) => ({
|
mapStateToProps: ({ form_data }: ControlPanelState) => ({
|
||||||
|
|||||||
+2
@@ -177,6 +177,7 @@ const granularity: SharedControlConfig<'SelectControl'> = {
|
|||||||
'can type and use simple natural language as in `10 seconds`, ' +
|
'can type and use simple natural language as in `10 seconds`, ' +
|
||||||
'`1 day` or `56 weeks`',
|
'`1 day` or `56 weeks`',
|
||||||
),
|
),
|
||||||
|
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||||
};
|
};
|
||||||
|
|
||||||
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||||
@@ -204,6 +205,7 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
|||||||
choices: (datasource as Dataset)?.time_grain_sqla || [],
|
choices: (datasource as Dataset)?.time_grain_sqla || [],
|
||||||
}),
|
}),
|
||||||
visibility: displayTimeRelatedControls,
|
visibility: displayTimeRelatedControls,
|
||||||
|
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||||
};
|
};
|
||||||
|
|
||||||
const time_range: SharedControlConfig<'DateFilterControl'> = {
|
const time_range: SharedControlConfig<'DateFilterControl'> = {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
"d3-format": "^1.3.2",
|
"d3-format": "^1.3.2",
|
||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"d3-interpolate": "^3.0.1",
|
"d3-interpolate": "^3.0.1",
|
||||||
"d3-scale": "^3.0.0",
|
"d3-scale": "^4.0.2",
|
||||||
"d3-time": "^3.1.0",
|
"d3-time": "^3.1.0",
|
||||||
"d3-time-format": "^4.1.0",
|
"d3-time-format": "^4.1.0",
|
||||||
"dompurify": "^3.2.4",
|
"dompurify": "^3.2.4",
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
"rehype-raw": "^7.0.0",
|
"rehype-raw": "^7.0.0",
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"reselect": "^4.0.0",
|
"reselect": "^5.1.1",
|
||||||
"rison": "^0.1.1",
|
"rison": "^0.1.1",
|
||||||
"seedrandom": "^3.0.5",
|
"seedrandom": "^3.0.5",
|
||||||
"@visx/responsive": "^3.12.0",
|
"@visx/responsive": "^3.12.0",
|
||||||
|
|||||||
@@ -17,11 +17,8 @@
|
|||||||
* under the License.
|
* 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 { RefObject } from 'react';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector, lruMemoize } from 'reselect';
|
||||||
import {
|
import {
|
||||||
AppSection,
|
AppSection,
|
||||||
Behavior,
|
Behavior,
|
||||||
@@ -37,7 +34,7 @@ import {
|
|||||||
SetDataMaskHook,
|
SetDataMaskHook,
|
||||||
} from '../types/Base';
|
} from '../types/Base';
|
||||||
import { QueryData, DataRecordFilters } from '..';
|
import { QueryData, DataRecordFilters } from '..';
|
||||||
import { SupersetTheme } from '../../theme';
|
import { supersetTheme, SupersetTheme } from '../../theme';
|
||||||
|
|
||||||
// TODO: more specific typing for these fields of ChartProps
|
// TODO: more specific typing for these fields of ChartProps
|
||||||
type AnnotationData = PlainObject;
|
type AnnotationData = PlainObject;
|
||||||
@@ -109,6 +106,8 @@ export interface ChartPropsConfig {
|
|||||||
theme: SupersetTheme;
|
theme: SupersetTheme;
|
||||||
/* legend index */
|
/* legend index */
|
||||||
legendIndex?: number;
|
legendIndex?: number;
|
||||||
|
inContextMenu?: boolean;
|
||||||
|
emitCrossFilters?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_WIDTH = 800;
|
const DEFAULT_WIDTH = 800;
|
||||||
@@ -161,7 +160,11 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
|
|||||||
|
|
||||||
theme: SupersetTheme;
|
theme: SupersetTheme;
|
||||||
|
|
||||||
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
|
constructor(
|
||||||
|
config: ChartPropsConfig & { formData?: FormData } = {
|
||||||
|
theme: supersetTheme,
|
||||||
|
},
|
||||||
|
) {
|
||||||
const {
|
const {
|
||||||
annotationData = {},
|
annotationData = {},
|
||||||
datasource = {},
|
datasource = {},
|
||||||
@@ -276,5 +279,16 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
|
|||||||
emitCrossFilters,
|
emitCrossFilters,
|
||||||
theme,
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
+2
-2
@@ -20,7 +20,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
|
import SyntaxHighlighterBase from 'react-syntax-highlighter/dist/cjs/light';
|
||||||
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
|
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
|
||||||
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
|
import tomorrow from 'react-syntax-highlighter/dist/cjs/styles/hljs/tomorrow-night';
|
||||||
import { useTheme, isThemeDark } from '@superset-ui/core';
|
import { useTheme, isThemeDark, logging } from '@superset-ui/core';
|
||||||
|
|
||||||
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
|
export type SupportedLanguage = 'sql' | 'htmlbars' | 'markdown' | 'json';
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ const registerLanguage = async (language: SupportedLanguage): Promise<void> => {
|
|||||||
SyntaxHighlighterBase.registerLanguage(language, languageModule.default);
|
SyntaxHighlighterBase.registerLanguage(language, languageModule.default);
|
||||||
registeredLanguages.add(language);
|
registeredLanguages.add(language);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to load language ${language}:`, error);
|
logging.warn(`Failed to load language ${language}:`, error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { t } from '@superset-ui/core';
|
import { logging, t } from '@superset-ui/core';
|
||||||
import { Button } from '../Button';
|
import { Button } from '../Button';
|
||||||
import { Form } from '../Form';
|
import { Form } from '../Form';
|
||||||
import { Modal } from './Modal';
|
import { Modal } from './Modal';
|
||||||
@@ -60,7 +60,7 @@ export function FormModal({
|
|||||||
await formSubmitHandler(values);
|
await formSubmitHandler(values);
|
||||||
handleSave();
|
handleSave();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
logging.error(err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { render } from '@superset-ui/core/spec';
|
import { render } from '@superset-ui/core/spec';
|
||||||
import TelemetryPixel from '.';
|
import { TelemetryPixel } from '.';
|
||||||
|
|
||||||
const OLD_ENV = process.env;
|
const OLD_ENV = process.env;
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
|
|||||||
|
|
||||||
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
||||||
|
|
||||||
const TelemetryPixel = ({
|
export const TelemetryPixel = ({
|
||||||
version = 'unknownVersion',
|
version = 'unknownVersion',
|
||||||
sha = 'unknownSHA',
|
sha = 'unknownSHA',
|
||||||
build = 'unknownBuild',
|
build = 'unknownBuild',
|
||||||
@@ -56,4 +56,3 @@ const TelemetryPixel = ({
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default TelemetryPixel;
|
|
||||||
|
|||||||
@@ -1,116 +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 { Dropdown, Icons } from '@superset-ui/core/components';
|
|
||||||
import type { MenuItem } from '@superset-ui/core/components/Menu';
|
|
||||||
import { t, useTheme } from '@superset-ui/core';
|
|
||||||
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
|
|
||||||
|
|
||||||
export interface ThemeSelectProps {
|
|
||||||
setThemeMode: (newMode: ThemeMode) => void;
|
|
||||||
tooltipTitle?: string;
|
|
||||||
themeMode: ThemeMode;
|
|
||||||
hasLocalOverride?: boolean;
|
|
||||||
onClearLocalSettings?: () => void;
|
|
||||||
allowOSPreference?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ThemeSelect: React.FC<ThemeSelectProps> = ({
|
|
||||||
setThemeMode,
|
|
||||||
tooltipTitle = 'Select theme',
|
|
||||||
themeMode,
|
|
||||||
hasLocalOverride = false,
|
|
||||||
onClearLocalSettings,
|
|
||||||
allowOSPreference = true,
|
|
||||||
}) => {
|
|
||||||
const theme = useTheme();
|
|
||||||
|
|
||||||
const handleSelect = (mode: ThemeMode) => {
|
|
||||||
setThemeMode(mode);
|
|
||||||
};
|
|
||||||
|
|
||||||
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
|
|
||||||
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
|
|
||||||
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
|
|
||||||
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
|
|
||||||
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Use different icon when local theme is active
|
|
||||||
const triggerIcon = hasLocalOverride ? (
|
|
||||||
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
|
|
||||||
) : (
|
|
||||||
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
|
|
||||||
);
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{
|
|
||||||
type: 'group',
|
|
||||||
label: t('Theme'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ThemeMode.DEFAULT,
|
|
||||||
label: t('Light'),
|
|
||||||
icon: <Icons.SunOutlined />,
|
|
||||||
onClick: () => handleSelect(ThemeMode.DEFAULT),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: ThemeMode.DARK,
|
|
||||||
label: t('Dark'),
|
|
||||||
icon: <Icons.MoonOutlined />,
|
|
||||||
onClick: () => handleSelect(ThemeMode.DARK),
|
|
||||||
},
|
|
||||||
...(allowOSPreference
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
key: ThemeMode.SYSTEM,
|
|
||||||
label: t('Match system'),
|
|
||||||
icon: <Icons.FormatPainterOutlined />,
|
|
||||||
onClick: () => handleSelect(ThemeMode.SYSTEM),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: []),
|
|
||||||
];
|
|
||||||
|
|
||||||
// Add clear settings option only when there's a local theme active
|
|
||||||
if (onClearLocalSettings && hasLocalOverride) {
|
|
||||||
menuItems.push(
|
|
||||||
{ type: 'divider' } as MenuItem,
|
|
||||||
{
|
|
||||||
key: 'clear-local',
|
|
||||||
label: t('Clear local theme'),
|
|
||||||
icon: <Icons.ClearOutlined />,
|
|
||||||
onClick: onClearLocalSettings,
|
|
||||||
} as MenuItem,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dropdown
|
|
||||||
menu={{
|
|
||||||
items: menuItems,
|
|
||||||
selectedKeys: [themeMode],
|
|
||||||
}}
|
|
||||||
trigger={['hover']}
|
|
||||||
>
|
|
||||||
{triggerIcon}
|
|
||||||
</Dropdown>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ThemeSelect;
|
|
||||||
+273
@@ -0,0 +1,273 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
userEvent,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@superset-ui/core/spec';
|
||||||
|
import { ThemeMode } from '@superset-ui/core';
|
||||||
|
import { Menu } from '@superset-ui/core/components';
|
||||||
|
import { ThemeSubMenu } from '.';
|
||||||
|
|
||||||
|
// Mock the translation function
|
||||||
|
jest.mock('@superset-ui/core', () => ({
|
||||||
|
...jest.requireActual('@superset-ui/core'),
|
||||||
|
t: (key: string) => key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('ThemeSubMenu', () => {
|
||||||
|
const defaultProps = {
|
||||||
|
allowOSPreference: true,
|
||||||
|
setThemeMode: jest.fn(),
|
||||||
|
themeMode: ThemeMode.DEFAULT,
|
||||||
|
hasLocalOverride: false,
|
||||||
|
onClearLocalSettings: jest.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderThemeSubMenu = (props = defaultProps) =>
|
||||||
|
render(
|
||||||
|
<Menu>
|
||||||
|
<ThemeSubMenu {...props} />
|
||||||
|
</Menu>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const findMenuWithText = async (text: string) => {
|
||||||
|
await waitFor(() => {
|
||||||
|
const found = screen
|
||||||
|
.getAllByRole('menu')
|
||||||
|
.some(m => within(m).queryByText(text));
|
||||||
|
|
||||||
|
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
|
||||||
|
});
|
||||||
|
|
||||||
|
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Light and Dark theme options by default', async () => {
|
||||||
|
renderThemeSubMenu();
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Light');
|
||||||
|
|
||||||
|
expect(within(menu!).getByText('Light')).toBeInTheDocument();
|
||||||
|
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render Match system option when allowOSPreference is false', async () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with allowOSPreference as true by default', async () => {
|
||||||
|
renderThemeSubMenu();
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Match system');
|
||||||
|
|
||||||
|
expect(within(menu).getByText('Match system')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
|
||||||
|
const mockClear = jest.fn();
|
||||||
|
renderThemeSubMenu({
|
||||||
|
...defaultProps,
|
||||||
|
hasLocalOverride: true,
|
||||||
|
onClearLocalSettings: mockClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Clear local theme');
|
||||||
|
|
||||||
|
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render clear option when hasLocalOverride is false', async () => {
|
||||||
|
const mockClear = jest.fn();
|
||||||
|
renderThemeSubMenu({
|
||||||
|
...defaultProps,
|
||||||
|
hasLocalOverride: false,
|
||||||
|
onClearLocalSettings: mockClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
|
||||||
|
const mockSet = jest.fn();
|
||||||
|
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Light');
|
||||||
|
userEvent.click(within(menu).getByText('Light'));
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setThemeMode with DARK when Dark is clicked', async () => {
|
||||||
|
const mockSet = jest.fn();
|
||||||
|
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Dark');
|
||||||
|
userEvent.click(within(menu).getByText('Dark'));
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
|
||||||
|
const mockSet = jest.fn();
|
||||||
|
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Match system');
|
||||||
|
userEvent.click(within(menu).getByText('Match system'));
|
||||||
|
|
||||||
|
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
|
||||||
|
const mockClear = jest.fn();
|
||||||
|
renderThemeSubMenu({
|
||||||
|
...defaultProps,
|
||||||
|
hasLocalOverride: true,
|
||||||
|
onClearLocalSettings: mockClear,
|
||||||
|
});
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Clear local theme');
|
||||||
|
userEvent.click(within(menu).getByText('Clear local theme'));
|
||||||
|
|
||||||
|
expect(mockClear).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays sun icon for DEFAULT theme', () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
|
||||||
|
expect(screen.getByTestId('sun')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays moon icon for DARK theme', () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
|
||||||
|
expect(screen.getByTestId('moon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays format-painter icon for SYSTEM theme', () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
|
||||||
|
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays override icon when hasLocalOverride is true', () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
|
||||||
|
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Theme group header', async () => {
|
||||||
|
renderThemeSubMenu();
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Theme');
|
||||||
|
|
||||||
|
expect(within(menu).getByText('Theme')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders sun icon for Light theme option', async () => {
|
||||||
|
renderThemeSubMenu();
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Light');
|
||||||
|
const lightOption = within(menu).getByText('Light').closest('li');
|
||||||
|
|
||||||
|
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders moon icon for Dark theme option', async () => {
|
||||||
|
renderThemeSubMenu();
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Dark');
|
||||||
|
const darkOption = within(menu).getByText('Dark').closest('li');
|
||||||
|
|
||||||
|
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders format-painter icon for Match system option', async () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Match system');
|
||||||
|
const matchOption = within(menu).getByText('Match system').closest('li');
|
||||||
|
|
||||||
|
expect(
|
||||||
|
within(matchOption!).getByTestId('format-painter'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders clear icon for Clear local theme option', async () => {
|
||||||
|
renderThemeSubMenu({
|
||||||
|
...defaultProps,
|
||||||
|
hasLocalOverride: true,
|
||||||
|
onClearLocalSettings: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const menu = await findMenuWithText('Clear local theme');
|
||||||
|
const clearOption = within(menu)
|
||||||
|
.getByText('Clear local theme')
|
||||||
|
.closest('li');
|
||||||
|
|
||||||
|
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders divider before clear option when clear option is present', async () => {
|
||||||
|
renderThemeSubMenu({
|
||||||
|
...defaultProps,
|
||||||
|
hasLocalOverride: true,
|
||||||
|
onClearLocalSettings: jest.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
|
||||||
|
const menu = await findMenuWithText('Clear local theme');
|
||||||
|
const divider = within(menu).queryByRole('separator');
|
||||||
|
|
||||||
|
expect(divider).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render divider when clear option is not present', async () => {
|
||||||
|
renderThemeSubMenu({ ...defaultProps });
|
||||||
|
|
||||||
|
userEvent.hover(await screen.findByRole('menuitem'));
|
||||||
|
const divider = document.querySelector('.ant-menu-item-divider');
|
||||||
|
|
||||||
|
expect(divider).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { useMemo } from 'react';
|
||||||
|
import { Icons, Menu } from '@superset-ui/core/components';
|
||||||
|
import {
|
||||||
|
css,
|
||||||
|
styled,
|
||||||
|
t,
|
||||||
|
ThemeMode,
|
||||||
|
useTheme,
|
||||||
|
ThemeAlgorithm,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
|
const StyledThemeSubMenu = styled(Menu.SubMenu)`
|
||||||
|
${({ theme }) => css`
|
||||||
|
[data-icon='caret-down'] {
|
||||||
|
color: ${theme.colorIcon};
|
||||||
|
font-size: ${theme.fontSizeXS}px;
|
||||||
|
margin-left: ${theme.sizeUnit}px;
|
||||||
|
}
|
||||||
|
&.ant-menu-submenu-active {
|
||||||
|
.ant-menu-title-content {
|
||||||
|
color: ${theme.colorPrimary};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
|
||||||
|
${({ theme, selected }) => css`
|
||||||
|
&:hover {
|
||||||
|
color: ${theme.colorPrimary} !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
${selected &&
|
||||||
|
css`
|
||||||
|
background-color: ${theme.colors.primary.light4} !important;
|
||||||
|
color: ${theme.colors.primary.dark1} !important;
|
||||||
|
`}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export interface ThemeSubMenuOption {
|
||||||
|
key: ThemeMode;
|
||||||
|
label: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ThemeSubMenuProps {
|
||||||
|
setThemeMode: (newMode: ThemeMode) => void;
|
||||||
|
themeMode: ThemeMode;
|
||||||
|
hasLocalOverride?: boolean;
|
||||||
|
onClearLocalSettings?: () => void;
|
||||||
|
allowOSPreference?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
|
||||||
|
setThemeMode,
|
||||||
|
themeMode,
|
||||||
|
hasLocalOverride = false,
|
||||||
|
onClearLocalSettings,
|
||||||
|
allowOSPreference = true,
|
||||||
|
}: ThemeSubMenuProps) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
const handleSelect = (mode: ThemeMode) => {
|
||||||
|
setThemeMode(mode);
|
||||||
|
};
|
||||||
|
|
||||||
|
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
|
||||||
|
useMemo(
|
||||||
|
() => ({
|
||||||
|
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
|
||||||
|
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
|
||||||
|
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
|
||||||
|
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedThemeModeIcon = useMemo(
|
||||||
|
() =>
|
||||||
|
hasLocalOverride ? (
|
||||||
|
<Icons.FormatPainterOutlined
|
||||||
|
style={{ color: theme.colors.error.base }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
themeIconMap[themeMode]
|
||||||
|
),
|
||||||
|
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
|
||||||
|
);
|
||||||
|
|
||||||
|
const themeOptions: ThemeSubMenuOption[] = [
|
||||||
|
{
|
||||||
|
key: ThemeMode.DEFAULT,
|
||||||
|
label: t('Light'),
|
||||||
|
icon: <Icons.SunOutlined />,
|
||||||
|
onClick: () => handleSelect(ThemeMode.DEFAULT),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: ThemeMode.DARK,
|
||||||
|
label: t('Dark'),
|
||||||
|
icon: <Icons.MoonOutlined />,
|
||||||
|
onClick: () => handleSelect(ThemeMode.DARK),
|
||||||
|
},
|
||||||
|
...(allowOSPreference
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: ThemeMode.SYSTEM,
|
||||||
|
label: t('Match system'),
|
||||||
|
icon: <Icons.FormatPainterOutlined />,
|
||||||
|
onClick: () => handleSelect(ThemeMode.SYSTEM),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add clear settings option only when there's a local theme active
|
||||||
|
const clearOption =
|
||||||
|
onClearLocalSettings && hasLocalOverride
|
||||||
|
? {
|
||||||
|
key: 'clear-local',
|
||||||
|
label: t('Clear local theme'),
|
||||||
|
icon: <Icons.ClearOutlined />,
|
||||||
|
onClick: onClearLocalSettings,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledThemeSubMenu
|
||||||
|
key="theme-sub-menu"
|
||||||
|
title={selectedThemeModeIcon}
|
||||||
|
icon={<Icons.CaretDownOutlined iconSize="xs" />}
|
||||||
|
>
|
||||||
|
<Menu.ItemGroup title={t('Theme')} />
|
||||||
|
{themeOptions.map(option => (
|
||||||
|
<StyledThemeSubMenuItem
|
||||||
|
key={option.key}
|
||||||
|
onClick={option.onClick}
|
||||||
|
selected={option.key === themeMode}
|
||||||
|
>
|
||||||
|
{option.icon} {option.label}
|
||||||
|
</StyledThemeSubMenuItem>
|
||||||
|
))}
|
||||||
|
{clearOption && [
|
||||||
|
<Menu.Divider key="theme-divider" />,
|
||||||
|
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
|
||||||
|
{clearOption.icon} {clearOption.label}
|
||||||
|
</Menu.Item>,
|
||||||
|
]}
|
||||||
|
</StyledThemeSubMenu>
|
||||||
|
);
|
||||||
|
};
|
||||||
+29
-71
@@ -16,14 +16,8 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { t, css, useTheme } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
import {
|
import { Icons, Modal, Typography, Button } from '@superset-ui/core/components';
|
||||||
Icons,
|
|
||||||
Modal,
|
|
||||||
Typography,
|
|
||||||
Button,
|
|
||||||
Flex,
|
|
||||||
} from '@superset-ui/core/components';
|
|
||||||
import type { FC, ReactElement } from 'react';
|
import type { FC, ReactElement } from 'react';
|
||||||
|
|
||||||
export type UnsavedChangesModalProps = {
|
export type UnsavedChangesModalProps = {
|
||||||
@@ -42,66 +36,30 @@ export const UnsavedChangesModal: FC<UnsavedChangesModalProps> = ({
|
|||||||
onConfirmNavigation,
|
onConfirmNavigation,
|
||||||
title = 'Unsaved Changes',
|
title = 'Unsaved Changes',
|
||||||
body = "If you don't save, changes will be lost.",
|
body = "If you don't save, changes will be lost.",
|
||||||
}): ReactElement => {
|
}: UnsavedChangesModalProps): ReactElement => (
|
||||||
const theme = useTheme();
|
<Modal
|
||||||
|
centered
|
||||||
return (
|
responsive
|
||||||
<Modal
|
onHide={onHide}
|
||||||
name={title}
|
show={showModal}
|
||||||
centered
|
width="444px"
|
||||||
responsive
|
title={
|
||||||
onHide={onHide}
|
<>
|
||||||
show={showModal}
|
<Icons.WarningOutlined iconSize="m" style={{ marginRight: 8 }} />
|
||||||
width="444px"
|
{title}
|
||||||
title={
|
</>
|
||||||
<Flex>
|
}
|
||||||
<Icons.WarningOutlined
|
footer={
|
||||||
iconColor={theme.colorWarning}
|
<>
|
||||||
css={css`
|
<Button buttonStyle="secondary" onClick={onConfirmNavigation}>
|
||||||
margin-right: ${theme.sizeUnit * 2}px;
|
{t('Discard')}
|
||||||
`}
|
</Button>
|
||||||
iconSize="l"
|
<Button buttonStyle="primary" onClick={handleSave}>
|
||||||
/>
|
{t('Save')}
|
||||||
<Typography.Title
|
</Button>
|
||||||
css={css`
|
</>
|
||||||
&& {
|
}
|
||||||
margin: 0;
|
>
|
||||||
margin-bottom: 0;
|
<Typography.Text>{body}</Typography.Text>
|
||||||
}
|
</Modal>
|
||||||
`}
|
);
|
||||||
level={5}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
</Typography.Title>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
footer={
|
|
||||||
<Flex
|
|
||||||
justify="flex-end"
|
|
||||||
css={css`
|
|
||||||
width: 100%;
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
htmlType="button"
|
|
||||||
buttonSize="small"
|
|
||||||
buttonStyle="secondary"
|
|
||||||
onClick={onConfirmNavigation}
|
|
||||||
>
|
|
||||||
{t('Discard')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
htmlType="button"
|
|
||||||
buttonSize="small"
|
|
||||||
buttonStyle="primary"
|
|
||||||
onClick={handleSave}
|
|
||||||
>
|
|
||||||
{t('Save')}
|
|
||||||
</Button>
|
|
||||||
</Flex>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Typography.Text>{body}</Typography.Text>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -164,6 +164,8 @@ export * from './Steps';
|
|||||||
export * from './Table';
|
export * from './Table';
|
||||||
export * from './TableView';
|
export * from './TableView';
|
||||||
export * from './Tag';
|
export * from './Tag';
|
||||||
|
export * from './TelemetryPixel';
|
||||||
|
export * from './ThemeSubMenu';
|
||||||
export * from './UnsavedChangesModal';
|
export * from './UnsavedChangesModal';
|
||||||
export * from './constants';
|
export * from './constants';
|
||||||
export * from './Result';
|
export * from './Result';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logging } from '@superset-ui/core';
|
||||||
import fetchRetry from 'fetch-retry';
|
import fetchRetry from 'fetch-retry';
|
||||||
import { CallApi, Payload, JsonValue, JsonObject } from '../types';
|
import { CallApi, Payload, JsonValue, JsonObject } from '../types';
|
||||||
import {
|
import {
|
||||||
@@ -152,8 +153,7 @@ export default async function callApi({
|
|||||||
// while logging error to console for any attribute that fails the cast to String
|
// while logging error to console for any attribute that fails the cast to String
|
||||||
valueString = stringify ? JSON.stringify(value) : String(value);
|
valueString = stringify ? JSON.stringify(value) : String(value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
logging.error(
|
||||||
console.error(
|
|
||||||
`Unable to convert attribute '${key}' to a String(). '${key}' was not added to the formData in request.body for call to ${url}`,
|
`Unable to convert attribute '${key}' to a String(). '${key}' was not added to the formData in request.body for call to ${url}`,
|
||||||
value,
|
value,
|
||||||
e,
|
e,
|
||||||
|
|||||||
@@ -16,6 +16,8 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { logging } from '@superset-ui/core';
|
||||||
|
|
||||||
export enum OverwritePolicy {
|
export enum OverwritePolicy {
|
||||||
Allow = 'ALLOW',
|
Allow = 'ALLOW',
|
||||||
Prohibit = 'PROHIBIT',
|
Prohibit = 'PROHIBIT',
|
||||||
@@ -120,8 +122,7 @@ export default class Registry<
|
|||||||
(('value' in item && item.value !== value) || 'loader' in item);
|
(('value' in item && item.value !== value) || 'loader' in item);
|
||||||
if (willOverwrite) {
|
if (willOverwrite) {
|
||||||
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
||||||
// eslint-disable-next-line no-console
|
logging.warn(
|
||||||
console.warn(
|
|
||||||
`Item with key "${key}" already exists. You are assigning a new value.`,
|
`Item with key "${key}" already exists. You are assigning a new value.`,
|
||||||
);
|
);
|
||||||
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
||||||
@@ -146,8 +147,7 @@ export default class Registry<
|
|||||||
(('loader' in item && item.loader !== loader) || 'value' in item);
|
(('loader' in item && item.loader !== loader) || 'value' in item);
|
||||||
if (willOverwrite) {
|
if (willOverwrite) {
|
||||||
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
if (this.overwritePolicy === OverwritePolicy.Warn) {
|
||||||
// eslint-disable-next-line no-console
|
logging.warn(
|
||||||
console.warn(
|
|
||||||
`Item with key "${key}" already exists. You are assigning a new value.`,
|
`Item with key "${key}" already exists. You are assigning a new value.`,
|
||||||
);
|
);
|
||||||
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
} else if (this.overwritePolicy === OverwritePolicy.Prohibit) {
|
||||||
@@ -278,7 +278,7 @@ export default class Registry<
|
|||||||
listener(keys);
|
listener(keys);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.error('Exception thrown from a registry listener:', e);
|
logging.error('Exception thrown from a registry listener:', e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
COMMON_ERR_MESSAGES,
|
COMMON_ERR_MESSAGES,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
SupersetClientResponse,
|
SupersetClientResponse,
|
||||||
|
logging,
|
||||||
t,
|
t,
|
||||||
SupersetError,
|
SupersetError,
|
||||||
ErrorTypeEnum,
|
ErrorTypeEnum,
|
||||||
@@ -256,8 +257,7 @@ export function getClientErrorObject(
|
|||||||
// fall back to Response.statusText or generic error of we cannot read the response
|
// fall back to Response.statusText or generic error of we cannot read the response
|
||||||
let error = (response as any).statusText || (response as any).message;
|
let error = (response as any).statusText || (response as any).message;
|
||||||
if (!error) {
|
if (!error) {
|
||||||
// eslint-disable-next-line no-console
|
logging.error('non-standard error:', response);
|
||||||
console.error('non-standard error:', response);
|
|
||||||
error = t('An error occurred');
|
error = t('An error occurred');
|
||||||
}
|
}
|
||||||
resolve({
|
resolve({
|
||||||
|
|||||||
@@ -26,7 +26,9 @@ import {
|
|||||||
type ThemeStorage,
|
type ThemeStorage,
|
||||||
type ThemeControllerOptions,
|
type ThemeControllerOptions,
|
||||||
type ThemeContextType,
|
type ThemeContextType,
|
||||||
|
type SupersetThemeConfig,
|
||||||
ThemeAlgorithm,
|
ThemeAlgorithm,
|
||||||
|
ThemeMode,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -66,7 +68,16 @@ const themeObject: Theme = Theme.fromConfig({
|
|||||||
const { theme } = themeObject;
|
const { theme } = themeObject;
|
||||||
const supersetTheme = theme;
|
const supersetTheme = theme;
|
||||||
|
|
||||||
export { Theme, themeObject, styled, theme, supersetTheme };
|
export {
|
||||||
|
Theme,
|
||||||
|
ThemeAlgorithm,
|
||||||
|
ThemeMode,
|
||||||
|
themeObject,
|
||||||
|
styled,
|
||||||
|
theme,
|
||||||
|
supersetTheme,
|
||||||
|
};
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
SupersetTheme,
|
SupersetTheme,
|
||||||
SerializableThemeConfig,
|
SerializableThemeConfig,
|
||||||
@@ -74,6 +85,7 @@ export type {
|
|||||||
ThemeStorage,
|
ThemeStorage,
|
||||||
ThemeControllerOptions,
|
ThemeControllerOptions,
|
||||||
ThemeContextType,
|
ThemeContextType,
|
||||||
|
SupersetThemeConfig,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Export theme utility functions
|
// Export theme utility functions
|
||||||
|
|||||||
@@ -429,3 +429,16 @@ export interface ThemeContextType {
|
|||||||
canDetectOSPreference: () => boolean;
|
canDetectOSPreference: () => boolean;
|
||||||
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration object for complete theme setup including default, dark themes and settings
|
||||||
|
*/
|
||||||
|
export interface SupersetThemeConfig {
|
||||||
|
theme_default: AnyThemeConfig;
|
||||||
|
theme_dark?: AnyThemeConfig;
|
||||||
|
theme_settings?: {
|
||||||
|
enforced?: boolean;
|
||||||
|
allowSwitching?: boolean;
|
||||||
|
allowOSPreference?: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import UntypedJed from 'jed';
|
import UntypedJed from 'jed';
|
||||||
import logging from '../utils/logging';
|
import { logging } from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
Jed,
|
Jed,
|
||||||
TranslatorConfig,
|
TranslatorConfig,
|
||||||
|
|||||||
@@ -17,8 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* eslint no-console: 0 */
|
import { logging } from '@superset-ui/core';
|
||||||
|
|
||||||
import Translator from './Translator';
|
import Translator from './Translator';
|
||||||
import { TranslatorConfig, Translations, LocaleData } from './types';
|
import { TranslatorConfig, Translations, LocaleData } from './types';
|
||||||
|
|
||||||
@@ -34,7 +33,7 @@ function configure(config?: TranslatorConfig) {
|
|||||||
|
|
||||||
function getInstance() {
|
function getInstance() {
|
||||||
if (!isConfigured) {
|
if (!isConfigured) {
|
||||||
console.warn('You should call configure(...) before calling other methods');
|
logging.warn('You should call configure(...) before calling other methods');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof singleton === 'undefined') {
|
if (typeof singleton === 'undefined') {
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('ChartProps', () => {
|
|||||||
});
|
});
|
||||||
expect(props1).not.toBe(props2);
|
expect(props1).not.toBe(props2);
|
||||||
});
|
});
|
||||||
it('selector returns a new chartProps if some input fields change', () => {
|
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
|
||||||
const props1 = selector({
|
const props1 = selector({
|
||||||
width: 800,
|
width: 800,
|
||||||
height: 600,
|
height: 600,
|
||||||
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
|
|||||||
theme: supersetTheme,
|
theme: supersetTheme,
|
||||||
});
|
});
|
||||||
expect(props1).not.toBe(props2);
|
expect(props1).not.toBe(props2);
|
||||||
expect(props1).not.toBe(props3);
|
expect(props1).toBe(props3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -30,5 +30,8 @@
|
|||||||
"homepage": "https://github.com/apache/superset#readme",
|
"homepage": "https://github.com/apache/superset#readme",
|
||||||
"publishConfig": {
|
"publishConfig": {
|
||||||
"access": "public"
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@superset-ui/core": "^0.20.4"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logging } from '@superset-ui/core';
|
||||||
|
|
||||||
export type Params = {
|
export type Params = {
|
||||||
port: MessagePort;
|
port: MessagePort;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -262,12 +264,12 @@ export class Switchboard {
|
|||||||
|
|
||||||
private log(...args: unknown[]) {
|
private log(...args: unknown[]) {
|
||||||
if (this.debugMode) {
|
if (this.debugMode) {
|
||||||
console.debug(`[${this.name}]`, ...args);
|
logging.debug(`[${this.name}]`, ...args);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private logError(...args: unknown[]) {
|
private logError(...args: unknown[]) {
|
||||||
console.error(`[${this.name}]`, ...args);
|
logging.error(`[${this.name}]`, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
private getNewMessageId() {
|
private getNewMessageId() {
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"declarationDir": "lib",
|
"declarationDir": "lib",
|
||||||
"outDir": "lib",
|
"outDir": "lib"
|
||||||
"rootDir": "src"
|
|
||||||
},
|
},
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"lib",
|
"lib",
|
||||||
@@ -14,5 +13,7 @@
|
|||||||
"types/**/*",
|
"types/**/*",
|
||||||
"../../types/**/*"
|
"../../types/**/*"
|
||||||
],
|
],
|
||||||
"references": []
|
"references": [
|
||||||
|
{ "path": "../superset-ui-core" },
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1385,7 +1385,7 @@ export default function (config) {
|
|||||||
p[0] = p[0] - __.margin.left;
|
p[0] = p[0] - __.margin.left;
|
||||||
p[1] = p[1] - __.margin.top;
|
p[1] = p[1] - __.margin.top;
|
||||||
|
|
||||||
(dims = dimensionsForPoint(p)),
|
((dims = dimensionsForPoint(p)),
|
||||||
(strum = {
|
(strum = {
|
||||||
p1: p,
|
p1: p,
|
||||||
dims: dims,
|
dims: dims,
|
||||||
@@ -1393,7 +1393,7 @@ export default function (config) {
|
|||||||
maxX: xscale(dims.right),
|
maxX: xscale(dims.right),
|
||||||
minY: 0,
|
minY: 0,
|
||||||
maxY: h(),
|
maxY: h(),
|
||||||
});
|
}));
|
||||||
|
|
||||||
strums[dims.i] = strum;
|
strums[dims.i] = strum;
|
||||||
strums.active = dims.i;
|
strums.active = dims.i;
|
||||||
@@ -1942,7 +1942,7 @@ export default function (config) {
|
|||||||
p[0] = p[0] - __.margin.left;
|
p[0] = p[0] - __.margin.left;
|
||||||
p[1] = p[1] - __.margin.top;
|
p[1] = p[1] - __.margin.top;
|
||||||
|
|
||||||
(dims = dimensionsForPoint(p)),
|
((dims = dimensionsForPoint(p)),
|
||||||
(arc = {
|
(arc = {
|
||||||
p1: p,
|
p1: p,
|
||||||
dims: dims,
|
dims: dims,
|
||||||
@@ -1953,7 +1953,7 @@ export default function (config) {
|
|||||||
startAngle: undefined,
|
startAngle: undefined,
|
||||||
endAngle: undefined,
|
endAngle: undefined,
|
||||||
arc: d3.svg.arc().innerRadius(0),
|
arc: d3.svg.arc().innerRadius(0),
|
||||||
});
|
}));
|
||||||
|
|
||||||
arcs[dims.i] = arc;
|
arcs[dims.i] = arc;
|
||||||
arcs.active = dims.i;
|
arcs.active = dims.i;
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
isDefined,
|
isDefined,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
JsonValue,
|
JsonValue,
|
||||||
|
logging,
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
QueryObjectFilterClause,
|
QueryObjectFilterClause,
|
||||||
SupersetClient,
|
SupersetClient,
|
||||||
@@ -254,7 +255,7 @@ const DeckMulti = (props: DeckMultiProps) => {
|
|||||||
}));
|
}));
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
console.error(
|
logging.error(
|
||||||
`Error loading layer for slice ${subsliceCopy.slice_id}:`,
|
`Error loading layer for slice ${subsliceCopy.slice_id}:`,
|
||||||
error,
|
error,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,8 +27,8 @@
|
|||||||
"@react-icons/all-files": "^4.1.0",
|
"@react-icons/all-files": "^4.1.0",
|
||||||
"@types/d3-array": "^2.9.0",
|
"@types/d3-array": "^2.9.0",
|
||||||
"@types/react-table": "^7.7.20",
|
"@types/react-table": "^7.7.20",
|
||||||
"ag-grid-community": "^33.1.1",
|
"ag-grid-community": "^34.0.2",
|
||||||
"ag-grid-react": "^33.1.1",
|
"ag-grid-react": "^34.0.2",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"d3-array": "^2.4.0",
|
"d3-array": "^2.4.0",
|
||||||
"lodash": "^4.17.21",
|
"lodash": "^4.17.21",
|
||||||
|
|||||||
+34
-26
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
/* eslint-disable import/no-extraneous-dependencies */
|
/* eslint-disable import/no-extraneous-dependencies */
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Dropdown, Menu } from 'antd';
|
import { Dropdown } from 'antd';
|
||||||
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
||||||
import { t } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
||||||
@@ -69,34 +69,42 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
|
|||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
visible={showComparisonDropdown}
|
open={showComparisonDropdown}
|
||||||
onVisibleChange={(flag: boolean) => {
|
onOpenChange={(flag: boolean) => {
|
||||||
setShowComparisonDropdown(flag);
|
setShowComparisonDropdown(flag);
|
||||||
}}
|
}}
|
||||||
overlay={
|
menu={{
|
||||||
<Menu
|
multiple: true,
|
||||||
multiple
|
onClick: handleOnClick,
|
||||||
onClick={handleOnClick}
|
onBlur: handleOnBlur,
|
||||||
onBlur={handleOnBlur}
|
selectedKeys: selectedComparisonColumns,
|
||||||
selectedKeys={selectedComparisonColumns}
|
items: [
|
||||||
>
|
{
|
||||||
<InfoText>
|
key: 'all',
|
||||||
{t(
|
label: (
|
||||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
<InfoText>
|
||||||
)}
|
{t(
|
||||||
</InfoText>
|
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||||
{comparisonColumns.map((column: ComparisonColumn) => (
|
|
||||||
<Menu.Item key={column.key}>
|
|
||||||
<ColumnLabel>{column.label}</ColumnLabel>
|
|
||||||
<CheckIconWrapper>
|
|
||||||
{selectedComparisonColumns.includes(column.key) && (
|
|
||||||
<CheckOutlined />
|
|
||||||
)}
|
)}
|
||||||
</CheckIconWrapper>
|
</InfoText>
|
||||||
</Menu.Item>
|
),
|
||||||
))}
|
type: 'group',
|
||||||
</Menu>
|
children: comparisonColumns.map((column: ComparisonColumn) => ({
|
||||||
}
|
key: column.key,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<ColumnLabel>{column.label}</ColumnLabel>
|
||||||
|
<CheckIconWrapper>
|
||||||
|
{selectedComparisonColumns.includes(column.key) && (
|
||||||
|
<CheckOutlined />
|
||||||
|
)}
|
||||||
|
</CheckIconWrapper>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'show_cell_bars',
|
name: 'show_cell_bars',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('Show Cell bars'),
|
label: t('Show cell bars'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
default: true,
|
default: true,
|
||||||
description: t(
|
description: t(
|
||||||
@@ -617,7 +617,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'color_pn',
|
name: 'color_pn',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('add colors to cell bars for +/-'),
|
label: t('Add colors to cell bars for +/-'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
default: true,
|
default: true,
|
||||||
description: t(
|
description: t(
|
||||||
@@ -631,7 +631,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'comparison_color_enabled',
|
name: 'comparison_color_enabled',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('basic conditional formatting'),
|
label: t('Basic conditional formatting'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
!isEmpty(controls?.time_compare?.value),
|
!isEmpty(controls?.time_compare?.value),
|
||||||
@@ -672,7 +672,7 @@ const config: ControlPanelConfig = {
|
|||||||
config: {
|
config: {
|
||||||
type: 'ConditionalFormattingControl',
|
type: 'ConditionalFormattingControl',
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
label: t('Custom Conditional Formatting'),
|
label: t('Custom conditional formatting'),
|
||||||
extraColorChoices: [
|
extraColorChoices: [
|
||||||
{
|
{
|
||||||
value: ColorSchemeEnum.Green,
|
value: ColorSchemeEnum.Green,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import BaseEvent from 'ol/events/Event';
|
|||||||
import { unByKey } from 'ol/Observable';
|
import { unByKey } from 'ol/Observable';
|
||||||
import { toLonLat } from 'ol/proj';
|
import { toLonLat } from 'ol/proj';
|
||||||
import { debounce } from 'lodash';
|
import { debounce } from 'lodash';
|
||||||
|
import { logging } from '@superset-ui/core';
|
||||||
import { fitMapToCharts } from '../util/mapUtil';
|
import { fitMapToCharts } from '../util/mapUtil';
|
||||||
import { ChartLayer } from './ChartLayer';
|
import { ChartLayer } from './ChartLayer';
|
||||||
import { createLayer } from '../util/layerUtil';
|
import { createLayer } from '../util/layerUtil';
|
||||||
@@ -188,7 +189,7 @@ export const OlChartMap = (props: OlChartMapProps) => {
|
|||||||
if (createdLayer.status === 'fulfilled' && createdLayer.value) {
|
if (createdLayer.status === 'fulfilled' && createdLayer.value) {
|
||||||
olMap.getLayers().insertAt(0, createdLayer.value);
|
olMap.getLayers().insertAt(0, createdLayer.value);
|
||||||
} else {
|
} else {
|
||||||
console.warn(`Layer could not be created: ${configs[idx]}`);
|
logging.warn(`Layer could not be created: ${configs[idx]}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
* Util for layer related operations.
|
* Util for layer related operations.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { logging } from '@superset-ui/core';
|
||||||
import OlParser from 'geostyler-openlayers-parser';
|
import OlParser from 'geostyler-openlayers-parser';
|
||||||
import TileLayer from 'ol/layer/Tile';
|
import TileLayer from 'ol/layer/Tile';
|
||||||
import TileWMS from 'ol/source/TileWMS';
|
import TileWMS from 'ol/source/TileWMS';
|
||||||
@@ -126,7 +127,7 @@ export const createWfsLayer = async (wfsLayerConf: WfsLayerConf) => {
|
|||||||
const olParser = new OlParser();
|
const olParser = new OlParser();
|
||||||
writeStyleResult = await olParser.writeStyle(style);
|
writeStyleResult = await olParser.writeStyle(style);
|
||||||
if (writeStyleResult.errors) {
|
if (writeStyleResult.errors) {
|
||||||
console.warn('Could not create ol-style', writeStyleResult.errors);
|
logging.warn('Could not create ol-style', writeStyleResult.errors);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -154,7 +155,7 @@ export const createLayer = async (layerConf: LayerConf) => {
|
|||||||
} else if (isXyzLayerConf(layerConf)) {
|
} else if (isXyzLayerConf(layerConf)) {
|
||||||
layer = createXyzLayer(layerConf);
|
layer = createXyzLayer(layerConf);
|
||||||
} else {
|
} else {
|
||||||
console.warn('Provided layerconfig is not recognized');
|
logging.warn('Provided layerconfig is not recognized');
|
||||||
}
|
}
|
||||||
return layer;
|
return layer;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
ChartProps,
|
ChartProps,
|
||||||
convertKeysToCamelCase,
|
convertKeysToCamelCase,
|
||||||
DataRecord,
|
DataRecord,
|
||||||
|
logging,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { isObject } from 'lodash';
|
import { isObject } from 'lodash';
|
||||||
import {
|
import {
|
||||||
@@ -89,7 +90,7 @@ export const groupByLocationGenericX = (
|
|||||||
const labelMap: string[] = queryData.label_map?.[k];
|
const labelMap: string[] = queryData.label_map?.[k];
|
||||||
|
|
||||||
if (!labelMap) {
|
if (!labelMap) {
|
||||||
console.log(
|
logging.debug(
|
||||||
'Cannot extract location from queryData. label_map not defined',
|
'Cannot extract location from queryData. label_map not defined',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
@@ -99,7 +100,7 @@ export const groupByLocationGenericX = (
|
|||||||
|
|
||||||
if (geojsonCols.length > 1) {
|
if (geojsonCols.length > 1) {
|
||||||
// TODO what should we do, if there is more than one geom column?
|
// TODO what should we do, if there is more than one geom column?
|
||||||
console.log(
|
logging.debug(
|
||||||
'More than one geometry column detected. Using first found.',
|
'More than one geometry column detected. Using first found.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+30
-15
@@ -49,38 +49,53 @@ describe('BigNumberWithTrendline buildQuery', () => {
|
|||||||
aggregation: null,
|
aggregation: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('creates raw metric query when aggregation is null', () => {
|
it('creates raw metric query when aggregation is "raw"', () => {
|
||||||
const queryContext = buildQuery({ ...baseFormData });
|
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
|
||||||
const bigNumberQuery = queryContext.queries[1];
|
const bigNumberQuery = queryContext.queries[1];
|
||||||
|
|
||||||
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
|
expect(bigNumberQuery.post_processing).toEqual([]);
|
||||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
expect(bigNumberQuery.is_timeseries).toBe(false);
|
||||||
|
expect(bigNumberQuery.columns).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('adds aggregation operator when aggregation is "sum"', () => {
|
it('returns single query for aggregation methods that can be computed client-side', () => {
|
||||||
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
|
const queryContext = buildQuery({ ...baseFormData, aggregation: 'sum' });
|
||||||
const bigNumberQuery = queryContext.queries[1];
|
|
||||||
|
|
||||||
expect(bigNumberQuery.post_processing).toEqual([
|
expect(queryContext.queries.length).toBe(1);
|
||||||
|
expect(queryContext.queries[0].post_processing).toEqual([
|
||||||
{ operation: 'pivot' },
|
{ operation: 'pivot' },
|
||||||
{ operation: 'aggregation', options: { operator: 'sum' } },
|
{ operation: 'rolling' },
|
||||||
|
{ operation: 'resample' },
|
||||||
|
{ operation: 'flatten' },
|
||||||
]);
|
]);
|
||||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips aggregation when aggregation is LAST_VALUE', () => {
|
it('returns single query for LAST_VALUE aggregation', () => {
|
||||||
const queryContext = buildQuery({
|
const queryContext = buildQuery({
|
||||||
...baseFormData,
|
...baseFormData,
|
||||||
aggregation: 'LAST_VALUE',
|
aggregation: 'LAST_VALUE',
|
||||||
});
|
});
|
||||||
const bigNumberQuery = queryContext.queries[1];
|
|
||||||
|
|
||||||
expect(bigNumberQuery.post_processing).toEqual([{ operation: 'pivot' }]);
|
expect(queryContext.queries.length).toBe(1);
|
||||||
expect(bigNumberQuery.is_timeseries).toBe(true);
|
expect(queryContext.queries[0].post_processing).toEqual([
|
||||||
|
{ operation: 'pivot' },
|
||||||
|
{ operation: 'rolling' },
|
||||||
|
{ operation: 'resample' },
|
||||||
|
{ operation: 'flatten' },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('always returns two queries', () => {
|
it('returns two queries only for raw aggregation', () => {
|
||||||
const queryContext = buildQuery({ ...baseFormData });
|
const queryContext = buildQuery({ ...baseFormData, aggregation: 'raw' });
|
||||||
expect(queryContext.queries.length).toBe(2);
|
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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+33
-24
@@ -39,28 +39,37 @@ export default function buildQuery(formData: QueryFormData) {
|
|||||||
? ensureIsArray(getXAxisColumn(formData))
|
? ensureIsArray(getXAxisColumn(formData))
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return buildQueryContext(formData, baseQueryObject => [
|
return buildQueryContext(formData, baseQueryObject => {
|
||||||
{
|
const queries = [
|
||||||
...baseQueryObject,
|
{
|
||||||
columns: [...timeColumn],
|
...baseQueryObject,
|
||||||
...(timeColumn.length ? {} : { is_timeseries: true }),
|
columns: [...timeColumn],
|
||||||
post_processing: [
|
...(timeColumn.length ? {} : { is_timeseries: true }),
|
||||||
pivotOperator(formData, baseQueryObject),
|
post_processing: [
|
||||||
rollingWindowOperator(formData, baseQueryObject),
|
pivotOperator(formData, baseQueryObject),
|
||||||
resampleOperator(formData, baseQueryObject),
|
rollingWindowOperator(formData, baseQueryObject),
|
||||||
flattenOperator(formData, baseQueryObject),
|
resampleOperator(formData, baseQueryObject),
|
||||||
],
|
flattenOperator(formData, baseQueryObject),
|
||||||
},
|
].filter(Boolean),
|
||||||
{
|
},
|
||||||
...baseQueryObject,
|
];
|
||||||
columns: [...(isRawMetric ? [] : timeColumn)],
|
|
||||||
is_timeseries: !isRawMetric,
|
// Only add second query for raw metrics which need different query structure
|
||||||
post_processing: isRawMetric
|
// All other aggregations (sum, mean, min, max, median, LAST_VALUE) can be computed client-side from trendline data
|
||||||
? []
|
if (formData.aggregation === 'raw') {
|
||||||
: [
|
queries.push({
|
||||||
pivotOperator(formData, baseQueryObject),
|
...baseQueryObject,
|
||||||
aggregationOperator(formData, baseQueryObject),
|
columns: [...(isRawMetric ? [] : timeColumn)],
|
||||||
],
|
is_timeseries: !isRawMetric,
|
||||||
},
|
post_processing: isRawMetric
|
||||||
]);
|
? []
|
||||||
|
: ([
|
||||||
|
pivotOperator(formData, baseQueryObject),
|
||||||
|
aggregationOperator(formData, baseQueryObject),
|
||||||
|
].filter(Boolean) as any[]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return queries;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-1
@@ -20,6 +20,41 @@ import { GenericDataType } from '@superset-ui/core';
|
|||||||
import transformProps from './transformProps';
|
import transformProps from './transformProps';
|
||||||
import { BigNumberWithTrendlineChartProps, BigNumberDatum } from '../types';
|
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', () => ({
|
jest.mock('@superset-ui/core', () => ({
|
||||||
GenericDataType: { Temporal: 2, String: 1 },
|
GenericDataType: { Temporal: 2, String: 1 },
|
||||||
extractTimegrain: jest.fn(() => 'P1D'),
|
extractTimegrain: jest.fn(() => 'P1D'),
|
||||||
@@ -218,7 +253,7 @@ describe('BigNumberWithTrendline transformProps', () => {
|
|||||||
coltypes: ['NUMERIC'],
|
coltypes: ['NUMERIC'],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
formData: { ...baseFormData, aggregation: 'SUM' },
|
formData: { ...baseFormData, aggregation: 'sum' },
|
||||||
rawFormData: baseRawFormData,
|
rawFormData: baseRawFormData,
|
||||||
hooks: baseHooks,
|
hooks: baseHooks,
|
||||||
datasource: baseDatasource,
|
datasource: baseDatasource,
|
||||||
|
|||||||
+51
-19
@@ -29,6 +29,7 @@ import {
|
|||||||
tooltipHtml,
|
tooltipHtml,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { EChartsCoreOption, graphic } from 'echarts/core';
|
import { EChartsCoreOption, graphic } from 'echarts/core';
|
||||||
|
import { aggregationChoices } from '@superset-ui/chart-controls';
|
||||||
import {
|
import {
|
||||||
BigNumberVizProps,
|
BigNumberVizProps,
|
||||||
BigNumberDatum,
|
BigNumberDatum,
|
||||||
@@ -43,6 +44,31 @@ const formatPercentChange = getNumberFormatter(
|
|||||||
NumberFormats.PERCENT_SIGNED_1_POINT,
|
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(
|
export default function transformProps(
|
||||||
chartProps: BigNumberWithTrendlineChartProps,
|
chartProps: BigNumberWithTrendlineChartProps,
|
||||||
): BigNumberVizProps {
|
): BigNumberVizProps {
|
||||||
@@ -126,27 +152,33 @@ export default function transformProps(
|
|||||||
// sort in time descending order
|
// sort in time descending order
|
||||||
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
|
.sort((a, b) => (a[0] !== null && b[0] !== null ? b[0] - a[0] : 0));
|
||||||
}
|
}
|
||||||
if (hasAggregatedData && aggregatedData) {
|
if (sortedData.length > 0) {
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
timestamp = sortedData.length > 0 ? sortedData[0][0] : null;
|
|
||||||
} else if (sortedData.length > 0) {
|
|
||||||
bigNumber = sortedData[0][1];
|
|
||||||
timestamp = sortedData[0][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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Use client-side aggregation for all other methods
|
||||||
|
bigNumber = computeClientSideAggregation(sortedData, aggregation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle null bigNumber case
|
||||||
if (bigNumber === null) {
|
if (bigNumber === null) {
|
||||||
bigNumberFallback = sortedData.find(d => d[1] !== null);
|
bigNumberFallback = sortedData.find(d => d[1] !== null);
|
||||||
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;
|
bigNumber = bigNumberFallback ? bigNumberFallback[1] : null;
|
||||||
|
|||||||
+16
-1
@@ -358,7 +358,22 @@ const config: ControlPanelConfig = {
|
|||||||
['x_axis_time_format'],
|
['x_axis_time_format'],
|
||||||
[xAxisLabelRotation],
|
[xAxisLabelRotation],
|
||||||
[xAxisLabelInterval],
|
[xAxisLabelInterval],
|
||||||
...richTooltipSection,
|
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
name: 'show_query_identifiers',
|
||||||
|
config: {
|
||||||
|
type: 'CheckboxControl',
|
||||||
|
label: t('Show query identifiers'),
|
||||||
|
description: t(
|
||||||
|
'Add Query A and Query B identifiers to tooltips to help differentiate series',
|
||||||
|
),
|
||||||
|
default: false,
|
||||||
|
renderTrigger: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
|
||||||
// eslint-disable-next-line react/jsx-key
|
// eslint-disable-next-line react/jsx-key
|
||||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||||
[
|
[
|
||||||
|
|||||||
+21
-7
@@ -212,6 +212,7 @@ export default function transformProps(
|
|||||||
sortSeriesAscendingB,
|
sortSeriesAscendingB,
|
||||||
timeGrainSqla,
|
timeGrainSqla,
|
||||||
percentageThreshold,
|
percentageThreshold,
|
||||||
|
showQueryIdentifiers = false,
|
||||||
metrics = [],
|
metrics = [],
|
||||||
metricsB = [],
|
metricsB = [],
|
||||||
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||||
@@ -395,10 +396,17 @@ export default function transformProps(
|
|||||||
const seriesName = inverted[entryName] || entryName;
|
const seriesName = inverted[entryName] || entryName;
|
||||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||||
|
|
||||||
let displayName = `${entryName} (Query A)`;
|
let displayName: string;
|
||||||
|
|
||||||
if (groupby.length > 0) {
|
if (groupby.length > 0) {
|
||||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
// When we have groupby, format as "metric, dimension"
|
||||||
|
const metricPart = showQueryIdentifiers
|
||||||
|
? `${MetricDisplayNameA} (Query A)`
|
||||||
|
: MetricDisplayNameA;
|
||||||
|
displayName = `${metricPart}, ${entryName}`;
|
||||||
|
} else {
|
||||||
|
// When no groupby, format as just the entry name with optional query identifier
|
||||||
|
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesFormatter = getFormatter(
|
const seriesFormatter = getFormatter(
|
||||||
@@ -453,10 +461,17 @@ export default function transformProps(
|
|||||||
const seriesName = `${seriesEntry} (1)`;
|
const seriesName = `${seriesEntry} (1)`;
|
||||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||||
|
|
||||||
let displayName = `${entryName} (Query B)`;
|
let displayName: string;
|
||||||
|
|
||||||
if (groupbyB.length > 0) {
|
if (groupbyB.length > 0) {
|
||||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
// When we have groupby, format as "metric, dimension"
|
||||||
|
const metricPart = showQueryIdentifiers
|
||||||
|
? `${MetricDisplayNameB} (Query B)`
|
||||||
|
: MetricDisplayNameB;
|
||||||
|
displayName = `${metricPart}, ${entryName}`;
|
||||||
|
} else {
|
||||||
|
// When no groupby, format as just the entry name with optional query identifier
|
||||||
|
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||||
}
|
}
|
||||||
|
|
||||||
const seriesFormatter = getFormatter(
|
const seriesFormatter = getFormatter(
|
||||||
@@ -696,14 +711,13 @@ export default function transformProps(
|
|||||||
zoomable,
|
zoomable,
|
||||||
),
|
),
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
data: rawSeriesA
|
data: series
|
||||||
.concat(rawSeriesB)
|
|
||||||
.filter(
|
.filter(
|
||||||
entry =>
|
entry =>
|
||||||
extractForecastSeriesContext((entry.name || '') as string).type ===
|
extractForecastSeriesContext((entry.name || '') as string).type ===
|
||||||
ForecastSeriesEnum.Observation,
|
ForecastSeriesEnum.Observation,
|
||||||
)
|
)
|
||||||
.map(entry => entry.name || '')
|
.map(entry => entry.id || entry.name || '')
|
||||||
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
||||||
},
|
},
|
||||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
|||||||
tooltipTimeFormat?: string;
|
tooltipTimeFormat?: string;
|
||||||
zoomable: boolean;
|
zoomable: boolean;
|
||||||
richTooltip: boolean;
|
richTooltip: boolean;
|
||||||
|
showQueryIdentifiers?: boolean;
|
||||||
xAxisLabelRotation: number;
|
xAxisLabelRotation: number;
|
||||||
xAxisLabelInterval?: number | string;
|
xAxisLabelInterval?: number | string;
|
||||||
colorScheme?: string;
|
colorScheme?: string;
|
||||||
@@ -133,6 +134,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
|||||||
groupbyB: [],
|
groupbyB: [],
|
||||||
zoomable: TIMESERIES_DEFAULTS.zoomable,
|
zoomable: TIMESERIES_DEFAULTS.zoomable,
|
||||||
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
|
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
|
||||||
|
showQueryIdentifiers: false,
|
||||||
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
|
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
|
||||||
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
|
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
|
||||||
...DEFAULT_TITLE_FORM_DATA,
|
...DEFAULT_TITLE_FORM_DATA,
|
||||||
|
|||||||
@@ -95,27 +95,27 @@ function getTotalValuePadding({
|
|||||||
top: donut ? 'middle' : '0',
|
top: donut ? 'middle' : '0',
|
||||||
left: 'center',
|
left: 'center',
|
||||||
};
|
};
|
||||||
const LEGEND_HEIGHT = 15;
|
|
||||||
const LEGEND_WIDTH = 215;
|
|
||||||
if (chartPadding.top) {
|
if (chartPadding.top) {
|
||||||
padding.top = donut
|
padding.top = donut
|
||||||
? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%`
|
? `${50 + (chartPadding.top / height / 2) * 100}%`
|
||||||
: `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`;
|
: `${(chartPadding.top / height) * 100}%`;
|
||||||
}
|
}
|
||||||
if (chartPadding.bottom) {
|
if (chartPadding.bottom) {
|
||||||
padding.top = donut
|
padding.top = donut
|
||||||
? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%`
|
? `${50 - (chartPadding.bottom / height / 2) * 100}%`
|
||||||
: '0';
|
: '0';
|
||||||
}
|
}
|
||||||
if (chartPadding.left) {
|
if (chartPadding.left) {
|
||||||
padding.left = `${
|
// When legend is on the left, shift text right to center it in the available space
|
||||||
50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100
|
const leftPaddingPercent = (chartPadding.left / width) * 100;
|
||||||
}%`;
|
const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25;
|
||||||
|
padding.left = `${adjustedLeftPercent}%`;
|
||||||
}
|
}
|
||||||
if (chartPadding.right) {
|
if (chartPadding.right) {
|
||||||
padding.left = `${
|
// When legend is on the right, shift text left to center it in the available space
|
||||||
50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100
|
const rightPaddingPercent = (chartPadding.right / width) * 100;
|
||||||
}%`;
|
const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75;
|
||||||
|
padding.left = `${adjustedLeftPercent}%`;
|
||||||
}
|
}
|
||||||
return padding;
|
return padding;
|
||||||
}
|
}
|
||||||
@@ -220,7 +220,7 @@ export default function transformProps(
|
|||||||
name: otherName,
|
name: otherName,
|
||||||
value: otherSum,
|
value: otherSum,
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
color: theme.colors.grayscale.dark1,
|
color: theme.colorText,
|
||||||
opacity:
|
opacity:
|
||||||
filterState.selectedValues &&
|
filterState.selectedValues &&
|
||||||
!filterState.selectedValues.includes(otherName)
|
!filterState.selectedValues.includes(otherName)
|
||||||
@@ -368,7 +368,7 @@ export default function transformProps(
|
|||||||
const defaultLabel = {
|
const defaultLabel = {
|
||||||
formatter,
|
formatter,
|
||||||
show: showLabels,
|
show: showLabels,
|
||||||
color: theme.colors.grayscale.dark2,
|
color: theme.colorText,
|
||||||
};
|
};
|
||||||
|
|
||||||
const chartPadding = getChartPadding(
|
const chartPadding = getChartPadding(
|
||||||
@@ -403,7 +403,7 @@ export default function transformProps(
|
|||||||
label: {
|
label: {
|
||||||
show: true,
|
show: true,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
backgroundColor: theme.colors.grayscale.light5,
|
backgroundColor: theme.colorBgContainer,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
data: transformedData,
|
data: transformedData,
|
||||||
@@ -445,6 +445,7 @@ export default function transformProps(
|
|||||||
text: t('Total: %s', numberFormatter(totalValue)),
|
text: t('Total: %s', numberFormatter(totalValue)),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: 'bold',
|
fontWeight: 'bold',
|
||||||
|
fill: theme.colorText,
|
||||||
},
|
},
|
||||||
z: 10,
|
z: 10,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
|||||||
width,
|
width,
|
||||||
echartOptions,
|
echartOptions,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
labelMap,
|
|
||||||
selectedValues,
|
selectedValues,
|
||||||
formData,
|
formData,
|
||||||
onContextMenu,
|
onContextMenu,
|
||||||
@@ -52,45 +51,47 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
|||||||
const getCrossFilterDataMask = useCallback(
|
const getCrossFilterDataMask = useCallback(
|
||||||
(treePathInfo: TreePathInfo[]) => {
|
(treePathInfo: TreePathInfo[]) => {
|
||||||
const treePath = extractTreePathInfo(treePathInfo);
|
const treePath = extractTreePathInfo(treePathInfo);
|
||||||
const name = treePath.join(',');
|
const joinedTreePath = treePath.join(',');
|
||||||
const selected = Object.values(selectedValues);
|
const value = treePath[treePath.length - 1];
|
||||||
let values: string[];
|
|
||||||
if (selected.includes(name)) {
|
const isCurrentValueSelected =
|
||||||
values = selected.filter(v => v !== name);
|
Object.values(selectedValues).includes(joinedTreePath);
|
||||||
} else {
|
|
||||||
values = [name];
|
if (!columns?.length || isCurrentValueSelected) {
|
||||||
|
return {
|
||||||
|
dataMask: {
|
||||||
|
extraFormData: {
|
||||||
|
filters: [],
|
||||||
|
},
|
||||||
|
filterState: {
|
||||||
|
value: null,
|
||||||
|
selectedValues: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
isCurrentValueSelected,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const labels = values.map(value => labelMap[value]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
dataMask: {
|
dataMask: {
|
||||||
extraFormData: {
|
extraFormData: {
|
||||||
filters:
|
filters: [
|
||||||
values.length === 0 || !columns
|
{
|
||||||
? []
|
col: columns[treePath.length - 1],
|
||||||
: columns.slice(0, treePath.length).map((col, idx) => {
|
op: '==' as const,
|
||||||
const val = labels.map(v => v[idx]);
|
val: value,
|
||||||
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: {
|
filterState: {
|
||||||
value: labels.length ? labels : null,
|
value,
|
||||||
selectedValues: values.length ? values : null,
|
selectedValues: [joinedTreePath],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
isCurrentValueSelected: selected.includes(name),
|
isCurrentValueSelected,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
[columns, labelMap, selectedValues],
|
[columns, selectedValues],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
@@ -101,7 +102,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
|||||||
|
|
||||||
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
||||||
},
|
},
|
||||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
|
||||||
);
|
);
|
||||||
|
|
||||||
const eventHandlers: EventHandlers = {
|
const eventHandlers: EventHandlers = {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
|||||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||||
stack: false,
|
stack: false,
|
||||||
tooltipTimeFormat: 'smart_date',
|
tooltipTimeFormat: 'smart_date',
|
||||||
|
xAxisTimeFormat: 'smart_date',
|
||||||
truncateXAxis: true,
|
truncateXAxis: true,
|
||||||
truncateYAxis: false,
|
truncateYAxis: false,
|
||||||
yAxisBounds: [null, null],
|
yAxisBounds: [null, null],
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { merge } from 'lodash';
|
|||||||
|
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
|
||||||
import { styled, useTheme } from '@superset-ui/core';
|
import { styled, useTheme, logging } from '@superset-ui/core';
|
||||||
import { use, init, EChartsType, registerLocale } from 'echarts/core';
|
import { use, init, EChartsType, registerLocale } from 'echarts/core';
|
||||||
import {
|
import {
|
||||||
SankeyChart,
|
SankeyChart,
|
||||||
@@ -117,7 +117,7 @@ const loadLocale = async (locale: string) => {
|
|||||||
try {
|
try {
|
||||||
lang = await import(`echarts/lib/i18n/lang${locale}`);
|
lang = await import(`echarts/lib/i18n/lang${locale}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`Locale ${locale} not supported in ECharts`, e);
|
logging.error(`Locale ${locale} not supported in ECharts`, e);
|
||||||
}
|
}
|
||||||
return lang?.default;
|
return lang?.default;
|
||||||
};
|
};
|
||||||
|
|||||||
+3
-2
@@ -128,9 +128,10 @@ describe('BigNumberWithTrendline', () => {
|
|||||||
expect(lastDatum?.[0]).toStrictEqual(100);
|
expect(lastDatum?.[0]).toStrictEqual(100);
|
||||||
expect(lastDatum?.[1]).toBeNull();
|
expect(lastDatum?.[1]).toBeNull();
|
||||||
|
|
||||||
// should note this is a fallback
|
// should get the last non-null value
|
||||||
expect(transformed.bigNumber).toStrictEqual(1.2345);
|
expect(transformed.bigNumber).toStrictEqual(1.2345);
|
||||||
expect(transformed.bigNumberFallback).not.toBeNull();
|
// bigNumberFallback is only set when bigNumber is null after aggregation
|
||||||
|
expect(transformed.bigNumberFallback).toBeNull();
|
||||||
|
|
||||||
// should successfully formatTime by granularity
|
// should successfully formatTime by granularity
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
|
|||||||
+41
-42
@@ -116,49 +116,48 @@ const chartPropsConfig = {
|
|||||||
theme: supersetTheme,
|
theme: supersetTheme,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should transform chart props for viz', () => {
|
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||||
const chartProps = new ChartProps(chartPropsConfig);
|
const chartPropsConfigWithoutIdentifiers = {
|
||||||
|
...chartPropsConfig,
|
||||||
|
formData: {
|
||||||
|
...formData,
|
||||||
|
showQueryIdentifiers: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||||
|
|
||||||
expect(transformed).toEqual(
|
// Check that series IDs don't include query identifiers
|
||||||
expect.objectContaining({
|
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||||
echartOptions: expect.objectContaining({
|
(s: any) => s.id,
|
||||||
series: expect.arrayContaining([
|
|
||||||
expect.objectContaining({
|
|
||||||
data: [
|
|
||||||
[599616000000, 1],
|
|
||||||
[599916000000, 3],
|
|
||||||
],
|
|
||||||
id: 'sum__num (Query A), boy',
|
|
||||||
stack: 'obs\na',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
data: [
|
|
||||||
[599616000000, 2],
|
|
||||||
[599916000000, 4],
|
|
||||||
],
|
|
||||||
id: 'sum__num (Query A), girl',
|
|
||||||
stack: 'obs\na',
|
|
||||||
}),
|
|
||||||
// Query B — Bar series
|
|
||||||
expect.objectContaining({
|
|
||||||
data: [
|
|
||||||
[599616000000, 1],
|
|
||||||
[599916000000, 3],
|
|
||||||
],
|
|
||||||
id: 'sum__num (Query B), boy',
|
|
||||||
stack: 'obs\nb',
|
|
||||||
}),
|
|
||||||
expect.objectContaining({
|
|
||||||
data: [
|
|
||||||
[599616000000, 2],
|
|
||||||
[599916000000, 4],
|
|
||||||
],
|
|
||||||
id: 'sum__num (Query B), girl',
|
|
||||||
stack: 'obs\nb',
|
|
||||||
}),
|
|
||||||
]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
expect(seriesIds).toContain('sum__num, girl');
|
||||||
|
expect(seriesIds).toContain('sum__num, boy');
|
||||||
|
expect(seriesIds).not.toContain('sum__num (Query A), girl');
|
||||||
|
expect(seriesIds).not.toContain('sum__num (Query A), boy');
|
||||||
|
expect(seriesIds).not.toContain('sum__num (Query B), girl');
|
||||||
|
expect(seriesIds).not.toContain('sum__num (Query B), boy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||||
|
const chartPropsConfigWithIdentifiers = {
|
||||||
|
...chartPropsConfig,
|
||||||
|
formData: {
|
||||||
|
...formData,
|
||||||
|
showQueryIdentifiers: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
|
||||||
|
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||||
|
|
||||||
|
// Check that series IDs include query identifiers
|
||||||
|
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||||
|
(s: any) => s.id,
|
||||||
|
);
|
||||||
|
expect(seriesIds).toContain('sum__num (Query A), girl');
|
||||||
|
expect(seriesIds).toContain('sum__num (Query A), boy');
|
||||||
|
expect(seriesIds).toContain('sum__num (Query B), girl');
|
||||||
|
expect(seriesIds).toContain('sum__num (Query B), boy');
|
||||||
|
expect(seriesIds).not.toContain('sum__num, girl');
|
||||||
|
expect(seriesIds).not.toContain('sum__num, boy');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -221,6 +221,157 @@ describe('Pie label string template', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Total value positioning with legends', () => {
|
||||||
|
const getChartPropsWithLegend = (
|
||||||
|
showTotal = true,
|
||||||
|
showLegend = true,
|
||||||
|
legendOrientation = 'right',
|
||||||
|
donut = true,
|
||||||
|
): EchartsPieChartProps => {
|
||||||
|
const formData: SqlaFormData = {
|
||||||
|
colorScheme: 'bnbColors',
|
||||||
|
datasource: '3__table',
|
||||||
|
granularity_sqla: 'ds',
|
||||||
|
metric: 'sum__num',
|
||||||
|
groupby: ['category'],
|
||||||
|
viz_type: 'pie',
|
||||||
|
show_total: showTotal,
|
||||||
|
show_legend: showLegend,
|
||||||
|
legend_orientation: legendOrientation,
|
||||||
|
donut,
|
||||||
|
};
|
||||||
|
|
||||||
|
return new ChartProps({
|
||||||
|
formData,
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
queriesData: [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{ category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
|
||||||
|
{ category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
theme: supersetTheme,
|
||||||
|
}) as EchartsPieChartProps;
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should center total text when legend is on the right', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||||
|
top: 'middle',
|
||||||
|
style: expect.objectContaining({
|
||||||
|
text: expect.stringContaining('Total:'),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The left position should be less than 50% (shifted left)
|
||||||
|
const leftValue = parseFloat(
|
||||||
|
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||||
|
);
|
||||||
|
expect(leftValue).toBeLessThan(50);
|
||||||
|
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should center total text when legend is on the left', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||||
|
top: 'middle',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The left position should be greater than 50% (shifted right)
|
||||||
|
const leftValue = parseFloat(
|
||||||
|
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||||
|
);
|
||||||
|
expect(leftValue).toBeGreaterThan(50);
|
||||||
|
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should center total text when legend is on top', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
left: 'center',
|
||||||
|
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The top position should be adjusted for top legend
|
||||||
|
const topValue = parseFloat(
|
||||||
|
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||||
|
);
|
||||||
|
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should center total text when legend is on bottom', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
left: 'center',
|
||||||
|
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The top position should be adjusted for bottom legend
|
||||||
|
const topValue = parseFloat(
|
||||||
|
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||||
|
);
|
||||||
|
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use default positioning when no legend is shown', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
left: 'center',
|
||||||
|
top: 'middle',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle regular pie chart (non-donut) positioning', () => {
|
||||||
|
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: 'text',
|
||||||
|
top: '0', // Non-donut charts use '0' as default top position
|
||||||
|
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show total graphic when showTotal is false', () => {
|
||||||
|
const props = getChartPropsWithLegend(false, true, 'right', true);
|
||||||
|
const transformed = transformProps(props);
|
||||||
|
|
||||||
|
expect(transformed.echartOptions.graphic).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('Other category', () => {
|
describe('Other category', () => {
|
||||||
const defaultFormData: SqlaFormData = {
|
const defaultFormData: SqlaFormData = {
|
||||||
colorScheme: 'bnbColors',
|
colorScheme: 'bnbColors',
|
||||||
|
|||||||
+204
@@ -0,0 +1,204 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel';
|
||||||
|
|
||||||
|
describe('Bar Chart Control Panel', () => {
|
||||||
|
describe('x_axis_time_format control', () => {
|
||||||
|
it('should include x_axis_time_format control in the panel', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
// Look for x_axis_time_format control in all sections and rows
|
||||||
|
let foundTimeFormatControl = false;
|
||||||
|
|
||||||
|
for (const section of config.controlPanelSections) {
|
||||||
|
if (section && section.controlSetRows) {
|
||||||
|
for (const row of section.controlSetRows) {
|
||||||
|
for (const control of row) {
|
||||||
|
if (
|
||||||
|
typeof control === 'object' &&
|
||||||
|
control !== null &&
|
||||||
|
'name' in control &&
|
||||||
|
control.name === 'x_axis_time_format'
|
||||||
|
) {
|
||||||
|
foundTimeFormatControl = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (foundTimeFormatControl) break;
|
||||||
|
}
|
||||||
|
if (foundTimeFormatControl) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(foundTimeFormatControl).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have correct default value for x_axis_time_format', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
// Find the x_axis_time_format control
|
||||||
|
let timeFormatControl: any = null;
|
||||||
|
|
||||||
|
for (const section of config.controlPanelSections) {
|
||||||
|
if (section && section.controlSetRows) {
|
||||||
|
for (const row of section.controlSetRows) {
|
||||||
|
for (const control of row) {
|
||||||
|
if (
|
||||||
|
typeof control === 'object' &&
|
||||||
|
control !== null &&
|
||||||
|
'name' in control &&
|
||||||
|
control.name === 'x_axis_time_format'
|
||||||
|
) {
|
||||||
|
timeFormatControl = control;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(timeFormatControl).toBeDefined();
|
||||||
|
expect(timeFormatControl.config).toBeDefined();
|
||||||
|
expect(timeFormatControl.config.default).toBe('smart_date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have visibility function for x_axis_time_format', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
// Find the x_axis_time_format control
|
||||||
|
let timeFormatControl: any = null;
|
||||||
|
|
||||||
|
for (const section of config.controlPanelSections) {
|
||||||
|
if (section && section.controlSetRows) {
|
||||||
|
for (const row of section.controlSetRows) {
|
||||||
|
for (const control of row) {
|
||||||
|
if (
|
||||||
|
typeof control === 'object' &&
|
||||||
|
control !== null &&
|
||||||
|
'name' in control &&
|
||||||
|
control.name === 'x_axis_time_format'
|
||||||
|
) {
|
||||||
|
timeFormatControl = control;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(timeFormatControl).toBeDefined();
|
||||||
|
expect(timeFormatControl.config.visibility).toBeDefined();
|
||||||
|
expect(typeof timeFormatControl.config.visibility).toBe('function');
|
||||||
|
|
||||||
|
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||||
|
// The important part is that the control has proper visibility configuration
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper control configuration', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
// Find the x_axis_time_format control
|
||||||
|
let timeFormatControl: any = null;
|
||||||
|
|
||||||
|
for (const section of config.controlPanelSections) {
|
||||||
|
if (section && section.controlSetRows) {
|
||||||
|
for (const row of section.controlSetRows) {
|
||||||
|
for (const control of row) {
|
||||||
|
if (
|
||||||
|
typeof control === 'object' &&
|
||||||
|
control !== null &&
|
||||||
|
'name' in control &&
|
||||||
|
control.name === 'x_axis_time_format'
|
||||||
|
) {
|
||||||
|
timeFormatControl = control;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
if (timeFormatControl) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(timeFormatControl).toBeDefined();
|
||||||
|
expect(timeFormatControl.config).toMatchObject({
|
||||||
|
default: 'smart_date',
|
||||||
|
disableStash: true,
|
||||||
|
resetOnHide: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should have a description that includes D3 time format docs
|
||||||
|
expect(timeFormatControl.config.description).toContain('D3');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Control panel structure for bar charts', () => {
|
||||||
|
it('should have Chart Orientation section', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
const orientationSection = config.controlPanelSections.find(
|
||||||
|
section => section && section.label === 'Chart Orientation',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(orientationSection).toBeDefined();
|
||||||
|
expect(orientationSection!.expanded).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have Chart Options section with X Axis controls', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
const chartOptionsSection = config.controlPanelSections.find(
|
||||||
|
section => section && section.label === 'Chart Options',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(chartOptionsSection).toBeDefined();
|
||||||
|
expect(chartOptionsSection!.expanded).toBe(true);
|
||||||
|
|
||||||
|
// Should contain X Axis subsection header - this is sufficient proof
|
||||||
|
expect(chartOptionsSection!.controlSetRows).toBeDefined();
|
||||||
|
expect(chartOptionsSection!.controlSetRows!.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have proper form data overrides', () => {
|
||||||
|
const config = controlPanel;
|
||||||
|
|
||||||
|
expect(config.formDataOverrides).toBeDefined();
|
||||||
|
expect(typeof config.formDataOverrides).toBe('function');
|
||||||
|
|
||||||
|
// Test the form data override function
|
||||||
|
const mockFormData = {
|
||||||
|
datasource: '1__table',
|
||||||
|
viz_type: 'echarts_timeseries_bar',
|
||||||
|
metrics: ['test_metric'],
|
||||||
|
groupby: ['test_column'],
|
||||||
|
other_field: 'test',
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = config.formDataOverrides!(mockFormData);
|
||||||
|
|
||||||
|
expect(result).toHaveProperty('metrics');
|
||||||
|
expect(result).toHaveProperty('groupby');
|
||||||
|
expect(result).toHaveProperty('other_field', 'test');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+353
@@ -0,0 +1,353 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { ChartProps, SqlaFormData, supersetTheme } from '@superset-ui/core';
|
||||||
|
import { EchartsTimeseriesChartProps } from '../../../src/types';
|
||||||
|
import transformProps from '../../../src/Timeseries/transformProps';
|
||||||
|
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||||
|
import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
|
||||||
|
|
||||||
|
describe('Bar Chart X-axis Time Formatting', () => {
|
||||||
|
const baseFormData: SqlaFormData = {
|
||||||
|
...DEFAULT_FORM_DATA,
|
||||||
|
colorScheme: 'bnbColors',
|
||||||
|
datasource: '3__table',
|
||||||
|
granularity_sqla: '__timestamp',
|
||||||
|
metric: ['Sales', 'Marketing', 'Operations'],
|
||||||
|
groupby: [],
|
||||||
|
viz_type: 'echarts_timeseries_bar',
|
||||||
|
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||||
|
orientation: 'vertical',
|
||||||
|
};
|
||||||
|
|
||||||
|
const timeseriesData = [
|
||||||
|
{
|
||||||
|
data: [
|
||||||
|
{ Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01
|
||||||
|
{ Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01
|
||||||
|
{ Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01
|
||||||
|
],
|
||||||
|
colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'],
|
||||||
|
coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseChartPropsConfig = {
|
||||||
|
width: 800,
|
||||||
|
height: 600,
|
||||||
|
queriesData: timeseriesData,
|
||||||
|
theme: supersetTheme,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('Default xAxisTimeFormat', () => {
|
||||||
|
it('should use smart_date as default xAxisTimeFormat', () => {
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: baseFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check that the x-axis has a formatter applied
|
||||||
|
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => {
|
||||||
|
const formDataWithoutTimeFormat = {
|
||||||
|
...baseFormData,
|
||||||
|
};
|
||||||
|
delete formDataWithoutTimeFormat.xAxisTimeFormat;
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: formDataWithoutTimeFormat,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||||
|
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Custom xAxisTimeFormat', () => {
|
||||||
|
it('should respect custom xAxisTimeFormat when explicitly set', () => {
|
||||||
|
const customFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%Y-%m-%d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: customFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify the formatter function exists and is applied
|
||||||
|
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||||
|
|
||||||
|
// The key test is that a formatter exists - the actual formatting is handled by d3-time-format
|
||||||
|
const { formatter } = xAxis.axisLabel;
|
||||||
|
expect(formatter).toBeDefined();
|
||||||
|
expect(typeof formatter).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle different time format options', () => {
|
||||||
|
const timeFormats = [
|
||||||
|
'%Y-%m-%d',
|
||||||
|
'%Y/%m/%d',
|
||||||
|
'%m/%d/%Y',
|
||||||
|
'%b %d, %Y',
|
||||||
|
'smart_date',
|
||||||
|
];
|
||||||
|
|
||||||
|
timeFormats.forEach(timeFormat => {
|
||||||
|
const customFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: timeFormat,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: customFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Orientation-specific behavior', () => {
|
||||||
|
it('should apply time formatting to x-axis in vertical bar charts', () => {
|
||||||
|
const verticalFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
orientation: 'vertical',
|
||||||
|
xAxisTimeFormat: '%Y-%m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: verticalFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// In vertical orientation, time should be on x-axis
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should apply time formatting to y-axis in horizontal bar charts', () => {
|
||||||
|
const horizontalFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
orientation: 'horizontal',
|
||||||
|
xAxisTimeFormat: '%Y-%m',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: horizontalFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// In horizontal orientation, axes are swapped, so time should be on y-axis
|
||||||
|
const yAxis = transformedProps.echartOptions.yAxis as any;
|
||||||
|
expect(yAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(typeof yAxis.axisLabel.formatter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Integration with existing features', () => {
|
||||||
|
it('should work with axis bounds', () => {
|
||||||
|
const formDataWithBounds = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%Y-%m-%d',
|
||||||
|
truncateXAxis: true,
|
||||||
|
xAxisBounds: [null, null] as [number | null, number | null],
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: formDataWithBounds,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
// The xAxis should be configured with the time formatting
|
||||||
|
expect(transformedProps.echartOptions.xAxis).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should work with label rotation', () => {
|
||||||
|
const formDataWithRotation = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%Y-%m-%d',
|
||||||
|
xAxisLabelRotation: 45,
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: formDataWithRotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('rotate', 45);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should maintain time formatting consistency with tooltip', () => {
|
||||||
|
const formDataWithTooltip = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%Y-%m-%d',
|
||||||
|
tooltipTimeFormat: '%Y-%m-%d',
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: formDataWithTooltip,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both axis and tooltip should have formatters
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||||
|
expect(transformedProps.xValueFormatter).toBeDefined();
|
||||||
|
expect(typeof transformedProps.xValueFormatter).toBe('function');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Regression test for Issue #30373', () => {
|
||||||
|
it('should not be stuck on adaptive formatting', () => {
|
||||||
|
// Test the exact scenario described in the issue
|
||||||
|
const issueFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format
|
||||||
|
};
|
||||||
|
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: issueFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const transformedProps = transformProps(
|
||||||
|
chartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used
|
||||||
|
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||||
|
const { formatter } = xAxis.axisLabel;
|
||||||
|
|
||||||
|
expect(formatter).toBeDefined();
|
||||||
|
expect(typeof formatter).toBe('function');
|
||||||
|
|
||||||
|
// The important part is that the xAxisTimeFormat is being used from formData
|
||||||
|
// The actual formatting is handled by the underlying time formatter
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should allow changing from smart_date to other formats', () => {
|
||||||
|
// First create with smart_date (default)
|
||||||
|
const smartDateFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: 'smart_date',
|
||||||
|
};
|
||||||
|
|
||||||
|
const smartDateChartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: smartDateFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const smartDateProps = transformProps(
|
||||||
|
smartDateChartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Then change to a different format
|
||||||
|
const customFormatFormData = {
|
||||||
|
...baseFormData,
|
||||||
|
xAxisTimeFormat: '%b %Y',
|
||||||
|
};
|
||||||
|
|
||||||
|
const customFormatChartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: customFormatFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const customFormatProps = transformProps(
|
||||||
|
customFormatChartProps as EchartsTimeseriesChartProps,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Both should have formatters - the key is that they're not undefined
|
||||||
|
const smartDateXAxis = smartDateProps.echartOptions.xAxis as any;
|
||||||
|
const customFormatXAxis = customFormatProps.echartOptions.xAxis as any;
|
||||||
|
|
||||||
|
expect(smartDateXAxis.axisLabel.formatter).toBeDefined();
|
||||||
|
expect(customFormatXAxis.axisLabel.formatter).toBeDefined();
|
||||||
|
|
||||||
|
// Both should be functions that can format time
|
||||||
|
expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function');
|
||||||
|
expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have xAxisTimeFormat in formData by default', () => {
|
||||||
|
// This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||||
|
const chartProps = new ChartProps({
|
||||||
|
...baseChartPropsConfig,
|
||||||
|
formData: baseFormData,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(chartProps.formData.xAxisTimeFormat).toBeDefined();
|
||||||
|
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
|
||||||
|
|
||||||
|
describe('Timeseries constants', () => {
|
||||||
|
describe('DEFAULT_FORM_DATA', () => {
|
||||||
|
it('should include xAxisTimeFormat in default form data', () => {
|
||||||
|
expect(DEFAULT_FORM_DATA).toHaveProperty('xAxisTimeFormat');
|
||||||
|
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe('smart_date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include tooltipTimeFormat in default form data', () => {
|
||||||
|
expect(DEFAULT_FORM_DATA).toHaveProperty('tooltipTimeFormat');
|
||||||
|
expect(DEFAULT_FORM_DATA.tooltipTimeFormat).toBe('smart_date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have consistent time format defaults', () => {
|
||||||
|
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe(
|
||||||
|
DEFAULT_FORM_DATA.tooltipTimeFormat,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have vertical orientation as default', () => {
|
||||||
|
expect(DEFAULT_FORM_DATA.orientation).toBe('vertical');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
+10
-4
@@ -34,6 +34,12 @@ const parseLabel = value => {
|
|||||||
return String(value);
|
return String(value);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function displayCell(value, allowRenderHtml) {
|
||||||
|
if (allowRenderHtml && typeof value === 'string') {
|
||||||
|
return safeHtmlSpan(value);
|
||||||
|
}
|
||||||
|
return parseLabel(value);
|
||||||
|
}
|
||||||
function displayHeaderCell(
|
function displayHeaderCell(
|
||||||
needToggle,
|
needToggle,
|
||||||
ArrowIcon,
|
ArrowIcon,
|
||||||
@@ -742,7 +748,7 @@ export class TableRenderer extends Component {
|
|||||||
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
|
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
{agg.format(aggValue)}
|
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -759,7 +765,7 @@ export class TableRenderer extends Component {
|
|||||||
onClick={rowTotalCallbacks[flatRowKey]}
|
onClick={rowTotalCallbacks[flatRowKey]}
|
||||||
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
|
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
|
||||||
>
|
>
|
||||||
{agg.format(aggValue)}
|
{displayCell(agg.format(aggValue), allowRenderHtml)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -823,7 +829,7 @@ export class TableRenderer extends Component {
|
|||||||
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
|
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
|
||||||
style={{ padding: '5px' }}
|
style={{ padding: '5px' }}
|
||||||
>
|
>
|
||||||
{agg.format(aggValue)}
|
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -840,7 +846,7 @@ export class TableRenderer extends Component {
|
|||||||
onClick={grandTotalCallback}
|
onClick={grandTotalCallback}
|
||||||
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
|
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
|
||||||
>
|
>
|
||||||
{agg.format(aggValue)}
|
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,6 @@ import {
|
|||||||
Space,
|
Space,
|
||||||
RawAntdSelect as Select,
|
RawAntdSelect as Select,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Menu,
|
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import {
|
import {
|
||||||
@@ -564,52 +563,62 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
|||||||
return (
|
return (
|
||||||
<Dropdown
|
<Dropdown
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
visible={showComparisonDropdown}
|
open={showComparisonDropdown}
|
||||||
onVisibleChange={(flag: boolean) => {
|
onOpenChange={(flag: boolean) => {
|
||||||
setShowComparisonDropdown(flag);
|
setShowComparisonDropdown(flag);
|
||||||
}}
|
}}
|
||||||
overlay={
|
menu={{
|
||||||
<Menu
|
multiple: true,
|
||||||
multiple
|
onClick: handleOnClick,
|
||||||
onClick={handleOnClick}
|
onBlur: handleOnBlur,
|
||||||
onBlur={handleOnBlur}
|
selectedKeys: selectedComparisonColumns,
|
||||||
selectedKeys={selectedComparisonColumns}
|
items: [
|
||||||
>
|
{
|
||||||
<div
|
key: 'all',
|
||||||
css={css`
|
label: (
|
||||||
max-width: 242px;
|
<div
|
||||||
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`
|
css={css`
|
||||||
|
max-width: 242px;
|
||||||
|
padding: 0 ${theme.sizeUnit * 2}px;
|
||||||
color: ${theme.colorText};
|
color: ${theme.colorText};
|
||||||
`}
|
|
||||||
>
|
|
||||||
{column.label}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
css={css`
|
|
||||||
float: right;
|
|
||||||
font-size: ${theme.fontSizeSM}px;
|
font-size: ${theme.fontSizeSM}px;
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{selectedComparisonColumns.includes(column.key) && (
|
{t(
|
||||||
<CheckOutlined />
|
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||||
)}
|
)}
|
||||||
</span>
|
</div>
|
||||||
</Menu.Item>
|
),
|
||||||
))}
|
type: 'group',
|
||||||
</Menu>
|
children: comparisonColumns.map(
|
||||||
}
|
(column: { key: string; label: string }) => ({
|
||||||
|
key: column.key,
|
||||||
|
label: (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
css={css`
|
||||||
|
color: ${theme.colorText};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{column.label}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
css={css`
|
||||||
|
float: right;
|
||||||
|
font-size: ${theme.fontSizeSM}px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{selectedComparisonColumns.includes(column.key) && (
|
||||||
|
<CheckOutlined />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
>
|
>
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@@ -646,7 +646,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'show_cell_bars',
|
name: 'show_cell_bars',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('Show Cell bars'),
|
label: t('Show cell bars'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
default: true,
|
default: true,
|
||||||
description: t(
|
description: t(
|
||||||
@@ -674,7 +674,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'color_pn',
|
name: 'color_pn',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('add colors to cell bars for +/-'),
|
label: t('Add colors to cell bars for +/-'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
default: true,
|
default: true,
|
||||||
description: t(
|
description: t(
|
||||||
@@ -688,7 +688,7 @@ const config: ControlPanelConfig = {
|
|||||||
name: 'comparison_color_enabled',
|
name: 'comparison_color_enabled',
|
||||||
config: {
|
config: {
|
||||||
type: 'CheckboxControl',
|
type: 'CheckboxControl',
|
||||||
label: t('basic conditional formatting'),
|
label: t('Basic conditional formatting'),
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
visibility: ({ controls }) =>
|
visibility: ({ controls }) =>
|
||||||
!isEmpty(controls?.time_compare?.value),
|
!isEmpty(controls?.time_compare?.value),
|
||||||
@@ -729,7 +729,7 @@ const config: ControlPanelConfig = {
|
|||||||
config: {
|
config: {
|
||||||
type: 'ConditionalFormattingControl',
|
type: 'ConditionalFormattingControl',
|
||||||
renderTrigger: true,
|
renderTrigger: true,
|
||||||
label: t('Custom Conditional Formatting'),
|
label: t('Custom conditional formatting'),
|
||||||
extraColorChoices: [
|
extraColorChoices: [
|
||||||
{
|
{
|
||||||
value: ColorSchemeEnum.Green,
|
value: ColorSchemeEnum.Green,
|
||||||
|
|||||||
@@ -22,12 +22,13 @@ import React from 'react';
|
|||||||
// eslint-disable-next-line no-restricted-imports
|
// eslint-disable-next-line no-restricted-imports
|
||||||
import { configure as configureTestingLibrary } from '@testing-library/react';
|
import { configure as configureTestingLibrary } from '@testing-library/react';
|
||||||
import { matchers } from '@emotion/jest';
|
import { matchers } from '@emotion/jest';
|
||||||
|
import { DEFAULT_BOOTSTRAP_DATA } from 'src/constants';
|
||||||
|
|
||||||
configureTestingLibrary({
|
configureTestingLibrary({
|
||||||
testIdAttribute: 'data-test',
|
testIdAttribute: 'data-test',
|
||||||
});
|
});
|
||||||
|
|
||||||
document.body.innerHTML = '<div id="app" data-bootstrap=""></div>';
|
document.body.innerHTML = `<div id="app" data-bootstrap="${JSON.stringify(DEFAULT_BOOTSTRAP_DATA).replace(/"/g, '"')}"></div>`;
|
||||||
expect.extend(matchers);
|
expect.extend(matchers);
|
||||||
|
|
||||||
// Allow JSX tests to have React import readily available
|
// Allow JSX tests to have React import readily available
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
isFeatureEnabled,
|
isFeatureEnabled,
|
||||||
COMMON_ERR_MESSAGES,
|
COMMON_ERR_MESSAGES,
|
||||||
getClientErrorObject,
|
getClientErrorObject,
|
||||||
|
logging,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { invert, mapKeys } from 'lodash';
|
import { invert, mapKeys } from 'lodash';
|
||||||
|
|
||||||
@@ -869,8 +870,7 @@ export function updateSavedQuery(query, clientId) {
|
|||||||
})
|
})
|
||||||
.catch(e => {
|
.catch(e => {
|
||||||
const message = t('Your query could not be updated');
|
const message = t('Your query could not be updated');
|
||||||
// eslint-disable-next-line no-console
|
logging.error(message, e);
|
||||||
console.error(message, e);
|
|
||||||
dispatch(addDangerToast(message));
|
dispatch(addDangerToast(message));
|
||||||
})
|
})
|
||||||
.then(() => dispatch(updateQueryEditor(query)));
|
.then(() => dispatch(updateQueryEditor(query)));
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ export enum ContextMenuItem {
|
|||||||
export interface ChartContextMenuProps {
|
export interface ChartContextMenuProps {
|
||||||
id: number;
|
id: number;
|
||||||
formData: QueryFormData;
|
formData: QueryFormData;
|
||||||
onSelection: () => void;
|
onSelection: (args?: any) => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
additionalConfig?: {
|
additionalConfig?: {
|
||||||
crossFilter?: Record<string, any>;
|
crossFilter?: Record<string, any>;
|
||||||
@@ -123,6 +123,12 @@ const ChartContextMenu = (
|
|||||||
const [dataset, setDataset] = useState<Dataset>();
|
const [dataset, setDataset] = useState<Dataset>();
|
||||||
const verboseMap = useVerboseMap(dataset);
|
const verboseMap = useVerboseMap(dataset);
|
||||||
|
|
||||||
|
const closeContextMenu = useCallback(() => {
|
||||||
|
setVisible(false);
|
||||||
|
setOpenKeys([]);
|
||||||
|
onClose();
|
||||||
|
}, [onClose]);
|
||||||
|
|
||||||
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
|
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
|
||||||
setDrillByColumn(column);
|
setDrillByColumn(column);
|
||||||
setDataset(dataset); // Save dataset when drilling
|
setDataset(dataset); // Save dataset when drilling
|
||||||
@@ -264,6 +270,7 @@ const ChartContextMenu = (
|
|||||||
<DrillByMenuItems
|
<DrillByMenuItems
|
||||||
drillByConfig={filters?.drillBy}
|
drillByConfig={filters?.drillBy}
|
||||||
onSelection={onSelection}
|
onSelection={onSelection}
|
||||||
|
onCloseMenu={closeContextMenu}
|
||||||
formData={formData}
|
formData={formData}
|
||||||
contextMenuY={clientY}
|
contextMenuY={clientY}
|
||||||
submenuIndex={submenuIndex}
|
submenuIndex={submenuIndex}
|
||||||
@@ -311,6 +318,7 @@ const ChartContextMenu = (
|
|||||||
onOpenChange={setOpenKeys}
|
onOpenChange={setOpenKeys}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
|
setOpenKeys([]);
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -166,8 +166,12 @@ test('render menu item with submenu without searchbox', async () => {
|
|||||||
renderMenu({});
|
renderMenu({});
|
||||||
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
|
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
|
||||||
await expectDrillByEnabled();
|
await expectDrillByEnabled();
|
||||||
|
|
||||||
|
// Check that each column appears in the drill-by submenu
|
||||||
slicedColumns.forEach(column => {
|
slicedColumns.forEach(column => {
|
||||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0]; // Use the first submenu
|
||||||
|
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -186,15 +190,19 @@ test('render menu item with submenu and searchbox', async () => {
|
|||||||
// Wait for all columns to be visible
|
// Wait for all columns to be visible
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
defaultColumns.forEach(column => {
|
defaultColumns.forEach(column => {
|
||||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
expect(
|
||||||
|
within(submenu).getByText(column.column_name),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
const searchbox = await waitFor(
|
const searchbox = await waitFor(
|
||||||
() => screen.getAllByPlaceholderText('Search columns')[1],
|
() => screen.getAllByPlaceholderText('Search columns')[0],
|
||||||
);
|
);
|
||||||
expect(searchbox).toBeInTheDocument();
|
expect(searchbox).toBeInTheDocument();
|
||||||
|
|
||||||
@@ -204,19 +212,26 @@ test('render menu item with submenu and searchbox', async () => {
|
|||||||
|
|
||||||
// Wait for filtered results
|
// Wait for filtered results
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
expectedFilteredColumnNames.forEach(colName => {
|
expectedFilteredColumnNames.forEach(colName => {
|
||||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
|
|
||||||
defaultColumns
|
defaultColumns
|
||||||
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
|
||||||
.forEach(col => {
|
.forEach(col => {
|
||||||
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
|
expect(
|
||||||
|
within(submenu).queryByText(col.column_name),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expectedFilteredColumnNames.forEach(colName => {
|
expectedFilteredColumnNames.forEach(colName => {
|
||||||
expect(screen.getByText(colName)).toBeInTheDocument();
|
expect(within(submenu).getByText(colName)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,17 +253,23 @@ test('Do not display excluded column in the menu', async () => {
|
|||||||
// Wait for menu items to be loaded
|
// Wait for menu items to be loaded
|
||||||
await waitFor(
|
await waitFor(
|
||||||
() => {
|
() => {
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
defaultColumns
|
defaultColumns
|
||||||
.filter(column => !excludedColNames.includes(column.column_name))
|
.filter(column => !excludedColNames.includes(column.column_name))
|
||||||
.forEach(column => {
|
.forEach(column => {
|
||||||
expect(screen.getByText(column.column_name)).toBeInTheDocument();
|
expect(
|
||||||
|
within(submenu).getByText(column.column_name),
|
||||||
|
).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ timeout: 10000 },
|
{ timeout: 10000 },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
excludedColNames.forEach(colName => {
|
excludedColNames.forEach(colName => {
|
||||||
expect(screen.queryByText(colName)).not.toBeInTheDocument();
|
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -269,7 +290,11 @@ test('When menu item is clicked, call onSelection with clicked column and drill
|
|||||||
await expectDrillByEnabled();
|
await expectDrillByEnabled();
|
||||||
|
|
||||||
// Wait for col1 to be visible before clicking
|
// Wait for col1 to be visible before clicking
|
||||||
const col1Element = await waitFor(() => screen.getByText('col1'));
|
const col1Element = await waitFor(() => {
|
||||||
|
const submenus = screen.getAllByTestId('drill-by-submenu');
|
||||||
|
const submenu = submenus[0];
|
||||||
|
return within(submenu).getByText('col1');
|
||||||
|
});
|
||||||
userEvent.click(col1Element);
|
userEvent.click(col1Element);
|
||||||
|
|
||||||
expect(onSelectionMock).toHaveBeenCalledWith(
|
expect(onSelectionMock).toHaveBeenCalledWith(
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ import {
|
|||||||
import { InputRef } from 'antd';
|
import { InputRef } from 'antd';
|
||||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||||
import { getSubmenuYOffset } from '../utils';
|
import { getSubmenuYOffset } from '../utils';
|
||||||
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
|
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||||
import { Dataset } from '../types';
|
import { Dataset } from '../types';
|
||||||
|
|
||||||
const SUBMENU_HEIGHT = 200;
|
const SUBMENU_HEIGHT = 200;
|
||||||
@@ -68,6 +68,7 @@ export interface DrillByMenuItemsProps {
|
|||||||
submenuIndex?: number;
|
submenuIndex?: number;
|
||||||
onSelection?: (...args: any) => void;
|
onSelection?: (...args: any) => void;
|
||||||
onClick?: (event: MouseEvent) => void;
|
onClick?: (event: MouseEvent) => void;
|
||||||
|
onCloseMenu?: () => void;
|
||||||
openNewModal?: boolean;
|
openNewModal?: boolean;
|
||||||
excludedColumns?: Column[];
|
excludedColumns?: Column[];
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -100,6 +101,7 @@ export const DrillByMenuItems = ({
|
|||||||
submenuIndex = 0,
|
submenuIndex = 0,
|
||||||
onSelection = () => {},
|
onSelection = () => {},
|
||||||
onClick = () => {},
|
onClick = () => {},
|
||||||
|
onCloseMenu = () => {},
|
||||||
excludedColumns,
|
excludedColumns,
|
||||||
openNewModal = true,
|
openNewModal = true,
|
||||||
open,
|
open,
|
||||||
@@ -124,6 +126,7 @@ export const DrillByMenuItems = ({
|
|||||||
if (openNewModal && onDrillBy && dataset) {
|
if (openNewModal && onDrillBy && dataset) {
|
||||||
onDrillBy(column, dataset);
|
onDrillBy(column, dataset);
|
||||||
}
|
}
|
||||||
|
onCloseMenu();
|
||||||
},
|
},
|
||||||
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
|
||||||
);
|
);
|
||||||
@@ -264,15 +267,14 @@ export const DrillByMenuItems = ({
|
|||||||
const { columns, ...rest } = data;
|
const { columns, ...rest } = data;
|
||||||
const column = columns[index];
|
const column = columns[index];
|
||||||
return (
|
return (
|
||||||
<MenuItemWithTruncation
|
<VirtualizedMenuItem
|
||||||
menuKey={`drill-by-item-${column.column_name}`}
|
|
||||||
tooltipText={column.verbose_name || column.column_name}
|
tooltipText={column.verbose_name || column.column_name}
|
||||||
onClick={e => handleSelection(e, column)}
|
onClick={e => handleSelection(e, column)}
|
||||||
style={style}
|
style={style}
|
||||||
{...rest}
|
{...rest}
|
||||||
>
|
>
|
||||||
{column.verbose_name || column.column_name}
|
{column.verbose_name || column.column_name}
|
||||||
</MenuItemWithTruncation>
|
</VirtualizedMenuItem>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,4 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
export { default as DrillDetailMenuItems } from './DrillDetailMenuItems';
|
||||||
|
export { useDrillDetailMenuItems } from './useDrillDetailMenuItems';
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dispatch,
|
||||||
|
ReactNode,
|
||||||
|
SetStateAction,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
} from 'react';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
import {
|
||||||
|
Behavior,
|
||||||
|
BinaryQueryObjectFilterClause,
|
||||||
|
css,
|
||||||
|
extractQueryFields,
|
||||||
|
getChartMetadataRegistry,
|
||||||
|
QueryFormData,
|
||||||
|
removeHTMLTags,
|
||||||
|
styled,
|
||||||
|
t,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
|
import { RootState } from 'src/dashboard/types';
|
||||||
|
import { getSubmenuYOffset } from '../utils';
|
||||||
|
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||||
|
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
|
||||||
|
|
||||||
|
const DRILL_TO_DETAIL = t('Drill to detail');
|
||||||
|
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
|
||||||
|
const DISABLED_REASONS = {
|
||||||
|
DATABASE: t(
|
||||||
|
'Drill to detail is disabled for this database. Change the database settings to enable it.',
|
||||||
|
),
|
||||||
|
NO_AGGREGATIONS: t(
|
||||||
|
'Drill to detail is disabled because this chart does not group data by dimension value.',
|
||||||
|
),
|
||||||
|
NO_FILTERS: t(
|
||||||
|
'Right-click on a dimension value to drill to detail by that value.',
|
||||||
|
),
|
||||||
|
NOT_SUPPORTED: t(
|
||||||
|
'Drill to detail by value is not yet supported for this chart type.',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
function getDisabledMenuItem(
|
||||||
|
children: ReactNode,
|
||||||
|
menuKey: string,
|
||||||
|
...rest: unknown[]
|
||||||
|
): MenuItem {
|
||||||
|
return {
|
||||||
|
disabled: true,
|
||||||
|
key: menuKey,
|
||||||
|
label: (
|
||||||
|
<div
|
||||||
|
css={css`
|
||||||
|
white-space: normal;
|
||||||
|
max-width: 160px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
...rest,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const Filter = ({
|
||||||
|
children,
|
||||||
|
stripHTML = false,
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
stripHTML: boolean;
|
||||||
|
}) => {
|
||||||
|
const content =
|
||||||
|
stripHTML && typeof children === 'string'
|
||||||
|
? removeHTMLTags(children)
|
||||||
|
: children;
|
||||||
|
return <span>{content}</span>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const StyledFilter = styled(Filter)`
|
||||||
|
${({ theme }) => `
|
||||||
|
font-weight: ${theme.fontWeightStrong};
|
||||||
|
color: ${theme.colorPrimary};
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export type DrillDetailMenuItemsArgs = {
|
||||||
|
formData: QueryFormData;
|
||||||
|
filters?: BinaryQueryObjectFilterClause[];
|
||||||
|
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
|
||||||
|
isContextMenu?: boolean;
|
||||||
|
contextMenuY?: number;
|
||||||
|
onSelection?: () => void;
|
||||||
|
onClick?: (event: MouseEvent) => void;
|
||||||
|
submenuIndex?: number;
|
||||||
|
setShowModal: (show: boolean) => void;
|
||||||
|
key?: string;
|
||||||
|
forceSubmenuRender?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useDrillDetailMenuItems = ({
|
||||||
|
formData,
|
||||||
|
filters = [],
|
||||||
|
isContextMenu = false,
|
||||||
|
contextMenuY = 0,
|
||||||
|
onSelection = () => null,
|
||||||
|
onClick = () => null,
|
||||||
|
submenuIndex = 0,
|
||||||
|
setFilters,
|
||||||
|
setShowModal,
|
||||||
|
key,
|
||||||
|
...props
|
||||||
|
}: DrillDetailMenuItemsArgs) => {
|
||||||
|
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
|
||||||
|
({ datasources }) =>
|
||||||
|
datasources[formData.datasource]?.database?.disable_drill_to_detail,
|
||||||
|
);
|
||||||
|
|
||||||
|
const openModal = useCallback(
|
||||||
|
(filters, event) => {
|
||||||
|
onClick(event);
|
||||||
|
onSelection();
|
||||||
|
setFilters(filters);
|
||||||
|
setShowModal(true);
|
||||||
|
},
|
||||||
|
[onClick, onSelection],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
|
||||||
|
// event for dimensions. If it doesn't, tell the user that drill to detail by
|
||||||
|
// dimension is not supported. If it does, and the `contextmenu` handler didn't
|
||||||
|
// pass any filters, tell the user that they didn't select a dimension.
|
||||||
|
const handlesDimensionContextMenu = useMemo(
|
||||||
|
() =>
|
||||||
|
getChartMetadataRegistry()
|
||||||
|
.get(formData.viz_type)
|
||||||
|
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
|
||||||
|
[formData.viz_type],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check metrics to see if chart's current configuration lacks
|
||||||
|
// aggregations, in which case Drill to Detail should be disabled.
|
||||||
|
const noAggregations = useMemo(() => {
|
||||||
|
const { metrics } = extractQueryFields(formData);
|
||||||
|
return isEmpty(metrics);
|
||||||
|
}, [formData]);
|
||||||
|
|
||||||
|
// Ensure submenu doesn't appear offscreen
|
||||||
|
const submenuYOffset = useMemo(
|
||||||
|
() =>
|
||||||
|
getSubmenuYOffset(
|
||||||
|
contextMenuY,
|
||||||
|
filters.length > 1 ? filters.length + 1 : filters.length,
|
||||||
|
submenuIndex,
|
||||||
|
),
|
||||||
|
[contextMenuY, filters.length, submenuIndex],
|
||||||
|
);
|
||||||
|
|
||||||
|
let drillDisabled;
|
||||||
|
let drillByDisabled;
|
||||||
|
if (drillToDetailDisabled) {
|
||||||
|
drillDisabled = DISABLED_REASONS.DATABASE;
|
||||||
|
drillByDisabled = DISABLED_REASONS.DATABASE;
|
||||||
|
} else if (handlesDimensionContextMenu) {
|
||||||
|
if (noAggregations) {
|
||||||
|
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||||
|
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
|
||||||
|
} else if (!filters?.length) {
|
||||||
|
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drillToDetailMenuItem: MenuItem = drillDisabled
|
||||||
|
? getDisabledMenuItem(
|
||||||
|
<>
|
||||||
|
{DRILL_TO_DETAIL}
|
||||||
|
<MenuItemTooltip title={drillDisabled} />
|
||||||
|
</>,
|
||||||
|
'drill-to-detail-disabled',
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
: {
|
||||||
|
key: 'drill-to-detail',
|
||||||
|
label: DRILL_TO_DETAIL,
|
||||||
|
onClick: openModal.bind(null, []),
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMenuItemWithTruncation = useMenuItemWithTruncation();
|
||||||
|
|
||||||
|
const drillToDetailByMenuItem: MenuItem = drillByDisabled
|
||||||
|
? getDisabledMenuItem(
|
||||||
|
<>
|
||||||
|
{DRILL_TO_DETAIL_BY}
|
||||||
|
<MenuItemTooltip title={drillByDisabled} />
|
||||||
|
</>,
|
||||||
|
'drill-to-detail-by-disabled',
|
||||||
|
props,
|
||||||
|
)
|
||||||
|
: {
|
||||||
|
key: key || 'drill-to-detail-by',
|
||||||
|
label: DRILL_TO_DETAIL_BY,
|
||||||
|
children: [
|
||||||
|
...filters.map((filter, i) => ({
|
||||||
|
key: `drill-detail-filter-${i}`,
|
||||||
|
label: getMenuItemWithTruncation({
|
||||||
|
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
|
||||||
|
onClick: openModal.bind(null, [filter]),
|
||||||
|
key: `drill-detail-filter-${i}`,
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{`${DRILL_TO_DETAIL_BY} `}
|
||||||
|
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
})),
|
||||||
|
filters.length > 1 && {
|
||||||
|
key: 'drill-detail-filter-all',
|
||||||
|
label: getMenuItemWithTruncation({
|
||||||
|
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
|
||||||
|
onClick: openModal.bind(null, filters),
|
||||||
|
key: 'drill-detail-filter-all',
|
||||||
|
children: (
|
||||||
|
<>
|
||||||
|
{`${DRILL_TO_DETAIL_BY} `}
|
||||||
|
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
].filter(Boolean) as MenuItem[],
|
||||||
|
onClick: openModal.bind(null, filters),
|
||||||
|
forceSubmenuRender: true,
|
||||||
|
popupOffset: [0, submenuYOffset],
|
||||||
|
popupClassName: 'chart-context-submenu',
|
||||||
|
...props,
|
||||||
|
};
|
||||||
|
if (isContextMenu) {
|
||||||
|
return {
|
||||||
|
drillToDetailMenuItem,
|
||||||
|
drillToDetailByMenuItem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
drillToDetailMenuItem,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,9 +18,14 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { ReactNode, CSSProperties, useCallback } from 'react';
|
import { ReactNode, CSSProperties, useCallback } from 'react';
|
||||||
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
|
import {
|
||||||
|
css,
|
||||||
|
truncationCSS,
|
||||||
|
useCSSTextTruncation,
|
||||||
|
useTheme,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
|
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
|
||||||
import { Tooltip } from '@superset-ui/core/components';
|
import { Flex, Tooltip } from '@superset-ui/core/components';
|
||||||
import { MenuItemProps } from 'antd';
|
import { MenuItemProps } from 'antd';
|
||||||
|
|
||||||
export type MenuItemWithTruncationProps = {
|
export type MenuItemWithTruncationProps = {
|
||||||
@@ -113,7 +118,12 @@ export const MenuItemWithTruncation = ({
|
|||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={style}
|
style={style}
|
||||||
>
|
>
|
||||||
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
<Tooltip
|
||||||
|
title={itemIsTruncated ? tooltipText : null}
|
||||||
|
css={css`
|
||||||
|
max-width: 200px;
|
||||||
|
`}
|
||||||
|
>
|
||||||
<div
|
<div
|
||||||
ref={itemRef}
|
ref={itemRef}
|
||||||
css={css`
|
css={css`
|
||||||
@@ -127,3 +137,50 @@ export const MenuItemWithTruncation = ({
|
|||||||
</Menu.Item>
|
</Menu.Item>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const VirtualizedMenuItem = ({
|
||||||
|
tooltipText,
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
style,
|
||||||
|
}: {
|
||||||
|
tooltipText: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
style?: CSSProperties;
|
||||||
|
}) => {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={onClick}
|
||||||
|
align="center"
|
||||||
|
style={style}
|
||||||
|
css={css`
|
||||||
|
cursor: pointer;
|
||||||
|
padding-left: ${theme.paddingXS}px;
|
||||||
|
&:hover {
|
||||||
|
background-color: ${theme.colorBgTextHover};
|
||||||
|
}
|
||||||
|
&:active {
|
||||||
|
background-color: ${theme.colorBgTextActive};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<Tooltip title={itemIsTruncated ? tooltipText : null}>
|
||||||
|
<div
|
||||||
|
ref={itemRef}
|
||||||
|
css={css`
|
||||||
|
max-width: 100%;
|
||||||
|
${truncationCSS};
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ const v1ChartDataRequest = async (
|
|||||||
ownState,
|
ownState,
|
||||||
parseMethod,
|
parseMethod,
|
||||||
) => {
|
) => {
|
||||||
const payload = buildV1ChartDataPayload({
|
const payload = await buildV1ChartDataPayload({
|
||||||
formData,
|
formData,
|
||||||
resultType,
|
resultType,
|
||||||
resultFormat,
|
resultFormat,
|
||||||
@@ -255,7 +255,7 @@ export function runAnnotationQuery({
|
|||||||
isDashboardRequest = false,
|
isDashboardRequest = false,
|
||||||
force = false,
|
force = false,
|
||||||
}) {
|
}) {
|
||||||
return function (dispatch, getState) {
|
return async function (dispatch, getState) {
|
||||||
const { charts, common } = getState();
|
const { charts, common } = getState();
|
||||||
const sliceKey = key || Object.keys(charts)[0];
|
const sliceKey = key || Object.keys(charts)[0];
|
||||||
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
||||||
@@ -310,17 +310,19 @@ export function runAnnotationQuery({
|
|||||||
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
|
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const payload = await buildV1ChartDataPayload({
|
||||||
|
formData: fd,
|
||||||
|
force,
|
||||||
|
resultFormat: 'json',
|
||||||
|
resultType: 'full',
|
||||||
|
});
|
||||||
|
|
||||||
return SupersetClient.post({
|
return SupersetClient.post({
|
||||||
url,
|
url,
|
||||||
signal,
|
signal,
|
||||||
timeout: queryTimeout * 1000,
|
timeout: queryTimeout * 1000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
jsonPayload: buildV1ChartDataPayload({
|
jsonPayload: payload,
|
||||||
formData: fd,
|
|
||||||
force,
|
|
||||||
resultFormat: 'json',
|
|
||||||
resultType: 'full',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.then(({ json }) => {
|
.then(({ json }) => {
|
||||||
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
|
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
|
||||||
@@ -420,6 +422,8 @@ export function exploreJSON(
|
|||||||
const setDataMask = dataMask => {
|
const setDataMask = dataMask => {
|
||||||
dispatch(updateDataMask(formData.slice_id, dataMask));
|
dispatch(updateDataMask(formData.slice_id, dataMask));
|
||||||
};
|
};
|
||||||
|
dispatch(chartUpdateStarted(controller, formData, key));
|
||||||
|
|
||||||
const chartDataRequest = getChartDataRequest({
|
const chartDataRequest = getChartDataRequest({
|
||||||
setDataMask,
|
setDataMask,
|
||||||
formData,
|
formData,
|
||||||
@@ -431,8 +435,6 @@ export function exploreJSON(
|
|||||||
ownState,
|
ownState,
|
||||||
});
|
});
|
||||||
|
|
||||||
dispatch(chartUpdateStarted(controller, formData, key));
|
|
||||||
|
|
||||||
const [useLegacyApi] = getQuerySettings(formData);
|
const [useLegacyApi] = getQuerySettings(formData);
|
||||||
const chartDataRequestCaught = chartDataRequest
|
const chartDataRequestCaught = chartDataRequest
|
||||||
.then(({ response, json }) =>
|
.then(({ response, json }) =>
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ describe('chart actions', () => {
|
|||||||
let dispatch;
|
let dispatch;
|
||||||
let getExploreUrlStub;
|
let getExploreUrlStub;
|
||||||
let getChartDataUriStub;
|
let getChartDataUriStub;
|
||||||
|
let buildV1ChartDataPayloadStub;
|
||||||
let waitForAsyncDataStub;
|
let waitForAsyncDataStub;
|
||||||
let fakeMetadata;
|
let fakeMetadata;
|
||||||
|
|
||||||
@@ -85,6 +86,13 @@ describe('chart actions', () => {
|
|||||||
getChartDataUriStub = sinon
|
getChartDataUriStub = sinon
|
||||||
.stub(exploreUtils, 'getChartDataUri')
|
.stub(exploreUtils, 'getChartDataUri')
|
||||||
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
|
.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 };
|
fakeMetadata = { useLegacyApi: true };
|
||||||
getChartMetadataRegistry.mockImplementation(() => ({
|
getChartMetadataRegistry.mockImplementation(() => ({
|
||||||
get: () => fakeMetadata,
|
get: () => fakeMetadata,
|
||||||
@@ -104,6 +112,7 @@ describe('chart actions', () => {
|
|||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
getExploreUrlStub.restore();
|
getExploreUrlStub.restore();
|
||||||
getChartDataUriStub.restore();
|
getChartDataUriStub.restore();
|
||||||
|
buildV1ChartDataPayloadStub.restore();
|
||||||
fetchMock.resetHistory();
|
fetchMock.resetHistory();
|
||||||
waitForAsyncDataStub.restore();
|
waitForAsyncDataStub.restore();
|
||||||
|
|
||||||
@@ -362,7 +371,7 @@ describe('chart actions timeout', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the timeout from arguments when given', () => {
|
it('should use the timeout from arguments when given', async () => {
|
||||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||||
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
||||||
const timeout = 10; // Set the timeout value here
|
const timeout = 10; // Set the timeout value here
|
||||||
@@ -370,7 +379,7 @@ describe('chart actions timeout', () => {
|
|||||||
const key = 'chartKey'; // Set the chart key here
|
const key = 'chartKey'; // Set the chart key here
|
||||||
|
|
||||||
const store = mockStore(initialState);
|
const store = mockStore(initialState);
|
||||||
store.dispatch(
|
await store.dispatch(
|
||||||
actions.runAnnotationQuery({
|
actions.runAnnotationQuery({
|
||||||
annotation: {
|
annotation: {
|
||||||
value: 'annotationValue',
|
value: 'annotationValue',
|
||||||
@@ -394,14 +403,14 @@ describe('chart actions timeout', () => {
|
|||||||
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use the timeout from common.conf when not passed as an argument', () => {
|
it('should use the timeout from common.conf when not passed as an argument', async () => {
|
||||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||||
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
||||||
const formData = { datasource: 'table__1' }; // Set the formData here
|
const formData = { datasource: 'table__1' }; // Set the formData here
|
||||||
const key = 'chartKey'; // Set the chart key here
|
const key = 'chartKey'; // Set the chart key here
|
||||||
|
|
||||||
const store = mockStore(initialState);
|
const store = mockStore(initialState);
|
||||||
store.dispatch(
|
await store.dispatch(
|
||||||
actions.runAnnotationQuery({
|
actions.runAnnotationQuery({
|
||||||
annotation: {
|
annotation: {
|
||||||
value: 'annotationValue',
|
value: 'annotationValue',
|
||||||
|
|||||||
@@ -16,11 +16,29 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
within,
|
||||||
|
cleanup,
|
||||||
|
} from 'spec/helpers/testing-library';
|
||||||
import { store } from 'src/views/store';
|
import { store } from 'src/views/store';
|
||||||
|
import { isFeatureEnabled } from '@superset-ui/core';
|
||||||
import { FacePile } from '.';
|
import { FacePile } from '.';
|
||||||
import { getRandomColor } from './utils';
|
import { getRandomColor } from './utils';
|
||||||
|
|
||||||
|
// Mock the feature flag
|
||||||
|
jest.mock('@superset-ui/core', () => ({
|
||||||
|
...jest.requireActual('@superset-ui/core'),
|
||||||
|
isFeatureEnabled: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
|
||||||
|
typeof isFeatureEnabled
|
||||||
|
>;
|
||||||
|
|
||||||
const users = [...new Array(10)].map((_, i) => ({
|
const users = [...new Array(10)].map((_, i) => ({
|
||||||
first_name: 'user',
|
first_name: 'user',
|
||||||
last_name: `${i}`,
|
last_name: `${i}`,
|
||||||
@@ -29,37 +47,99 @@ const users = [...new Array(10)].map((_, i) => ({
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
|
// Default to Slack avatars disabled
|
||||||
|
mockIsFeatureEnabled.mockImplementation(() => false);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
|
mockIsFeatureEnabled.mockReset();
|
||||||
|
cleanup();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FacePile', () => {
|
describe('FacePile', () => {
|
||||||
let container: HTMLElement;
|
it('renders empty state with no users', () => {
|
||||||
|
const { container } = render(<FacePile users={[]} />, { store });
|
||||||
|
|
||||||
beforeEach(() => {
|
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
|
||||||
({ container } = render(<FacePile users={users} />, { store }));
|
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('is a valid element', () => {
|
it('renders single user without truncation', () => {
|
||||||
const exposedFaces = screen.getAllByText(/U/);
|
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
|
||||||
expect(exposedFaces).toHaveLength(4);
|
store,
|
||||||
const overflownFaces = screen.getByText('+6');
|
});
|
||||||
expect(overflownFaces).toBeVisible();
|
|
||||||
|
|
||||||
// Display user info when hovering over one of exposed face in the pile.
|
const avatars = container.querySelectorAll('.ant-avatar');
|
||||||
fireEvent.mouseEnter(exposedFaces[0]);
|
expect(avatars).toHaveLength(1);
|
||||||
|
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||||
|
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple users no truncation', () => {
|
||||||
|
const { container } = render(<FacePile users={users.slice(0, 4)} />, {
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
const avatars = container.querySelectorAll('.ant-avatar');
|
||||||
|
expect(avatars).toHaveLength(4);
|
||||||
|
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||||
|
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders multiple users with truncation', () => {
|
||||||
|
const { container } = render(<FacePile users={users} />, { store });
|
||||||
|
|
||||||
|
// Should show 4 avatars + 1 overflow indicator = 5 total elements
|
||||||
|
const avatars = container.querySelectorAll('.ant-avatar');
|
||||||
|
expect(avatars).toHaveLength(5);
|
||||||
|
|
||||||
|
// Should show first 4 users
|
||||||
|
expect(within(container).getByText('U0')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U1')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U2')).toBeInTheDocument();
|
||||||
|
expect(within(container).getByText('U3')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Should show overflow count (+6 because 10 total - 4 shown)
|
||||||
|
expect(within(container).getByText('+6')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays user tooltip on hover', () => {
|
||||||
|
const { container } = render(<FacePile users={users.slice(0, 2)} />, {
|
||||||
|
store,
|
||||||
|
});
|
||||||
|
|
||||||
|
const firstAvatar = within(container).getByText('U0');
|
||||||
|
fireEvent.mouseEnter(firstAvatar);
|
||||||
act(() => jest.runAllTimers());
|
act(() => jest.runAllTimers());
|
||||||
|
|
||||||
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
|
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders an Avatar', () => {
|
it('displays avatar images when Slack avatars are enabled', () => {
|
||||||
expect(container.querySelector('.ant-avatar')).toBeVisible();
|
// Enable Slack avatars feature flag
|
||||||
});
|
mockIsFeatureEnabled.mockImplementation(
|
||||||
|
feature => feature === 'SLACK_ENABLE_AVATARS',
|
||||||
|
);
|
||||||
|
|
||||||
it('hides overflow', () => {
|
const { container: testContainer } = render(
|
||||||
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
|
<FacePile users={users.slice(0, 2)} />,
|
||||||
|
{
|
||||||
|
store,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const avatars = testContainer.querySelectorAll('.ant-avatar');
|
||||||
|
expect(avatars).toHaveLength(2);
|
||||||
|
|
||||||
|
// Should have img elements with correct src attributes
|
||||||
|
const imgs = testContainer.querySelectorAll('.ant-avatar img');
|
||||||
|
expect(imgs).toHaveLength(2);
|
||||||
|
expect(imgs[0]).toHaveAttribute('src', '/api/v1/user/0/avatar.png');
|
||||||
|
expect(imgs[1]).toHaveAttribute('src', '/api/v1/user/1/avatar.png');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,9 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { tagToSelectOption } from 'src/components/Tag/utils';
|
import fetchMock from 'fetch-mock';
|
||||||
|
import rison from 'rison';
|
||||||
|
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
|
||||||
|
|
||||||
describe('tagToSelectOption', () => {
|
describe('tagToSelectOption', () => {
|
||||||
test('converts a Tag object with table_name to a SelectTagsValue', () => {
|
test('converts a Tag object with table_name to a SelectTagsValue', () => {
|
||||||
@@ -35,3 +37,166 @@ describe('tagToSelectOption', () => {
|
|||||||
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
|
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loadTags', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
fetchMock.reset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
fetchMock.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('constructs correct API query with custom tag filter', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ id: 1, name: 'analytics', type: 1 },
|
||||||
|
{ id: 2, name: 'finance', type: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: mockTags,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTags('analytics', 0, 25);
|
||||||
|
|
||||||
|
// Verify the API was called with correct parameters
|
||||||
|
const calls = fetchMock.calls();
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
|
||||||
|
const [url] = calls[0];
|
||||||
|
expect(url).toContain('/api/v1/tag/?q=');
|
||||||
|
|
||||||
|
// Extract and decode the query parameter
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const queryParam = urlObj.searchParams.get('q');
|
||||||
|
expect(queryParam).not.toBeNull();
|
||||||
|
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||||
|
|
||||||
|
// Verify the query structure
|
||||||
|
expect(decodedQuery).toEqual({
|
||||||
|
filters: [
|
||||||
|
{ col: 'name', opr: 'ct', value: 'analytics' },
|
||||||
|
{ col: 'type', opr: 'custom_tag', value: true },
|
||||||
|
],
|
||||||
|
page: 0,
|
||||||
|
page_size: 25,
|
||||||
|
order_column: 'name',
|
||||||
|
order_direction: 'asc',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns correctly transformed data', async () => {
|
||||||
|
const mockTags = [
|
||||||
|
{ id: 1, name: 'analytics', type: 1 },
|
||||||
|
{ id: 2, name: 'finance', type: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: mockTags,
|
||||||
|
count: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await loadTags('', 0, 25);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
data: [
|
||||||
|
{ value: 1, label: 'analytics', key: 1 },
|
||||||
|
{ value: 2, label: 'finance', key: 2 },
|
||||||
|
],
|
||||||
|
totalCount: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles search parameter correctly', async () => {
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: [],
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTags('financial-data', 0, 25);
|
||||||
|
|
||||||
|
const calls = fetchMock.calls();
|
||||||
|
const [url] = calls[0];
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const queryParam = urlObj.searchParams.get('q');
|
||||||
|
expect(queryParam).not.toBeNull();
|
||||||
|
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||||
|
|
||||||
|
// Should include the search term in the name filter
|
||||||
|
expect(decodedQuery.filters[0]).toEqual({
|
||||||
|
col: 'name',
|
||||||
|
opr: 'ct',
|
||||||
|
value: 'financial-data',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles pagination parameters correctly', async () => {
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: [],
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTags('', 2, 10);
|
||||||
|
|
||||||
|
const calls = fetchMock.calls();
|
||||||
|
const [url] = calls[0];
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const queryParam = urlObj.searchParams.get('q');
|
||||||
|
expect(queryParam).not.toBeNull();
|
||||||
|
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||||
|
|
||||||
|
expect(decodedQuery.page).toBe(2);
|
||||||
|
expect(decodedQuery.page_size).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('always includes custom tag filter regardless of other parameters', async () => {
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: [],
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test with different combinations of parameters
|
||||||
|
await loadTags('', 0, 25);
|
||||||
|
await loadTags('search-term', 1, 50);
|
||||||
|
await loadTags('another-search', 5, 100);
|
||||||
|
|
||||||
|
const calls = fetchMock.calls();
|
||||||
|
|
||||||
|
// Verify all calls include the custom tag filter
|
||||||
|
calls.forEach(call => {
|
||||||
|
const [url] = call;
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const queryParam = urlObj.searchParams.get('q');
|
||||||
|
expect(queryParam).not.toBeNull();
|
||||||
|
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||||
|
|
||||||
|
// Every call should have the custom tag filter
|
||||||
|
expect(decodedQuery.filters).toContainEqual({
|
||||||
|
col: 'type',
|
||||||
|
opr: 'custom_tag',
|
||||||
|
value: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('maintains correct order specification', async () => {
|
||||||
|
fetchMock.get('glob:*/api/v1/tag/*', {
|
||||||
|
result: [],
|
||||||
|
count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadTags('test', 0, 25);
|
||||||
|
|
||||||
|
const calls = fetchMock.calls();
|
||||||
|
const [url] = calls[0];
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const queryParam = urlObj.searchParams.get('q');
|
||||||
|
expect(queryParam).not.toBeNull();
|
||||||
|
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
|
||||||
|
|
||||||
|
// Should always order by name ascending
|
||||||
|
expect(decodedQuery.order_column).toBe('name');
|
||||||
|
expect(decodedQuery.order_direction).toBe('asc');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -78,3 +78,129 @@ test('should render 3 elements when maxTags is set to 3', async () => {
|
|||||||
expect(tagsListItems).toHaveLength(3);
|
expect(tagsListItems).toHaveLength(3);
|
||||||
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Tag type filtering', () => {
|
||||||
|
test('should render only custom type tags (type: 1)', async () => {
|
||||||
|
const mixedTypeTags = [
|
||||||
|
{ name: 'custom-tag', type: 1, id: 1 }, // Custom - should show
|
||||||
|
{ name: 'type:chart', type: 2, id: 2 }, // Type - should be filtered out
|
||||||
|
{ name: 'owner:admin', type: 3, id: 3 }, // Owner - should be filtered out
|
||||||
|
{ name: 'another-custom', type: 1, id: 4 }, // Custom - should show
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter tags like ChartList does - only custom types
|
||||||
|
const filteredTags = mixedTypeTags.filter(tag =>
|
||||||
|
tag.type
|
||||||
|
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ tags: filteredTags, maxTags: 5 });
|
||||||
|
const tagsListItems = await findAllTags();
|
||||||
|
|
||||||
|
// Should only show 2 custom tags, sorted alphabetically
|
||||||
|
expect(tagsListItems).toHaveLength(2);
|
||||||
|
expect(tagsListItems[0]).toHaveTextContent('another-custom');
|
||||||
|
expect(tagsListItems[1]).toHaveTextContent('custom-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show tags when type is undefined (fallback case)', async () => {
|
||||||
|
const undefinedTypeTags = [
|
||||||
|
{ name: 'legacy-tag', id: 1 }, // No type property - should show due to fallback
|
||||||
|
{ name: 'custom-tag', type: 1, id: 2 }, // Custom - should show
|
||||||
|
{ name: 'system-tag', type: 2, id: 3 }, // System - should be filtered out
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply ChartList filtering logic - undefined type defaults to true
|
||||||
|
const filteredTags = undefinedTypeTags.filter(tag =>
|
||||||
|
tag.type
|
||||||
|
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ tags: filteredTags, maxTags: 5 });
|
||||||
|
const tagsListItems = await findAllTags();
|
||||||
|
|
||||||
|
// Should show both tags, sorted alphabetically
|
||||||
|
expect(tagsListItems).toHaveLength(2);
|
||||||
|
expect(tagsListItems[0]).toHaveTextContent('custom-tag');
|
||||||
|
expect(tagsListItems[1]).toHaveTextContent('legacy-tag');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle legacy TagTypes.custom string format', async () => {
|
||||||
|
const legacyFormatTags = [
|
||||||
|
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 1 }, // Legacy string format - should show
|
||||||
|
{ name: 'modern-custom', type: 1, id: 2 }, // Modern enum - should show
|
||||||
|
{ name: 'other-type', type: 'TagTypes.other', id: 3 }, // Other legacy type - should be filtered out
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply ChartList filtering logic - supports both numeric and legacy string
|
||||||
|
const filteredTags = legacyFormatTags.filter(tag =>
|
||||||
|
tag.type
|
||||||
|
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ tags: filteredTags, maxTags: 5 });
|
||||||
|
const tagsListItems = await findAllTags();
|
||||||
|
|
||||||
|
// Should show both custom formats, sorted alphabetically
|
||||||
|
expect(tagsListItems).toHaveLength(2);
|
||||||
|
expect(tagsListItems[0]).toHaveTextContent('legacy-custom');
|
||||||
|
expect(tagsListItems[1]).toHaveTextContent('modern-custom');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should show empty list when all tags are filtered out', async () => {
|
||||||
|
const nonCustomTags = [
|
||||||
|
{ name: 'type:chart', type: 2, id: 1 }, // Type tag
|
||||||
|
{ name: 'owner:admin', type: 3, id: 2 }, // Owner tag
|
||||||
|
{ name: 'favoritedBy:user', type: 4, id: 3 }, // FavoritedBy tag
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply ChartList filtering - all should be filtered out
|
||||||
|
const filteredTags = nonCustomTags.filter(tag =>
|
||||||
|
tag.type
|
||||||
|
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
setup({ tags: filteredTags, maxTags: 5 });
|
||||||
|
|
||||||
|
// Should render container but with no tags
|
||||||
|
const container = document.querySelector('.tag-list');
|
||||||
|
expect(container).toBeInTheDocument();
|
||||||
|
|
||||||
|
// No tags should be rendered
|
||||||
|
const tagsListItems = document.querySelectorAll('.ant-tag');
|
||||||
|
expect(tagsListItems).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should handle mixed scenarios with truncation', async () => {
|
||||||
|
const largeMixedTagSet = [
|
||||||
|
{ name: 'custom-1', type: 1, id: 1 }, // Custom - should show
|
||||||
|
{ name: 'system-1', type: 2, id: 2 }, // System - filtered out
|
||||||
|
{ name: 'custom-2', type: 1, id: 3 }, // Custom - should show
|
||||||
|
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 4 }, // Legacy custom - should show
|
||||||
|
{ name: 'custom-3', type: 1, id: 5 }, // Custom - should show
|
||||||
|
{ name: 'owner-tag', type: 3, id: 6 }, // Owner - filtered out
|
||||||
|
{ name: 'custom-4', type: 1, id: 7 }, // Custom - should show (but truncated)
|
||||||
|
];
|
||||||
|
|
||||||
|
// Apply ChartList filtering - should get 5 custom tags
|
||||||
|
const filteredTags = largeMixedTagSet.filter(tag =>
|
||||||
|
tag.type
|
||||||
|
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
|
||||||
|
: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Set maxTags to 3 to test truncation of filtered results
|
||||||
|
setup({ tags: filteredTags, maxTags: 3 });
|
||||||
|
const tagsListItems = await findAllTags();
|
||||||
|
|
||||||
|
// Should show 3 tags: 2 custom tags (alphabetically sorted) + 1 "+3..." truncation indicator
|
||||||
|
expect(tagsListItems).toHaveLength(3);
|
||||||
|
expect(tagsListItems[0]).toHaveTextContent('custom-1');
|
||||||
|
expect(tagsListItems[1]).toHaveTextContent('custom-2');
|
||||||
|
expect(tagsListItems[2]).toHaveTextContent('+3...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
getClientErrorObject,
|
getClientErrorObject,
|
||||||
getCategoricalSchemeRegistry,
|
getCategoricalSchemeRegistry,
|
||||||
promiseTimeout,
|
promiseTimeout,
|
||||||
|
logging,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
addChart,
|
addChart,
|
||||||
@@ -887,7 +888,7 @@ export const applyDashboardLabelsColorOnLoad = metadata => async dispatch => {
|
|||||||
dispatch(setDashboardLabelsColorMapSync());
|
dispatch(setDashboardLabelsColorMapSync());
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update dashboard color on load:', e);
|
logging.error('Failed to update dashboard color on load:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1054,6 +1055,6 @@ export const updateDashboardLabelsColor = renderedChartIds => (_, getState) => {
|
|||||||
// re-apply the color map first to get fresh maps accordingly
|
// re-apply the color map first to get fresh maps accordingly
|
||||||
applyColors(metadata, shouldGoFresh, shouldMerge);
|
applyColors(metadata, shouldGoFresh, shouldMerge);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update colors for new charts and labels:', e);
|
logging.error('Failed to update colors for new charts and labels:', e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
t,
|
t,
|
||||||
css,
|
css,
|
||||||
getExtensionsRegistry,
|
getExtensionsRegistry,
|
||||||
|
logging,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -86,7 +87,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
|||||||
addInfoToast(t('Changes saved.'));
|
addInfoToast(t('Changes saved.'));
|
||||||
},
|
},
|
||||||
err => {
|
err => {
|
||||||
console.error(err);
|
logging.error(err);
|
||||||
addDangerToast(
|
addDangerToast(
|
||||||
t(
|
t(
|
||||||
t('Sorry, something went wrong. The changes could not be saved.'),
|
t('Sorry, something went wrong. The changes could not be saved.'),
|
||||||
@@ -115,7 +116,7 @@ export const DashboardEmbedControls = ({ dashboardId, onHide }: Props) => {
|
|||||||
onHide();
|
onHide();
|
||||||
},
|
},
|
||||||
err => {
|
err => {
|
||||||
console.error(err);
|
logging.error(err);
|
||||||
addDangerToast(
|
addDangerToast(
|
||||||
t(
|
t(
|
||||||
'Sorry, something went wrong. Embedding could not be deactivated.',
|
'Sorry, something went wrong. Embedding could not be deactivated.',
|
||||||
|
|||||||
+207
-153
@@ -18,16 +18,16 @@
|
|||||||
*/
|
*/
|
||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import { t } from '@superset-ui/core';
|
import { t } from '@superset-ui/core';
|
||||||
import { isEmpty } from 'lodash';
|
import { isEmpty } from 'lodash';
|
||||||
import { URL_PARAMS } from 'src/constants';
|
import { URL_PARAMS } from 'src/constants';
|
||||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
|
||||||
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
|
import { useDownloadMenuItems } from 'src/dashboard/components/menu/DownloadMenuItems';
|
||||||
|
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
||||||
import CssEditor from 'src/dashboard/components/CssEditor';
|
import CssEditor from 'src/dashboard/components/CssEditor';
|
||||||
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
|
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
|
||||||
import SaveModal from 'src/dashboard/components/SaveModal';
|
import SaveModal from 'src/dashboard/components/SaveModal';
|
||||||
import HeaderReportDropdown from 'src/features/reports/ReportModal/HeaderReportDropdown';
|
|
||||||
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
|
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
|
||||||
import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
|
import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
|
||||||
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
|
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
|
||||||
@@ -74,9 +74,6 @@ export const useHeaderActionsMenu = ({
|
|||||||
}: HeaderDropdownProps) => {
|
}: HeaderDropdownProps) => {
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
const [css, setCss] = useState(customCss || '');
|
const [css, setCss] = useState(customCss || '');
|
||||||
const [showReportSubMenu, setShowReportSubMenu] = useState<boolean | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||||
const directPathToChild = useSelector(
|
const directPathToChild = useSelector(
|
||||||
(state: RootState) => state.dashboardState.directPathToChild,
|
(state: RootState) => state.dashboardState.directPathToChild,
|
||||||
@@ -172,163 +169,220 @@ export const useHeaderActionsMenu = ({
|
|||||||
[directPathToChild],
|
[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 menu = useMemo(() => {
|
||||||
const isEmbedded = !dashboardInfo?.userId;
|
const isEmbedded = !dashboardInfo?.userId;
|
||||||
const refreshIntervalOptions =
|
const refreshIntervalOptions =
|
||||||
dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
|
dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
|
||||||
|
|
||||||
|
const menuItems: MenuItem[] = [];
|
||||||
|
|
||||||
|
// Refresh dashboard
|
||||||
|
if (!editMode) {
|
||||||
|
menuItems.push({
|
||||||
|
key: MenuKeys.RefreshDashboard,
|
||||||
|
label: t('Refresh dashboard'),
|
||||||
|
disabled: isLoading,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle fullscreen
|
||||||
|
if (!editMode && !isEmbedded) {
|
||||||
|
menuItems.push({
|
||||||
|
key: MenuKeys.ToggleFullscreen,
|
||||||
|
label: getUrlParam(URL_PARAMS.standalone)
|
||||||
|
? t('Exit fullscreen')
|
||||||
|
: t('Enter fullscreen'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit properties
|
||||||
|
if (editMode) {
|
||||||
|
menuItems.push({
|
||||||
|
key: MenuKeys.EditProperties,
|
||||||
|
label: t('Edit properties'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit CSS
|
||||||
|
if (editMode) {
|
||||||
|
menuItems.push(
|
||||||
|
createModalMenuItem(
|
||||||
|
MenuKeys.EditCss,
|
||||||
|
<CssEditor
|
||||||
|
triggerNode={<div>{t('Theme & CSS')}</div>}
|
||||||
|
initialCss={css}
|
||||||
|
onChange={changeCss}
|
||||||
|
addDangerToast={addDangerToast}
|
||||||
|
currentThemeId={dashboardInfo.theme?.id || null}
|
||||||
|
onThemeChange={handleThemeChange}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
menuItems.push({ type: 'divider' });
|
||||||
|
|
||||||
|
// Save as
|
||||||
|
if (userCanSave) {
|
||||||
|
menuItems.push(
|
||||||
|
createModalMenuItem(
|
||||||
|
MenuKeys.SaveModal,
|
||||||
|
<SaveModal
|
||||||
|
addSuccessToast={addSuccessToast}
|
||||||
|
addDangerToast={addDangerToast}
|
||||||
|
dashboardId={dashboardId}
|
||||||
|
dashboardTitle={dashboardTitle}
|
||||||
|
dashboardInfo={dashboardInfo}
|
||||||
|
saveType={SAVE_TYPE_NEWDASHBOARD}
|
||||||
|
layout={layout}
|
||||||
|
expandedSlices={expandedSlices}
|
||||||
|
refreshFrequency={refreshFrequency}
|
||||||
|
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
|
||||||
|
lastModifiedTime={lastModifiedTime}
|
||||||
|
customCss={customCss}
|
||||||
|
colorNamespace={colorNamespace}
|
||||||
|
colorScheme={colorScheme}
|
||||||
|
onSave={onSave}
|
||||||
|
triggerNode={
|
||||||
|
<div data-test="save-as-menu-item">{t('Save as')}</div>
|
||||||
|
}
|
||||||
|
canOverwrite={userCanEdit}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Download submenu
|
||||||
|
menuItems.push(downloadMenuItem);
|
||||||
|
|
||||||
|
// Share submenu
|
||||||
|
if (userCanShare) {
|
||||||
|
menuItems.push(shareMenuItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Embed dashboard
|
||||||
|
if (!editMode && userCanCurate) {
|
||||||
|
menuItems.push({
|
||||||
|
key: MenuKeys.ManageEmbedded,
|
||||||
|
label: t('Embed dashboard'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Divider
|
||||||
|
menuItems.push({ type: 'divider' });
|
||||||
|
|
||||||
|
// Report dropdown
|
||||||
|
if (!editMode && reportMenuItem) {
|
||||||
|
menuItems.push(reportMenuItem);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set filter mapping
|
||||||
|
if (editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes)) {
|
||||||
|
menuItems.push(
|
||||||
|
createModalMenuItem(
|
||||||
|
MenuKeys.SetFilterMapping,
|
||||||
|
<FilterScopeModal
|
||||||
|
triggerNode={<div>{t('Set filter mapping')}</div>}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-refresh interval
|
||||||
|
menuItems.push(
|
||||||
|
createModalMenuItem(
|
||||||
|
MenuKeys.AutorefreshModal,
|
||||||
|
<RefreshIntervalModal
|
||||||
|
addSuccessToast={addSuccessToast}
|
||||||
|
refreshFrequency={refreshFrequency}
|
||||||
|
refreshLimit={refreshLimit}
|
||||||
|
refreshWarning={refreshWarning}
|
||||||
|
onChange={changeRefreshInterval}
|
||||||
|
editMode={editMode}
|
||||||
|
refreshIntervalOptions={refreshIntervalOptions}
|
||||||
|
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu
|
<Menu
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="header-actions-menu"
|
data-test="header-actions-menu"
|
||||||
onClick={handleMenuClick}
|
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>
|
|
||||||
);
|
);
|
||||||
}, [
|
}, [
|
||||||
css,
|
addDangerToast,
|
||||||
showReportSubMenu,
|
addSuccessToast,
|
||||||
isDropdownVisible,
|
|
||||||
directPathToChild,
|
|
||||||
handleMenuClick,
|
|
||||||
changeCss,
|
|
||||||
changeRefreshInterval,
|
changeRefreshInterval,
|
||||||
emailSubject,
|
changeCss,
|
||||||
url,
|
colorNamespace,
|
||||||
dashboardComponentId,
|
colorScheme,
|
||||||
|
css,
|
||||||
|
customCss,
|
||||||
|
dashboardId,
|
||||||
|
dashboardInfo,
|
||||||
|
dashboardTitle,
|
||||||
|
downloadMenuItem,
|
||||||
|
editMode,
|
||||||
|
expandedSlices,
|
||||||
|
handleMenuClick,
|
||||||
|
isLoading,
|
||||||
|
lastModifiedTime,
|
||||||
|
layout,
|
||||||
|
onSave,
|
||||||
|
refreshFrequency,
|
||||||
|
refreshLimit,
|
||||||
|
refreshWarning,
|
||||||
|
reportMenuItem,
|
||||||
|
shareMenuItems,
|
||||||
|
shouldPersistRefreshFrequency,
|
||||||
|
userCanCurate,
|
||||||
|
userCanEdit,
|
||||||
|
userCanSave,
|
||||||
|
userCanShare,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [menu, isDropdownVisible, setIsDropdownVisible];
|
return [menu, isDropdownVisible, setIsDropdownVisible];
|
||||||
|
|||||||
@@ -438,10 +438,9 @@ describe('PropertiesModal', () => {
|
|||||||
const props = createProps();
|
const props = createProps();
|
||||||
const propsWithDashboardInfo = { ...props, dashboardInfo };
|
const propsWithDashboardInfo = { ...props, dashboardInfo };
|
||||||
|
|
||||||
const open = () => waitFor(() => userEvent.click(getSelect()));
|
|
||||||
const getSelect = () =>
|
const getSelect = () =>
|
||||||
screen.getByRole('combobox', { name: SupersetCore.t('Owners') });
|
screen.getByRole('combobox', { name: SupersetCore.t('Owners') });
|
||||||
|
const open = () => waitFor(() => userEvent.click(getSelect()));
|
||||||
const getElementsByClassName = (className: string) =>
|
const getElementsByClassName = (className: string) =>
|
||||||
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
|
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;
|
||||||
|
|
||||||
|
|||||||
@@ -41,20 +41,20 @@ import {
|
|||||||
QueryFormData,
|
QueryFormData,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import {
|
import {
|
||||||
NoAnimationDropdown,
|
NoAnimationDropdown,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
Button,
|
||||||
ModalTrigger,
|
ModalTrigger,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
|
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
|
||||||
import downloadAsImage from 'src/utils/downloadAsImage';
|
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||||
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
|
||||||
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
|
||||||
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
|
||||||
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
|
||||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||||
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
|
||||||
@@ -334,183 +334,199 @@ const SliceHeaderControls = (
|
|||||||
animationDuration: '0s',
|
animationDuration: '0s',
|
||||||
};
|
};
|
||||||
|
|
||||||
const menu = (
|
const newMenuItems: MenuItem[] = [
|
||||||
<Menu
|
{
|
||||||
onClick={handleMenuClick}
|
key: MenuKeys.ForceRefresh,
|
||||||
data-test={`slice_${slice.slice_id}-menu`}
|
label: (
|
||||||
id={`slice_${slice.slice_id}-menu`}
|
<>
|
||||||
selectable={false}
|
{t('Force refresh')}
|
||||||
>
|
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
|
||||||
<Menu.Item
|
{refreshTooltip}
|
||||||
key={MenuKeys.ForceRefresh}
|
</RefreshTooltip>
|
||||||
disabled={props.chartStatus === 'loading'}
|
</>
|
||||||
style={{ height: 'auto', lineHeight: 'initial' }}
|
),
|
||||||
data-test="refresh-chart-menu-item"
|
disabled: props.chartStatus === 'loading',
|
||||||
>
|
style: { height: 'auto', lineHeight: 'initial' },
|
||||||
{t('Force refresh')}
|
...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type
|
||||||
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
|
},
|
||||||
{refreshTooltip}
|
{
|
||||||
</RefreshTooltip>
|
key: MenuKeys.Fullscreen,
|
||||||
</Menu.Item>
|
label: fullscreenLabel,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'divider',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
<Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item>
|
if (slice.description) {
|
||||||
|
newMenuItems.push({
|
||||||
|
key: MenuKeys.ToggleChartDescription,
|
||||||
|
label: props.isDescriptionExpanded
|
||||||
|
? t('Hide chart description')
|
||||||
|
: t('Show chart description'),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
<Menu.Divider />
|
if (canExplore) {
|
||||||
|
newMenuItems.push({
|
||||||
|
key: MenuKeys.ExploreChart,
|
||||||
|
label: (
|
||||||
|
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
|
||||||
|
{t('Edit chart')}
|
||||||
|
</Tooltip>
|
||||||
|
),
|
||||||
|
...{ 'data-test-edit-chart-name': slice.slice_name },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{slice.description && (
|
if (canEditCrossFilters) {
|
||||||
<Menu.Item key={MenuKeys.ToggleChartDescription}>
|
newMenuItems.push({
|
||||||
{props.isDescriptionExpanded
|
key: MenuKeys.CrossFilterScoping,
|
||||||
? t('Hide chart description')
|
label: t('Cross-filtering scoping'),
|
||||||
: t('Show chart description')}
|
});
|
||||||
</Menu.Item>
|
}
|
||||||
)}
|
|
||||||
|
|
||||||
{canExplore && (
|
if (canExplore || canEditCrossFilters) {
|
||||||
<Menu.Item
|
newMenuItems.push({ type: 'divider' });
|
||||||
key={MenuKeys.ExploreChart}
|
}
|
||||||
data-test-edit-chart-name={slice.slice_name}
|
|
||||||
>
|
|
||||||
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
|
|
||||||
{t('Edit chart')}
|
|
||||||
</Tooltip>
|
|
||||||
</Menu.Item>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{canEditCrossFilters && (
|
if (canExplore || canViewQuery) {
|
||||||
<Menu.Item key={MenuKeys.CrossFilterScoping}>
|
newMenuItems.push({
|
||||||
{t('Cross-filtering scoping')}
|
key: MenuKeys.ViewQuery,
|
||||||
</Menu.Item>
|
label: (
|
||||||
)}
|
<ModalTrigger
|
||||||
|
triggerNode={
|
||||||
{(canExplore || canEditCrossFilters) && <Menu.Divider />}
|
<div data-test="view-query-menu-item">{t('View query')}</div>
|
||||||
|
}
|
||||||
{(canExplore || canViewQuery) && (
|
modalTitle={t('View query')}
|
||||||
<Menu.Item key={MenuKeys.ViewQuery}>
|
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
|
||||||
<ModalTrigger
|
draggable
|
||||||
triggerNode={
|
resizable
|
||||||
<div data-test="view-query-menu-item">{t('View query')}</div>
|
responsive
|
||||||
}
|
ref={queryMenuRef}
|
||||||
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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{(slice.description || canExplore) && <Menu.Divider />}
|
if (canExplore || canViewTable) {
|
||||||
|
newMenuItems.push({
|
||||||
{supersetCanShare && (
|
key: MenuKeys.ViewResults,
|
||||||
<ShareMenuItems
|
label: (
|
||||||
dashboardId={dashboardId}
|
<ViewResultsModalTrigger
|
||||||
dashboardComponentId={componentId}
|
canExplore={props.supersetCanExplore}
|
||||||
copyMenuItemTitle={t('Copy permalink to clipboard')}
|
exploreUrl={props.exploreUrl}
|
||||||
emailMenuItemTitle={t('Share chart by email')}
|
triggerNode={
|
||||||
emailSubject={t('Superset chart')}
|
<div data-test="view-query-menu-item">{t('View as table')}</div>
|
||||||
emailBody={t('Check out this chart: ')}
|
}
|
||||||
addSuccessToast={addSuccessToast}
|
modalRef={resultsMenuRef}
|
||||||
addDangerToast={addDangerToast}
|
modalTitle={t('Chart Data: %s', slice.slice_name)}
|
||||||
title={t('Share')}
|
modalBody={
|
||||||
|
<ResultsPaneOnDashboard
|
||||||
|
queryFormData={props.formData}
|
||||||
|
queryForce={false}
|
||||||
|
dataSize={20}
|
||||||
|
isRequest
|
||||||
|
isVisible
|
||||||
|
canDownload={!!props.supersetCanCSV}
|
||||||
|
/>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
{props.supersetCanCSV && (
|
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
|
||||||
<Menu.SubMenu title={t('Download')} key={MenuKeys.Download}>
|
useDrillDetailMenuItems({
|
||||||
<Menu.Item
|
formData: props.formData,
|
||||||
key={MenuKeys.ExportCsv}
|
filters: modalFilters,
|
||||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
setFilters,
|
||||||
>
|
setShowModal: setDrillModalIsOpen,
|
||||||
{t('Export to .CSV')}
|
key: MenuKeys.DrillToDetail,
|
||||||
</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>
|
|
||||||
|
|
||||||
{isPivotTable && (
|
const shareMenuItems = useShareMenuItems({
|
||||||
<Menu.Item
|
dashboardId,
|
||||||
key={MenuKeys.ExportPivotXlsx}
|
dashboardComponentId: componentId,
|
||||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
copyMenuItemTitle: t('Copy permalink to clipboard'),
|
||||||
>
|
emailMenuItemTitle: t('Share chart by email'),
|
||||||
{t('Export to Pivoted Excel')}
|
emailSubject: t('Superset chart'),
|
||||||
</Menu.Item>
|
emailBody: t('Check out this chart: '),
|
||||||
)}
|
addSuccessToast,
|
||||||
|
addDangerToast,
|
||||||
|
title: t('Share'),
|
||||||
|
});
|
||||||
|
|
||||||
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
||||||
props.supersetCanCSV &&
|
newMenuItems.push(drillToDetailMenuItem);
|
||||||
isTable && (
|
if (drillToDetailByMenuItem) {
|
||||||
<>
|
newMenuItems.push(drillToDetailByMenuItem);
|
||||||
<Menu.Item
|
}
|
||||||
key={MenuKeys.ExportFullCsv}
|
}
|
||||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
|
||||||
>
|
if (slice.description || canExplore) {
|
||||||
{t('Export to full .CSV')}
|
newMenuItems.push({ type: 'divider' });
|
||||||
</Menu.Item>
|
}
|
||||||
<Menu.Item
|
|
||||||
key={MenuKeys.ExportFullXlsx}
|
if (supersetCanShare) {
|
||||||
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
|
newMenuItems.push(shareMenuItems);
|
||||||
>
|
}
|
||||||
{t('Export to full Excel')}
|
|
||||||
</Menu.Item>
|
if (props.supersetCanCSV) {
|
||||||
</>
|
newMenuItems.push({
|
||||||
)}
|
type: 'submenu',
|
||||||
|
key: MenuKeys.Download,
|
||||||
|
label: t('Download'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportCsv,
|
||||||
|
label: t('Export to .CSV'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
...(isPivotTable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportPivotCsv,
|
||||||
|
label: t('Export to Pivoted .CSV'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportPivotXlsx,
|
||||||
|
label: t('Export to Pivoted Excel'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportXlsx,
|
||||||
|
label: t('Export to Excel'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
|
||||||
|
props.supersetCanCSV &&
|
||||||
|
isTable
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportFullCsv,
|
||||||
|
label: t('Export to full .CSV'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: MenuKeys.ExportFullXlsx,
|
||||||
|
label: t('Export to full Excel'),
|
||||||
|
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
{
|
||||||
|
key: MenuKeys.DownloadAsImage,
|
||||||
|
label: t('Download as image'),
|
||||||
|
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
<Menu.Item
|
|
||||||
key={MenuKeys.DownloadAsImage}
|
|
||||||
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
|
|
||||||
>
|
|
||||||
{t('Download as image')}
|
|
||||||
</Menu.Item>
|
|
||||||
</Menu.SubMenu>
|
|
||||||
)}
|
|
||||||
</Menu>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isFullSize && (
|
{isFullSize && (
|
||||||
@@ -522,7 +538,15 @@ const SliceHeaderControls = (
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<NoAnimationDropdown
|
<NoAnimationDropdown
|
||||||
popupRender={() => menu}
|
popupRender={() => (
|
||||||
|
<Menu
|
||||||
|
onClick={handleMenuClick}
|
||||||
|
data-test={`slice_${slice.slice_id}-menu`}
|
||||||
|
id={`slice_${slice.slice_id}-menu`}
|
||||||
|
selectable={false}
|
||||||
|
items={newMenuItems}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
overlayStyle={dropdownOverlayStyle}
|
overlayStyle={dropdownOverlayStyle}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
|
|||||||
+10
-12
@@ -17,8 +17,8 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { render, screen } from 'spec/helpers/testing-library';
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import DownloadMenuItems from '.';
|
import { useDownloadMenuItems } from '.';
|
||||||
|
|
||||||
const createProps = () => ({
|
const createProps = () => ({
|
||||||
pdfMenuItemTitle: 'Export to PDF',
|
pdfMenuItemTitle: 'Export to PDF',
|
||||||
@@ -30,19 +30,17 @@ const createProps = () => ({
|
|||||||
submenuKey: 'download',
|
submenuKey: 'download',
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderComponent = () => {
|
const MenuWrapper = () => {
|
||||||
render(
|
const downloadMenuItem = useDownloadMenuItems(createProps());
|
||||||
<Menu forceSubMenuRender>
|
const menuItems: MenuItem[] = [downloadMenuItem];
|
||||||
<DownloadMenuItems {...createProps()} />
|
return <Menu forceSubMenuRender items={menuItems} />;
|
||||||
</Menu>,
|
|
||||||
{
|
|
||||||
useRedux: true,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
test('Should render menu items', () => {
|
test('Should render menu items', () => {
|
||||||
renderComponent();
|
render(<MenuWrapper />, {
|
||||||
|
useRedux: true,
|
||||||
|
});
|
||||||
|
|
||||||
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
|
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Download as Image')).toBeInTheDocument();
|
expect(screen.getByText('Download as Image')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -16,16 +16,21 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
|
import { SyntheticEvent } from 'react';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core';
|
||||||
|
import { MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
|
||||||
import { ComponentProps } from 'react';
|
import { MenuKeys } from 'src/dashboard/types';
|
||||||
|
import downloadAsPdf from 'src/utils/downloadAsPdf';
|
||||||
|
import downloadAsImage from 'src/utils/downloadAsImage';
|
||||||
|
import {
|
||||||
|
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
|
||||||
|
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
|
||||||
|
} from 'src/logger/LogUtils';
|
||||||
|
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||||
import { DownloadScreenshotFormat } from './types';
|
import { DownloadScreenshotFormat } from './types';
|
||||||
import DownloadAsPdf from './DownloadAsPdf';
|
|
||||||
import DownloadAsImage from './DownloadAsImage';
|
|
||||||
|
|
||||||
export interface DownloadMenuItemProps
|
export interface UseDownloadMenuItemsProps {
|
||||||
extends ComponentProps<typeof Menu.SubMenu> {
|
|
||||||
pdfMenuItemTitle: string;
|
pdfMenuItemTitle: string;
|
||||||
imageMenuItemTitle: string;
|
imageMenuItemTitle: string;
|
||||||
dashboardTitle: string;
|
dashboardTitle: string;
|
||||||
@@ -33,56 +38,81 @@ export interface DownloadMenuItemProps
|
|||||||
dashboardId: number;
|
dashboardId: number;
|
||||||
title: string;
|
title: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
submenuKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DownloadMenuItems = (props: DownloadMenuItemProps) => {
|
export const useDownloadMenuItems = (
|
||||||
|
props: UseDownloadMenuItemsProps,
|
||||||
|
): MenuItem => {
|
||||||
const {
|
const {
|
||||||
pdfMenuItemTitle,
|
pdfMenuItemTitle,
|
||||||
imageMenuItemTitle,
|
imageMenuItemTitle,
|
||||||
logEvent,
|
logEvent,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
dashboardTitle,
|
dashboardTitle,
|
||||||
submenuKey,
|
|
||||||
disabled,
|
disabled,
|
||||||
title,
|
title,
|
||||||
...rest
|
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
|
const { addDangerToast } = useToasts();
|
||||||
|
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
|
||||||
|
|
||||||
const isWebDriverScreenshotEnabled =
|
const isWebDriverScreenshotEnabled =
|
||||||
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
|
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
|
||||||
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
|
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
|
||||||
|
|
||||||
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
|
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
|
||||||
|
|
||||||
return isWebDriverScreenshotEnabled ? (
|
const onDownloadPdf = async (e: SyntheticEvent) => {
|
||||||
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
|
try {
|
||||||
<Menu.Item
|
downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
|
||||||
key={DownloadScreenshotFormat.PDF}
|
} catch (error) {
|
||||||
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PDF)}
|
logging.error(error);
|
||||||
>
|
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||||
{pdfMenuItemTitle}
|
}
|
||||||
</Menu.Item>
|
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF);
|
||||||
<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;
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
+26
-23
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import {
|
import {
|
||||||
render,
|
render,
|
||||||
screen,
|
screen,
|
||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
} from 'spec/helpers/testing-library';
|
} from 'spec/helpers/testing-library';
|
||||||
import * as copyTextToClipboard from 'src/utils/copy';
|
import * as copyTextToClipboard from 'src/utils/copy';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import ShareMenuItems from '.';
|
import { ComponentProps } from 'react';
|
||||||
|
import { useShareMenuItems, ShareMenuItemProps } from '.';
|
||||||
|
|
||||||
const spy = jest.spyOn(copyTextToClipboard, 'default');
|
const spy = jest.spyOn(copyTextToClipboard, 'default');
|
||||||
|
|
||||||
@@ -69,17 +70,23 @@ afterAll((): void => {
|
|||||||
window.location = location;
|
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', () => {
|
test('Should render menu items', () => {
|
||||||
const props = createProps();
|
|
||||||
render(
|
render(
|
||||||
<Menu
|
<MenuWrapper
|
||||||
onClick={jest.fn()}
|
onClick={jest.fn()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="main-menu"
|
data-test="main-menu"
|
||||||
forceSubMenuRender
|
forceSubMenuRender
|
||||||
>
|
shareProps={createProps()}
|
||||||
<ShareMenuItems {...props} />
|
/>,
|
||||||
</Menu>,
|
|
||||||
{ useRedux: true },
|
{ useRedux: true },
|
||||||
);
|
);
|
||||||
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
|
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
|
||||||
@@ -90,14 +97,13 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
|
|||||||
spy.mockResolvedValue(undefined);
|
spy.mockResolvedValue(undefined);
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(
|
render(
|
||||||
<Menu
|
<MenuWrapper
|
||||||
onClick={jest.fn()}
|
onClick={jest.fn()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="main-menu"
|
data-test="main-menu"
|
||||||
forceSubMenuRender
|
forceSubMenuRender
|
||||||
>
|
shareProps={props}
|
||||||
<ShareMenuItems {...props} />
|
/>,
|
||||||
</Menu>,
|
|
||||||
{ useRedux: true },
|
{ useRedux: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,14 +129,13 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
|||||||
spy.mockRejectedValue(undefined);
|
spy.mockRejectedValue(undefined);
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(
|
render(
|
||||||
<Menu
|
<MenuWrapper
|
||||||
onClick={jest.fn()}
|
onClick={jest.fn()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="main-menu"
|
data-test="main-menu"
|
||||||
forceSubMenuRender
|
forceSubMenuRender
|
||||||
>
|
shareProps={props}
|
||||||
<ShareMenuItems {...props} />
|
/>,
|
||||||
</Menu>,
|
|
||||||
{ useRedux: true },
|
{ useRedux: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -157,14 +162,13 @@ test('Click on "Copy dashboard URL" and fail', async () => {
|
|||||||
test('Click on "Share dashboard by email" and succeed', async () => {
|
test('Click on "Share dashboard by email" and succeed', async () => {
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(
|
render(
|
||||||
<Menu
|
<MenuWrapper
|
||||||
onClick={jest.fn()}
|
onClick={jest.fn()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="main-menu"
|
data-test="main-menu"
|
||||||
forceSubMenuRender
|
forceSubMenuRender
|
||||||
>
|
shareProps={props}
|
||||||
<ShareMenuItems {...props} />
|
/>,
|
||||||
</Menu>,
|
|
||||||
{ useRedux: true },
|
{ useRedux: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -191,14 +195,13 @@ test('Click on "Share dashboard by email" and fail', async () => {
|
|||||||
);
|
);
|
||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(
|
render(
|
||||||
<Menu
|
<MenuWrapper
|
||||||
onClick={jest.fn()}
|
onClick={jest.fn()}
|
||||||
selectable={false}
|
selectable={false}
|
||||||
data-test="main-menu"
|
data-test="main-menu"
|
||||||
forceSubMenuRender
|
forceSubMenuRender
|
||||||
>
|
shareProps={props}
|
||||||
<ShareMenuItems {...props} />
|
/>,
|
||||||
</Menu>,
|
|
||||||
{ useRedux: true },
|
{ useRedux: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -19,12 +19,13 @@
|
|||||||
import { ComponentProps, RefObject } from 'react';
|
import { ComponentProps, RefObject } from 'react';
|
||||||
import copyTextToClipboard from 'src/utils/copy';
|
import copyTextToClipboard from 'src/utils/copy';
|
||||||
import { t, logging } from '@superset-ui/core';
|
import { t, logging } from '@superset-ui/core';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
|
||||||
import { getDashboardPermalink } from 'src/utils/urlUtils';
|
import { getDashboardPermalink } from 'src/utils/urlUtils';
|
||||||
import { MenuKeys, RootState } from 'src/dashboard/types';
|
import { MenuKeys, RootState } from 'src/dashboard/types';
|
||||||
import { shallowEqual, useSelector } from 'react-redux';
|
import { shallowEqual, useSelector } from 'react-redux';
|
||||||
|
|
||||||
interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
|
export interface ShareMenuItemProps
|
||||||
|
extends ComponentProps<typeof Menu.SubMenu> {
|
||||||
url?: string;
|
url?: string;
|
||||||
copyMenuItemTitle: string;
|
copyMenuItemTitle: string;
|
||||||
emailMenuItemTitle: string;
|
emailMenuItemTitle: string;
|
||||||
@@ -40,9 +41,10 @@ interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
|
|||||||
setOpenKeys?: Function;
|
setOpenKeys?: Function;
|
||||||
title: string;
|
title: string;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
[key: string]: any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ShareMenuItems = (props: ShareMenuItemProps) => {
|
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
|
||||||
const {
|
const {
|
||||||
copyMenuItemTitle,
|
copyMenuItemTitle,
|
||||||
emailMenuItemTitle,
|
emailMenuItemTitle,
|
||||||
@@ -96,20 +98,23 @@ const ShareMenuItems = (props: ShareMenuItemProps) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return {
|
||||||
<Menu.SubMenu
|
...rest,
|
||||||
title={title}
|
type: 'submenu',
|
||||||
key={MenuKeys.Share}
|
label: title,
|
||||||
disabled={disabled}
|
key: MenuKeys.Share,
|
||||||
{...rest}
|
disabled,
|
||||||
>
|
children: [
|
||||||
<Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}>
|
{
|
||||||
{copyMenuItemTitle}
|
key: MenuKeys.CopyLink,
|
||||||
</Menu.Item>
|
label: copyMenuItemTitle,
|
||||||
<Menu.Item key={MenuKeys.ShareByEmail} onClick={() => onShareByEmail()}>
|
onClick: onCopyLink,
|
||||||
{emailMenuItemTitle}
|
},
|
||||||
</Menu.Item>
|
{
|
||||||
</Menu.SubMenu>
|
key: MenuKeys.ShareByEmail,
|
||||||
);
|
label: emailMenuItemTitle,
|
||||||
|
onClick: onShareByEmail,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
};
|
};
|
||||||
export default ShareMenuItems;
|
|
||||||
|
|||||||
+29
@@ -155,6 +155,34 @@ const FilterValue: FC<FilterControlProps> = ({
|
|||||||
dashboardId,
|
dashboardId,
|
||||||
});
|
});
|
||||||
const filterOwnState = filter.dataMask?.ownState || {};
|
const filterOwnState = filter.dataMask?.ownState || {};
|
||||||
|
if (filter?.cascadeParentIds?.length) {
|
||||||
|
// Prevent unnecessary backend requests by validating parent filter selections first
|
||||||
|
|
||||||
|
let selectedParentFilterValueCounts = 0;
|
||||||
|
|
||||||
|
filter?.cascadeParentIds?.forEach(pId => {
|
||||||
|
const extraFormData = dataMaskSelected?.[pId]?.extraFormData;
|
||||||
|
if (extraFormData?.filters?.length) {
|
||||||
|
selectedParentFilterValueCounts += extraFormData.filters.length;
|
||||||
|
} else if (extraFormData?.time_range) {
|
||||||
|
selectedParentFilterValueCounts += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// check if all parent filters with defaults have a value selected
|
||||||
|
|
||||||
|
let depsCount = dependencies.filters?.length ?? 0;
|
||||||
|
|
||||||
|
if (dependencies?.time_range) {
|
||||||
|
depsCount += 1;
|
||||||
|
}
|
||||||
|
if (selectedParentFilterValueCounts !== depsCount) {
|
||||||
|
// child filter should not request backend until it
|
||||||
|
// has all the required information from parent filters
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: We should try to improve our useEffect hooks to depend more on
|
// TODO: We should try to improve our useEffect hooks to depend more on
|
||||||
// granular information instead of big objects that require deep comparison.
|
// granular information instead of big objects that require deep comparison.
|
||||||
const customizer = (
|
const customizer = (
|
||||||
@@ -226,6 +254,7 @@ const FilterValue: FC<FilterControlProps> = ({
|
|||||||
hasDataSource,
|
hasDataSource,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
shouldRefresh,
|
shouldRefresh,
|
||||||
|
dataMaskSelected,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
+3
-3
@@ -96,14 +96,14 @@ test('remove filter', async () => {
|
|||||||
test('add filter', async () => {
|
test('add filter', async () => {
|
||||||
defaultRender();
|
defaultRender();
|
||||||
// First trash icon
|
// First trash icon
|
||||||
const addFilterButton = await screen.findByText('Add Filter');
|
const addFilterButton = await screen.findByText('Add filter');
|
||||||
userEvent.click(addFilterButton);
|
userEvent.click(addFilterButton);
|
||||||
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
|
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('add divider', async () => {
|
test('add divider', async () => {
|
||||||
defaultRender();
|
defaultRender();
|
||||||
const addFilterButton = await screen.findByText('Add Divider');
|
const addFilterButton = await screen.findByText('Add divider');
|
||||||
userEvent.click(addFilterButton);
|
userEvent.click(addFilterButton);
|
||||||
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
|
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
|
||||||
});
|
});
|
||||||
@@ -128,7 +128,7 @@ test('filter container should scroll to bottom when adding items', async () => {
|
|||||||
|
|
||||||
defaultRender(state, props);
|
defaultRender(state, props);
|
||||||
|
|
||||||
const addFilterButton = await screen.findByText('Add Filter');
|
const addFilterButton = await screen.findByText('Add filter');
|
||||||
|
|
||||||
userEvent.click(addFilterButton);
|
userEvent.click(addFilterButton);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -111,7 +111,7 @@ const FilterTitlePane: FC<Props> = ({
|
|||||||
data-test="add-new-filter-button"
|
data-test="add-new-filter-button"
|
||||||
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
|
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
|
||||||
>
|
>
|
||||||
{t('Add Filter')}
|
{t('Add filter')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
buttonSize="default"
|
buttonSize="default"
|
||||||
@@ -125,7 +125,7 @@ const FilterTitlePane: FC<Props> = ({
|
|||||||
data-test="add-new-divider-button"
|
data-test="add-new-divider-button"
|
||||||
onClick={() => handleOnAdd(NativeFilterType.Divider)}
|
onClick={() => handleOnAdd(NativeFilterType.Divider)}
|
||||||
>
|
>
|
||||||
{t('Add Divider')}
|
{t('Add divider')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContainer>
|
</TabsContainer>
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
*/
|
*/
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { t } from '@superset-ui/core';
|
import { t, logging } from '@superset-ui/core';
|
||||||
import { Charts, Layout, RootState, Slice } from 'src/dashboard/types';
|
import { Charts, Layout, RootState, Slice } from 'src/dashboard/types';
|
||||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||||
import {
|
import {
|
||||||
@@ -46,7 +46,7 @@ export function useFilterScopeTree(
|
|||||||
|
|
||||||
const sliceEntities = useSelector<RootState, Slice>(state => {
|
const sliceEntities = useSelector<RootState, Slice>(state => {
|
||||||
if (!state.sliceEntities) {
|
if (!state.sliceEntities) {
|
||||||
console.warn('sliceEntities not found in state');
|
logging.warn('sliceEntities not found in state');
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
return state.sliceEntities.slices || {};
|
return state.sliceEntities.slices || {};
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user