mirror of
https://github.com/apache/superset.git
synced 2026-07-03 05:15:35 +00:00
Compare commits
172 Commits
codespaces
...
elizabeth/
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c6756d9ac5 | ||
|
|
ff1f7b64e2 | ||
|
|
63bb1d55a4 | ||
|
|
c568d463b9 | ||
|
|
179a6f2cfe | ||
|
|
695a20d009 | ||
|
|
e908775fb2 | ||
|
|
af05396227 | ||
|
|
277f03c207 | ||
|
|
48699a7194 | ||
|
|
b7d076bfee | ||
|
|
009b99bfbb | ||
|
|
b45141b2a1 | ||
|
|
4683a0827d | ||
|
|
ffb617a4c8 | ||
|
|
9de1706baa | ||
|
|
a95566f114 | ||
|
|
a82e310600 | ||
|
|
691926f0e1 | ||
|
|
a42185cd3b | ||
|
|
89eb7b207c | ||
|
|
f99022b242 | ||
|
|
f8b9e3ace4 | ||
|
|
9cbe5a90b8 | ||
|
|
f7fe617f4c | ||
|
|
e6c8343fd0 | ||
|
|
6969f2cf7a | ||
|
|
852adaa6cc | ||
|
|
1f482b42eb | ||
|
|
31e2143c84 | ||
|
|
b89e0d74be | ||
|
|
1127ab6c07 | ||
|
|
8d210fc7b8 | ||
|
|
8acb2fb700 | ||
|
|
a3cbc9755f | ||
|
|
28788fd1fa | ||
|
|
21790814db | ||
|
|
ff6dc03ddf | ||
|
|
fbcdf6909c | ||
|
|
fc95c4fc89 | ||
|
|
3a007f6284 | ||
|
|
2403d8d584 | ||
|
|
47874318df | ||
|
|
f6353bd1e8 | ||
|
|
1101182654 | ||
|
|
d79fc92a1a | ||
|
|
bce476c4a2 | ||
|
|
ecfb9f7d7c | ||
|
|
58ebc57285 | ||
|
|
1a57e50bd6 | ||
|
|
f3884a2db8 | ||
|
|
cb899f691b | ||
|
|
b25722ee2b | ||
|
|
34e10f5972 | ||
|
|
e88096f75c | ||
|
|
6d827cf905 | ||
|
|
ab13166e41 | ||
|
|
89f09ea57c | ||
|
|
baec438be9 | ||
|
|
5309edf3a5 | ||
|
|
f50cbd7958 | ||
|
|
2465ab4a98 | ||
|
|
1947d4da76 | ||
|
|
e452f5b70d | ||
|
|
698de7a38d | ||
|
|
e2a9f2dead | ||
|
|
1f80725b0e | ||
|
|
c3cb5c7e99 | ||
|
|
f7dd0659bf | ||
|
|
3c17ff8445 | ||
|
|
57d0e78d40 | ||
|
|
ae986903b3 | ||
|
|
6964f9bdbf | ||
|
|
9efa9898ff | ||
|
|
22b44421a4 | ||
|
|
02924b3c74 | ||
|
|
99539c786e | ||
|
|
5e621ceb34 | ||
|
|
370a24da81 | ||
|
|
732506b3fa | ||
|
|
1af9c8dba2 | ||
|
|
1dc22a9002 | ||
|
|
ad592c717e | ||
|
|
38e15196f2 | ||
|
|
8131c24acd | ||
|
|
952b620465 | ||
|
|
f3e3bd0348 | ||
|
|
1e1310dbd8 | ||
|
|
adaae8ba15 | ||
|
|
a66b7e98e0 | ||
|
|
3e12d97e8e | ||
|
|
00304f77e1 | ||
|
|
e88db9f403 | ||
|
|
53e9cf6d17 | ||
|
|
5a004590e0 | ||
|
|
53503e32ae | ||
|
|
246181a546 | ||
|
|
6f5d9c989a | ||
|
|
8515792b04 | ||
|
|
923b2b1d77 | ||
|
|
486b0122d0 | ||
|
|
ae090fa74c | ||
|
|
35ec6d308a | ||
|
|
c62a6f5cee | ||
|
|
cdd140b3cc | ||
|
|
09cf49c2ba | ||
|
|
ac4b4c7646 | ||
|
|
d0a6c78966 | ||
|
|
65f2071aa4 | ||
|
|
e8f37a3f89 | ||
|
|
19d229ea12 | ||
|
|
622a62d7a1 | ||
|
|
4a556f4ac4 | ||
|
|
7a1839ba1b | ||
|
|
8f2afb8f4d | ||
|
|
02586981da | ||
|
|
8700a0b939 | ||
|
|
d843fef2ce | ||
|
|
f45654c03c | ||
|
|
761daec53d | ||
|
|
407fb67f1e | ||
|
|
49689eec6c | ||
|
|
791ea9860d | ||
|
|
2f8939d229 | ||
|
|
ccf6290120 | ||
|
|
96a1aa60e8 | ||
|
|
2ea0368c2d | ||
|
|
9e407e4e80 | ||
|
|
360e58c181 | ||
|
|
22d5eb7835 | ||
|
|
7c4a77a909 | ||
|
|
4e209e51d0 | ||
|
|
7191ae55c8 | ||
|
|
17725ebc83 | ||
|
|
1a7a381bd5 | ||
|
|
daf207e5c2 | ||
|
|
72294c569f | ||
|
|
792dd08d38 | ||
|
|
1e40e7d02b | ||
|
|
7e98c75f01 | ||
|
|
b18de05ea4 | ||
|
|
9300652277 | ||
|
|
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 |
20
.devcontainer/Dockerfile
Normal file
20
.devcontainer/Dockerfile
Normal file
@@ -0,0 +1,20 @@
|
||||
# Keep this in sync with the base image in the main Dockerfile (ARG PY_VER)
|
||||
FROM python:3.11.13-bookworm AS base
|
||||
|
||||
# Install system dependencies that Superset needs
|
||||
# This layer will be cached across Codespace sessions
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Install uv for fast Python package management
|
||||
# This will also be cached in the image
|
||||
RUN curl -LsSf https://astral.sh/uv/install.sh | sh && \
|
||||
echo 'export PATH="/root/.cargo/bin:$PATH"' >> /etc/bash.bashrc
|
||||
|
||||
# Set the cargo/bin directory in PATH for all users
|
||||
ENV PATH="/root/.cargo/bin:${PATH}"
|
||||
16
.devcontainer/README.md
Normal file
16
.devcontainer/README.md
Normal file
@@ -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.
|
||||
62
.devcontainer/bashrc-additions
Normal file
62
.devcontainer/bashrc-additions
Normal file
@@ -0,0 +1,62 @@
|
||||
# Superset Codespaces environment setup
|
||||
# This file is appended to ~/.bashrc during Codespace setup
|
||||
|
||||
# Find the workspace directory (handles both 'superset' and 'superset-2' names)
|
||||
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
|
||||
|
||||
if [ -n "$WORKSPACE_DIR" ]; then
|
||||
# Check if virtual environment exists
|
||||
if [ -d "$WORKSPACE_DIR/.venv" ]; then
|
||||
# Activate the virtual environment
|
||||
source "$WORKSPACE_DIR/.venv/bin/activate"
|
||||
echo "✅ Python virtual environment activated"
|
||||
|
||||
# Verify pre-commit is installed and set up
|
||||
if command -v pre-commit &> /dev/null; then
|
||||
echo "✅ pre-commit is available ($(pre-commit --version))"
|
||||
# Install git hooks if not already installed
|
||||
if [ -d "$WORKSPACE_DIR/.git" ] && [ ! -f "$WORKSPACE_DIR/.git/hooks/pre-commit" ]; then
|
||||
echo "🪝 Installing pre-commit hooks..."
|
||||
cd "$WORKSPACE_DIR" && pre-commit install
|
||||
fi
|
||||
else
|
||||
echo "⚠️ pre-commit not found. Run: pip install pre-commit"
|
||||
fi
|
||||
else
|
||||
echo "⚠️ Python virtual environment not found at $WORKSPACE_DIR/.venv"
|
||||
echo " Run: cd $WORKSPACE_DIR && .devcontainer/setup-dev.sh"
|
||||
fi
|
||||
|
||||
# Always cd to the workspace directory for convenience
|
||||
cd "$WORKSPACE_DIR"
|
||||
fi
|
||||
|
||||
# Add helpful aliases for Superset development
|
||||
alias start-superset="$WORKSPACE_DIR/.devcontainer/start-superset.sh"
|
||||
alias setup-dev="$WORKSPACE_DIR/.devcontainer/setup-dev.sh"
|
||||
|
||||
# Show helpful message on login
|
||||
echo ""
|
||||
echo "🚀 Superset Codespaces Environment"
|
||||
echo "=================================="
|
||||
|
||||
# Check if Superset is running
|
||||
if docker ps 2>/dev/null | grep -q "superset"; then
|
||||
echo "✅ Superset is running!"
|
||||
echo " - Check the 'Ports' tab for your live Superset URL"
|
||||
echo " - Initial startup takes 10-20 minutes"
|
||||
echo " - Login: admin/admin"
|
||||
else
|
||||
echo "⚠️ Superset is not running. Use: start-superset"
|
||||
# Check if there's a startup log
|
||||
if [ -f "/tmp/superset-startup.log" ]; then
|
||||
echo " 📋 Startup log found: cat /tmp/superset-startup.log"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "Quick commands:"
|
||||
echo " start-superset - Start Superset with Docker Compose"
|
||||
echo " setup-dev - Set up Python environment (if not already done)"
|
||||
echo " pre-commit run - Run pre-commit checks on staged files"
|
||||
echo ""
|
||||
20
.devcontainer/build-and-push-image.sh
Executable file
20
.devcontainer/build-and-push-image.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
# Script to build and push the devcontainer image to GitHub Container Registry
|
||||
# This allows caching the image between Codespace sessions
|
||||
|
||||
# You'll need to run this with appropriate GitHub permissions
|
||||
# gh auth login --scopes write:packages
|
||||
|
||||
REGISTRY="ghcr.io"
|
||||
OWNER="apache"
|
||||
REPO="superset"
|
||||
TAG="devcontainer-base"
|
||||
|
||||
echo "Building devcontainer image..."
|
||||
docker build -t $REGISTRY/$OWNER/$REPO:$TAG .devcontainer/
|
||||
|
||||
echo "Pushing to GitHub Container Registry..."
|
||||
docker push $REGISTRY/$OWNER/$REPO:$TAG
|
||||
|
||||
echo "Done! Update .devcontainer/devcontainer.json to use:"
|
||||
echo " \"image\": \"$REGISTRY/$OWNER/$REPO:$TAG\""
|
||||
66
.devcontainer/devcontainer.json
Normal file
66
.devcontainer/devcontainer.json
Normal file
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
78
.devcontainer/setup-dev.sh
Executable file
78
.devcontainer/setup-dev.sh
Executable file
@@ -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 ""
|
||||
108
.devcontainer/start-superset.sh
Executable file
108
.devcontainer/start-superset.sh
Executable file
@@ -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
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,7 +2,7 @@
|
||||
|
||||
# https://github.com/apache/superset/issues/13351
|
||||
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho
|
||||
/superset/migrations/ @mistercrunch @michael-s-molina @betodealmeida @eschutho @sadpandajoe
|
||||
|
||||
# Notify some committers of changes in the components
|
||||
|
||||
|
||||
2
.github/workflows/welcome-new-users.yml
vendored
2
.github/workflows/welcome-new-users.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Welcome Message
|
||||
uses: actions/first-interaction@v1
|
||||
uses: actions/first-interaction@v2
|
||||
continue-on-error: true
|
||||
with:
|
||||
repo-token: ${{ github.token }}
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -130,4 +130,7 @@ superset/static/stats/statistics.html
|
||||
|
||||
# LLM-related
|
||||
CLAUDE.local.md
|
||||
PROJECT.md
|
||||
.aider*
|
||||
.claude_rc*
|
||||
.env.local
|
||||
|
||||
@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
|
||||
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
|
||||
|
||||
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
|
||||
# Copy translation files
|
||||
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 \
|
||||
npm run build-translation; \
|
||||
fi; \
|
||||
|
||||
21
LLMS.md
21
LLMS.md
@@ -16,6 +16,7 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
|
||||
- **Prefer integration tests** over Cypress end-to-end tests
|
||||
- **Cypress is last resort** - Actively moving away from Cypress
|
||||
- **Use Jest + React Testing Library** for component testing
|
||||
- **Use `test()` instead of `describe()`** - Follow [avoid nesting when testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) principles
|
||||
|
||||
### Backend Type Safety
|
||||
- **Add type hints** - All new Python code needs proper typing
|
||||
@@ -186,6 +187,26 @@ pre-commit run eslint # Frontend linting
|
||||
- **[GPT.md](GPT.md)** - For OpenAI/ChatGPT tools
|
||||
- **[.cursor/rules/dev-standard.mdc](.cursor/rules/dev-standard.mdc)** - For Cursor editor
|
||||
|
||||
## Local Configuration
|
||||
|
||||
### Feature Flags & Settings
|
||||
Always use git-ignored config files instead of modifying core files:
|
||||
|
||||
**For Docker deployments** (check with `docker ps` or `docker-compose ps`):
|
||||
- **Config file**: `docker/pythonpath_dev/superset_config_docker.py` (auto-imported)
|
||||
- **Pattern**: Copy `superset_config_local.example` to `superset_config_docker.py`
|
||||
- **Git status**: Ignored by `docker/*local*` pattern
|
||||
- **Restart**: `docker-compose down && docker-compose up` (or your Docker management tool)
|
||||
|
||||
**For Flask/local deployments**:
|
||||
- **Config file**: `superset_config.py` (in project root, auto-imported)
|
||||
- **Git status**: Ignored by `/superset_config.py` pattern
|
||||
- **Restart**: Restart Flask development server
|
||||
|
||||
**Both methods**:
|
||||
- **Add feature flags**: `FEATURE_FLAGS = {"ENABLE_THEME_EDITOR": True}`
|
||||
- **Check deployment**: Use `docker ps` to see if containers are running
|
||||
|
||||
---
|
||||
|
||||
**LLM Note**: This codebase is actively modernizing toward full TypeScript and type safety. Always run `pre-commit run` to validate changes. Follow the ongoing refactors section to avoid deprecated patterns.
|
||||
|
||||
@@ -32,11 +32,10 @@ else
|
||||
SUPERSET_VERSION="${1}"
|
||||
SUPERSET_RC="${2}"
|
||||
SUPERSET_PGP_FULLNAME="${3}"
|
||||
SUPERSET_VERSION_RC="${SUPERSET_VERSION}rc${SUPERSET_RC}"
|
||||
SUPERSET_RELEASE_RC_TARBALL="apache_superset-${SUPERSET_VERSION_RC}-source.tar.gz"
|
||||
fi
|
||||
|
||||
SUPERSET_VERSION_RC="${SUPERSET_VERSION}rc${SUPERSET_RC}"
|
||||
|
||||
if [ -z "${SUPERSET_SVN_DEV_PATH}" ]; then
|
||||
SUPERSET_SVN_DEV_PATH="$HOME/svn/superset_dev"
|
||||
fi
|
||||
|
||||
@@ -28,6 +28,7 @@ These features are considered **unfinished** and should only be used on developm
|
||||
[//]: # "PLEASE KEEP THE LIST SORTED ALPHABETICALLY"
|
||||
|
||||
- ALERT_REPORT_TABS
|
||||
- DATE_RANGE_TIMESHIFTS_ENABLED
|
||||
- ENABLE_ADVANCED_DATA_TYPES
|
||||
- PRESTO_EXPAND_DATA
|
||||
- SHARE_QUERIES_VIA_KV_STORE
|
||||
|
||||
@@ -94,9 +94,9 @@ under the License.
|
||||
| can available domains on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
|
||||
| can request access on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
|
||||
| can dashboard on Superset |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|O|
|
||||
| can post on TableSchemaView |:heavy_check_mark:|:heavy_check_mark:|O|O|
|
||||
| can expanded on TableSchemaView |:heavy_check_mark:|:heavy_check_mark:|O|O|
|
||||
| can delete on TableSchemaView |:heavy_check_mark:|:heavy_check_mark:|O|O|
|
||||
| can post on TableSchemaView |:heavy_check_mark:|O|O|:heavy_check_mark:|
|
||||
| can expanded on TableSchemaView |:heavy_check_mark:|O|O|:heavy_check_mark:|
|
||||
| can delete on TableSchemaView |:heavy_check_mark:|O|O|:heavy_check_mark:|
|
||||
| can get on TabStateView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
|
||||
| can post on TabStateView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
|
||||
| can delete query on TabStateView |:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|:heavy_check_mark:|
|
||||
|
||||
@@ -23,6 +23,13 @@ This file documents any backwards-incompatible changes in Superset and
|
||||
assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:
|
||||
- Change `"error.base"` to just `"error"` after this PR
|
||||
- Change any hex color values to one of: `"success"`, `"processing"`, `"error"`, `"warning"`, `"default"`
|
||||
- Custom colors are no longer supported to maintain consistency with Ant Design components
|
||||
- [34561](https://github.com/apache/superset/pull/34561) Added tiled screenshot functionality for Playwright-based reports to handle large dashboards more efficiently. When enabled (default: `SCREENSHOT_TILED_ENABLED = True`), dashboards with 20+ charts or height exceeding 5000px will be captured using multiple viewport-sized tiles and combined into a single image. This improves report generation performance and reliability for large dashboards.
|
||||
Note: Pillow is now a required dependency (previously optional) to support image processing for tiled screenshots.
|
||||
`thumbnails` optional dependency is now deprecated and will be removed in the next major release (7.0).
|
||||
- [33084](https://github.com/apache/superset/pull/33084) The DISALLOWED_SQL_FUNCTIONS configuration now includes additional potentially sensitive database functions across PostgreSQL, MySQL, SQLite, MS SQL Server, and ClickHouse. Existing queries using these functions may now be blocked. Review your SQL Lab queries and dashboards if you encounter "disallowed function" errors after upgrading
|
||||
- [34235](https://github.com/apache/superset/pull/34235) CSV exports now use `utf-8-sig` encoding by default to include a UTF-8 BOM, improving compatibility with Excel.
|
||||
- [34258](https://github.com/apache/superset/pull/34258) changing the default in Dockerfile to INCLUDE_CHROMIUM="false" (from "true") in the past. This ensures the `lean` layer is lean by default, and people can opt-in to the `chromium` layer by setting the build arg `INCLUDE_CHROMIUM=true`. This is a breaking change for anyone using the `lean` layer, as it will no longer include Chromium by default.
|
||||
@@ -32,6 +39,7 @@ assists people when migrating to a new version.
|
||||
- [32317](https://github.com/apache/superset/pull/32317) The horizontal filter bar feature is now out of testing/beta development and its feature flag `HORIZONTAL_FILTER_BAR` has been removed.
|
||||
- [31590](https://github.com/apache/superset/pull/31590) Marks the begining of intricate work around supporting dynamic Theming, and breaks support for [THEME_OVERRIDES](https://github.com/apache/superset/blob/732de4ac7fae88e29b7f123b6cbb2d7cd411b0e4/superset/config.py#L671) in favor of a new theming system based on AntD V5. Likely this will be in disrepair until settling over the 5.x lifecycle.
|
||||
- [32432](https://github.com/apache/superset/pull/31260) Moves the List Roles FAB view to the frontend and requires `FAB_ADD_SECURITY_API` to be enabled in the configuration and `superset init` to be executed.
|
||||
- [34319](https://github.com/apache/superset/pull/34319) Drill to Detail and Drill By is now supported in Embedded mode, and also with the `DASHBOARD_RBAC` FF. If you don't want to expose these features in Embedded / `DASHBOARD_RBAC`, make sure the roles used for Embedded / `DASHBOARD_RBAC`don't have the required permissions to perform D2D actions.
|
||||
|
||||
## 5.0.0
|
||||
|
||||
|
||||
@@ -17,16 +17,47 @@
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Lightweight docker-compose for running multiple Superset instances
|
||||
# This includes only essential services: database, Redis, and Superset app
|
||||
# This includes only essential services: database and Superset app (no Redis)
|
||||
#
|
||||
# IMPORTANT: To run multiple instances in parallel:
|
||||
# RUNNING SUPERSET:
|
||||
# 1. Start services: docker-compose -f docker-compose-light.yml up
|
||||
# 2. Access at: http://localhost:9001 (or NODE_PORT if specified)
|
||||
#
|
||||
# RUNNING MULTIPLE INSTANCES:
|
||||
# - Use different project names: docker-compose -p project1 -f docker-compose-light.yml up
|
||||
# - Use different NODE_PORT values: NODE_PORT=9002 docker-compose -p project2 -f docker-compose-light.yml up
|
||||
# - Volumes are isolated by project name (e.g., project1_db_home_light, project2_db_home_light)
|
||||
# - Database name is intentionally different (superset_light) to prevent accidental cross-connections
|
||||
#
|
||||
# For verbose logging during development:
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed Superset logs
|
||||
# RUNNING TESTS WITH PYTEST:
|
||||
# Tests run in an isolated environment with a separate test database.
|
||||
# The pytest-runner service automatically creates and initializes the test database on first use.
|
||||
#
|
||||
# Basic usage:
|
||||
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest tests/unit_tests/
|
||||
#
|
||||
# Run specific test file:
|
||||
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest tests/unit_tests/test_foo.py
|
||||
#
|
||||
# Run with pytest options:
|
||||
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest -v -s -x tests/
|
||||
#
|
||||
# Force reload test database and run tests (when tests are failing due to bad state):
|
||||
# docker-compose -f docker-compose-light.yml run --rm -e FORCE_RELOAD=true pytest-runner pytest tests/
|
||||
#
|
||||
# Run any command in test environment:
|
||||
# docker-compose -f docker-compose-light.yml run --rm pytest-runner bash
|
||||
# docker-compose -f docker-compose-light.yml run --rm pytest-runner pytest --collect-only
|
||||
#
|
||||
# For parallel test execution with different projects:
|
||||
# docker-compose -p project1 -f docker-compose-light.yml run --rm pytest-runner pytest tests/
|
||||
#
|
||||
# DEVELOPMENT TIPS:
|
||||
# - First test run takes ~20-30 seconds (database creation + initialization)
|
||||
# - Subsequent runs are fast (~2-3 seconds startup)
|
||||
# - Use FORCE_RELOAD=true when you need a clean test database
|
||||
# - Tests use SimpleCache instead of Redis (no Redis required)
|
||||
# - Set SUPERSET_LOG_LEVEL=debug in docker/.env-local for detailed logs
|
||||
# -----------------------------------------------------------------------
|
||||
x-superset-user: &superset-user root
|
||||
x-superset-volumes: &superset-volumes
|
||||
@@ -56,13 +87,14 @@ services:
|
||||
required: false
|
||||
image: postgres:16
|
||||
restart: unless-stopped
|
||||
# No host port mapping - only accessible within Docker network
|
||||
volumes:
|
||||
- db_home_light:/var/lib/postgresql/data
|
||||
- ./docker/docker-entrypoint-initdb.d:/docker-entrypoint-initdb.d
|
||||
environment:
|
||||
# Override database name to avoid conflicts
|
||||
POSTGRES_DB: superset_light
|
||||
# Increase max connections for test runs
|
||||
command: postgres -c max_connections=200
|
||||
|
||||
superset-light:
|
||||
env_file:
|
||||
@@ -150,6 +182,34 @@ services:
|
||||
required: false
|
||||
volumes: *superset-volumes
|
||||
|
||||
pytest-runner:
|
||||
build:
|
||||
<<: *common-build
|
||||
entrypoint: ["/app/docker/docker-pytest-entrypoint.sh"]
|
||||
env_file:
|
||||
- path: docker/.env # default
|
||||
required: true
|
||||
- path: docker/.env-local # optional override
|
||||
required: false
|
||||
profiles:
|
||||
- test # Only starts when --profile test is used
|
||||
depends_on:
|
||||
db-light:
|
||||
condition: service_started
|
||||
user: *superset-user
|
||||
volumes: *superset-volumes
|
||||
environment:
|
||||
# Test-specific database configuration
|
||||
DATABASE_HOST: db-light
|
||||
DATABASE_DB: test
|
||||
POSTGRES_DB: test
|
||||
# Point to test database
|
||||
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@db-light:5432/test
|
||||
# Use the light test config that doesn't require Redis
|
||||
SUPERSET_CONFIG: superset_test_config_light
|
||||
# Python path includes test directory
|
||||
PYTHONPATH: /app/pythonpath:/app/docker/pythonpath_dev:/app
|
||||
|
||||
volumes:
|
||||
superset_home_light:
|
||||
external: false
|
||||
|
||||
152
docker/docker-pytest-entrypoint.sh
Executable file
152
docker/docker-pytest-entrypoint.sh
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
set -e
|
||||
|
||||
# Wait for PostgreSQL to be ready
|
||||
echo "Waiting for database to be ready..."
|
||||
for i in {1..30}; do
|
||||
if python3 -c "
|
||||
import psycopg2
|
||||
try:
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
|
||||
conn.close()
|
||||
print('Database is ready!')
|
||||
except:
|
||||
exit(1)
|
||||
" 2>/dev/null; then
|
||||
echo "Database connection established!"
|
||||
break
|
||||
fi
|
||||
echo "Waiting for database... ($i/30)"
|
||||
if [ $i -eq 30 ]; then
|
||||
echo "Database connection timeout after 30 seconds"
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Handle database setup based on FORCE_RELOAD
|
||||
if [ "${FORCE_RELOAD}" = "true" ]; then
|
||||
echo "Force reload requested - resetting test database"
|
||||
# Drop and recreate the test database using Python
|
||||
python3 -c "
|
||||
import psycopg2
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
|
||||
# Connect to default database
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
|
||||
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Drop and recreate test database
|
||||
try:
|
||||
cur.execute('DROP DATABASE IF EXISTS test')
|
||||
except:
|
||||
pass
|
||||
|
||||
cur.execute('CREATE DATABASE test')
|
||||
conn.close()
|
||||
|
||||
# Connect to test database to create schemas
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
|
||||
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute('CREATE SCHEMA sqllab_test_db')
|
||||
cur.execute('CREATE SCHEMA admin_database')
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
print('Test database reset successfully')
|
||||
"
|
||||
# Use --no-reset-db since we already reset it
|
||||
FLAGS="--no-reset-db"
|
||||
else
|
||||
echo "Using existing test database (set FORCE_RELOAD=true to reset)"
|
||||
FLAGS="--no-reset-db"
|
||||
|
||||
# Ensure test database exists using Python
|
||||
python3 -c "
|
||||
import psycopg2
|
||||
from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT
|
||||
|
||||
# Check if test database exists
|
||||
try:
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
|
||||
conn.close()
|
||||
print('Test database already exists')
|
||||
except:
|
||||
print('Creating test database...')
|
||||
# Connect to default database to create test database
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='superset_light')
|
||||
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cur = conn.cursor()
|
||||
|
||||
# Create test database
|
||||
cur.execute('CREATE DATABASE test')
|
||||
conn.close()
|
||||
|
||||
# Connect to test database to create schemas
|
||||
conn = psycopg2.connect(host='db-light', user='superset', password='superset', database='test')
|
||||
conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT)
|
||||
cur = conn.cursor()
|
||||
|
||||
cur.execute('CREATE SCHEMA IF NOT EXISTS sqllab_test_db')
|
||||
cur.execute('CREATE SCHEMA IF NOT EXISTS admin_database')
|
||||
|
||||
cur.close()
|
||||
conn.close()
|
||||
print('Test database created successfully')
|
||||
"
|
||||
fi
|
||||
|
||||
# Always run database migrations to ensure schema is up to date
|
||||
echo "Running database migrations..."
|
||||
cd /app
|
||||
superset db upgrade
|
||||
|
||||
# Initialize test environment if needed
|
||||
if [ "${FORCE_RELOAD}" = "true" ] || [ ! -f "/app/superset_home/.test_initialized" ]; then
|
||||
echo "Initializing test environment..."
|
||||
# Run initialization commands
|
||||
superset init
|
||||
echo "Loading test users..."
|
||||
superset load-test-users
|
||||
|
||||
# Mark as initialized
|
||||
touch /app/superset_home/.test_initialized
|
||||
else
|
||||
echo "Test environment already initialized (skipping init and load-test-users)"
|
||||
echo "Tip: Use FORCE_RELOAD=true to reinitialize the test database"
|
||||
fi
|
||||
|
||||
# Create missing scripts needed for tests
|
||||
if [ ! -f "/app/scripts/tag_latest_release.sh" ]; then
|
||||
echo "Creating missing tag_latest_release.sh script for tests..."
|
||||
cp /app/docker/tag_latest_release.sh /app/scripts/tag_latest_release.sh 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Install pip module for Shillelagh compatibility (aligns with CI environment)
|
||||
echo "Installing pip module for Shillelagh compatibility..."
|
||||
uv pip install pip
|
||||
|
||||
# If arguments provided, execute them
|
||||
if [ $# -gt 0 ]; then
|
||||
exec "$@"
|
||||
fi
|
||||
@@ -26,7 +26,7 @@ gunicorn \
|
||||
--workers ${SERVER_WORKER_AMOUNT:-1} \
|
||||
--worker-class ${SERVER_WORKER_CLASS:-gthread} \
|
||||
--threads ${SERVER_THREADS_AMOUNT:-20} \
|
||||
--log-level "${GUNICORN_LOGLEVEL:info}" \
|
||||
--log-level "${GUNICORN_LOGLEVEL:-info}" \
|
||||
--timeout ${GUNICORN_TIMEOUT:-60} \
|
||||
--keep-alive ${GUNICORN_KEEPALIVE:-2} \
|
||||
--max-requests ${WORKER_MAX_REQUESTS:-0} \
|
||||
|
||||
@@ -23,25 +23,57 @@ MIN_MEM_FREE_GB=3
|
||||
MIN_MEM_FREE_KB=$(($MIN_MEM_FREE_GB*1000000))
|
||||
|
||||
echo_mem_warn() {
|
||||
MEM_FREE_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_FREE_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
# Check if running in Codespaces first
|
||||
if [[ -n "${CODESPACES}" ]]; then
|
||||
echo "Memory available: Codespaces managed"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MEM_FREE_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||
# Check platform and get memory accordingly
|
||||
if [[ -f /proc/meminfo ]]; then
|
||||
# Linux
|
||||
if grep -q MemAvailable /proc/meminfo; then
|
||||
MEM_AVAIL_KB=$(awk '/MemAvailable/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_AVAIL_GB=$(awk '/MemAvailable/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
else
|
||||
MEM_AVAIL_KB=$(awk '/MemFree/ { printf "%s \n", $2 }' /proc/meminfo)
|
||||
MEM_AVAIL_GB=$(awk '/MemFree/ { printf "%s \n", $2/1024/1024 }' /proc/meminfo)
|
||||
fi
|
||||
elif [[ "$(uname)" == "Darwin" ]]; then
|
||||
# macOS - use vm_stat to get free memory
|
||||
# vm_stat reports in pages, typically 4096 bytes per page
|
||||
PAGE_SIZE=$(pagesize)
|
||||
FREE_PAGES=$(vm_stat | awk '/Pages free:/ {print $3}' | tr -d '.')
|
||||
INACTIVE_PAGES=$(vm_stat | awk '/Pages inactive:/ {print $3}' | tr -d '.')
|
||||
# Free + inactive pages give us available memory (similar to MemAvailable on Linux)
|
||||
AVAIL_PAGES=$((FREE_PAGES + INACTIVE_PAGES))
|
||||
MEM_AVAIL_KB=$((AVAIL_PAGES * PAGE_SIZE / 1024))
|
||||
MEM_AVAIL_GB=$(echo "scale=2; $MEM_AVAIL_KB / 1024 / 1024" | bc)
|
||||
else
|
||||
# Other platforms
|
||||
echo "Memory available: Unable to determine"
|
||||
return
|
||||
fi
|
||||
|
||||
if [[ "${MEM_AVAIL_KB}" -lt "${MIN_MEM_FREE_KB}" ]]; then
|
||||
cat <<EOF
|
||||
===============================================
|
||||
======== Memory Insufficient Warning =========
|
||||
===============================================
|
||||
|
||||
It looks like you only have ${MEM_FREE_GB}GB of
|
||||
memory free. Please increase your Docker
|
||||
It looks like you only have ${MEM_AVAIL_GB}GB of
|
||||
memory ${MEM_TYPE}. Please increase your Docker
|
||||
resources to at least ${MIN_MEM_FREE_GB}GB
|
||||
|
||||
Note: During builds, available memory may be
|
||||
temporarily low due to caching and compilation.
|
||||
|
||||
===============================================
|
||||
======== Memory Insufficient Warning =========
|
||||
===============================================
|
||||
EOF
|
||||
else
|
||||
echo "Memory check Ok [${MEM_FREE_GB}GB free]"
|
||||
echo "Memory available: ${MEM_AVAIL_GB} GB"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
55
docker/pythonpath_dev/superset_test_config_light.py
Normal file
55
docker/pythonpath_dev/superset_test_config_light.py
Normal file
@@ -0,0 +1,55 @@
|
||||
# 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.
|
||||
#
|
||||
# Test configuration for docker-compose-light.yml - uses SimpleCache instead of Redis
|
||||
|
||||
# Import all settings from the main test config first
|
||||
import os
|
||||
import sys
|
||||
|
||||
# Add the tests directory to the path to import the test config
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
from tests.integration_tests.superset_test_config import * # noqa: F403
|
||||
|
||||
# Override Redis-based caching to use simple in-memory cache
|
||||
CACHE_CONFIG = {
|
||||
"CACHE_TYPE": "SimpleCache",
|
||||
"CACHE_DEFAULT_TIMEOUT": 300,
|
||||
"CACHE_KEY_PREFIX": "superset_test_",
|
||||
}
|
||||
|
||||
DATA_CACHE_CONFIG = {
|
||||
**CACHE_CONFIG,
|
||||
"CACHE_DEFAULT_TIMEOUT": 30,
|
||||
"CACHE_KEY_PREFIX": "superset_test_data_",
|
||||
}
|
||||
|
||||
# Keep SimpleCache for these as they're already using it
|
||||
# FILTER_STATE_CACHE_CONFIG - already SimpleCache in parent
|
||||
# EXPLORE_FORM_DATA_CACHE_CONFIG - already SimpleCache in parent
|
||||
|
||||
# Disable Celery for lightweight testing
|
||||
CELERY_CONFIG = None
|
||||
|
||||
# Use FileSystemCache for SQL Lab results instead of Redis
|
||||
from flask_caching.backends.filesystemcache import FileSystemCache # noqa: E402
|
||||
|
||||
RESULTS_BACKEND = FileSystemCache("/app/superset_home/sqllab_test")
|
||||
|
||||
# Override WEBDRIVER_BASEURL for tests to match expected values
|
||||
WEBDRIVER_BASEURL = "http://0.0.0.0:8080/"
|
||||
WEBDRIVER_BASEURL_USER_FRIENDLY = WEBDRIVER_BASEURL
|
||||
190
docker/tag_latest_release.sh
Executable file
190
docker/tag_latest_release.sh
Executable file
@@ -0,0 +1,190 @@
|
||||
#! /bin/bash
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
run_git_tag () {
|
||||
if [[ "$DRY_RUN" == "false" ]] && [[ "$SKIP_TAG" == "false" ]]
|
||||
then
|
||||
git tag -a -f latest "${GITHUB_TAG_NAME}" -m "latest tag"
|
||||
echo "${GITHUB_TAG_NAME} has been tagged 'latest'"
|
||||
fi
|
||||
exit 0
|
||||
}
|
||||
|
||||
###
|
||||
# separating out git commands into functions so they can be mocked in unit tests
|
||||
###
|
||||
git_show_ref () {
|
||||
if [[ "$TEST_ENV" == "true" ]]
|
||||
then
|
||||
if [[ "$GITHUB_TAG_NAME" == "does_not_exist" ]]
|
||||
# mock return for testing only
|
||||
then
|
||||
echo ""
|
||||
else
|
||||
echo "2817aebd69dc7d199ec45d973a2079f35e5658b6 refs/tags/${GITHUB_TAG_NAME}"
|
||||
fi
|
||||
fi
|
||||
result=$(git show-ref "${GITHUB_TAG_NAME}")
|
||||
echo "${result}"
|
||||
}
|
||||
|
||||
get_latest_tag_list () {
|
||||
if [[ "$TEST_ENV" == "true" ]]
|
||||
then
|
||||
echo "(tag: 2.1.0, apache/2.1test)"
|
||||
else
|
||||
result=$(git show-ref --tags --dereference latest | awk '{print $2}' | xargs git show --pretty=tformat:%d -s | grep tag:)
|
||||
echo "${result}"
|
||||
fi
|
||||
}
|
||||
###
|
||||
|
||||
split_string () {
|
||||
local version="$1"
|
||||
local delimiter="$2"
|
||||
local components=()
|
||||
local tmp=""
|
||||
for (( i=0; i<${#version}; i++ )); do
|
||||
local char="${version:$i:1}"
|
||||
if [[ "$char" != "$delimiter" ]]; then
|
||||
tmp="$tmp$char"
|
||||
elif [[ -n "$tmp" ]]; then
|
||||
components+=("$tmp")
|
||||
tmp=""
|
||||
fi
|
||||
done
|
||||
if [[ -n "$tmp" ]]; then
|
||||
components+=("$tmp")
|
||||
fi
|
||||
echo "${components[@]}"
|
||||
}
|
||||
|
||||
DRY_RUN=false
|
||||
|
||||
# get params passed in with script when it was run
|
||||
# --dry-run is optional and returns the value of SKIP_TAG, but does not run the git tag statement
|
||||
# A tag name is required as a param. A SHA won't work. You must first tag a sha with a release number
|
||||
# and then run this script
|
||||
while [[ $# -gt 0 ]]
|
||||
do
|
||||
key="$1"
|
||||
|
||||
case $key in
|
||||
--dry-run)
|
||||
DRY_RUN=true
|
||||
shift # past value
|
||||
;;
|
||||
*) # this should be the tag name
|
||||
GITHUB_TAG_NAME=$key
|
||||
shift # past value
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "${GITHUB_TAG_NAME}" ]; then
|
||||
echo "Missing tag parameter, usage: ./scripts/tag_latest_release.sh <GITHUB_TAG_NAME>"
|
||||
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z "$(git_show_ref)" ]; then
|
||||
echo "The tag ${GITHUB_TAG_NAME} does not exist. Please use a different tag."
|
||||
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# check that this tag only contains a proper semantic version
|
||||
if ! [[ ${GITHUB_TAG_NAME} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
echo "This tag ${GITHUB_TAG_NAME} is not a valid release version. Not tagging."
|
||||
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
|
||||
exit 1
|
||||
fi
|
||||
|
||||
## split the current GITHUB_TAG_NAME into an array at the dot
|
||||
THIS_TAG_NAME=$(split_string "${GITHUB_TAG_NAME}" ".")
|
||||
|
||||
# look up the 'latest' tag on git
|
||||
LATEST_TAG_LIST=$(get_latest_tag_list) || echo 'not found'
|
||||
|
||||
# if 'latest' tag doesn't exist, then set this commit to latest
|
||||
if [[ -z "$LATEST_TAG_LIST" ]]
|
||||
then
|
||||
echo "there are no latest tags yet, so I'm going to start by tagging this sha as the latest"
|
||||
run_git_tag
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# remove parenthesis and tag: from the list of tags
|
||||
LATEST_TAGS_STRINGS=$(echo "$LATEST_TAG_LIST" | sed 's/tag: \([^,]*\)/\1/g' | tr -d '()')
|
||||
|
||||
LATEST_TAGS=$(split_string "$LATEST_TAGS_STRINGS" ",")
|
||||
TAGS=($(split_string "$LATEST_TAGS" " "))
|
||||
|
||||
# Initialize a flag for comparison result
|
||||
compare_result=""
|
||||
|
||||
# Iterate through the tags of the latest release
|
||||
for tag in $TAGS
|
||||
do
|
||||
if [[ $tag == "latest" ]]; then
|
||||
continue
|
||||
else
|
||||
## extract just the version from this tag
|
||||
LATEST_RELEASE_TAG="$tag"
|
||||
echo "LATEST_RELEASE_TAG: ${LATEST_RELEASE_TAG}"
|
||||
|
||||
# check that this only contains a proper semantic version
|
||||
if ! [[ ${LATEST_RELEASE_TAG} =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]
|
||||
then
|
||||
echo "'Latest' has been associated with tag ${LATEST_RELEASE_TAG} which is not a valid release version. Looking for another."
|
||||
continue
|
||||
fi
|
||||
echo "The current release with the latest tag is version ${LATEST_RELEASE_TAG}"
|
||||
# Split the version strings into arrays
|
||||
THIS_TAG_NAME_ARRAY=($(split_string "$THIS_TAG_NAME" "."))
|
||||
LATEST_RELEASE_TAG_ARRAY=($(split_string "$LATEST_RELEASE_TAG" "."))
|
||||
|
||||
# Iterate through the components of the version strings
|
||||
for (( j=0; j<${#THIS_TAG_NAME_ARRAY[@]}; j++ )); do
|
||||
echo "Comparing ${THIS_TAG_NAME_ARRAY[$j]} to ${LATEST_RELEASE_TAG_ARRAY[$j]}"
|
||||
if [[ $((THIS_TAG_NAME_ARRAY[$j])) > $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="greater"
|
||||
break
|
||||
elif [[ $((THIS_TAG_NAME_ARRAY[$j])) < $((LATEST_RELEASE_TAG_ARRAY[$j])) ]]; then
|
||||
compare_result="lesser"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
|
||||
# Determine the result based on the comparison
|
||||
if [[ -z "$compare_result" ]]; then
|
||||
echo "Versions are equal"
|
||||
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
|
||||
elif [[ "$compare_result" == "greater" ]]; then
|
||||
echo "This release tag ${GITHUB_TAG_NAME} is newer than the latest."
|
||||
echo "SKIP_TAG=false" >> $GITHUB_OUTPUT
|
||||
# Add other actions you want to perform for a newer version
|
||||
elif [[ "$compare_result" == "lesser" ]]; then
|
||||
echo "This release tag ${GITHUB_TAG_NAME} is older than the latest."
|
||||
echo "This release tag ${GITHUB_TAG_NAME} is not the latest. Not tagging."
|
||||
# if you've gotten this far, then we don't want to run any tags in the next step
|
||||
echo "SKIP_TAG=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
@@ -13,9 +13,9 @@ apache-superset>=6.0
|
||||
Superset now rides on **Ant Design v5's token-based theming**.
|
||||
Every Antd token works, plus a handful of Superset-specific ones for charts and dashboard chrome.
|
||||
|
||||
## Managing Themes via CRUD Interface
|
||||
## Managing Themes via UI
|
||||
|
||||
Superset now includes a built-in **Theme Management** interface accessible from the admin menu under **Settings > Themes**.
|
||||
Superset includes a built-in **Theme Management** interface accessible from the admin menu under **Settings > Themes**.
|
||||
|
||||
### Creating a New Theme
|
||||
|
||||
@@ -29,22 +29,38 @@ Superset now includes a built-in **Theme Management** interface accessible from
|
||||
|
||||
You can also extend with Superset-specific tokens (documented in the default theme object) before you import.
|
||||
|
||||
### System Theme Administration
|
||||
|
||||
When `ENABLE_UI_THEME_ADMINISTRATION = True` is configured, administrators can manage system-wide themes directly from the UI:
|
||||
|
||||
#### Setting System Themes
|
||||
- **System Default Theme**: Click the sun icon on any theme to set it as the system-wide default
|
||||
- **System Dark Theme**: Click the moon icon on any theme to set it as the system dark mode theme
|
||||
- **Automatic OS Detection**: When both default and dark themes are set, Superset automatically detects and applies the appropriate theme based on OS preferences
|
||||
|
||||
#### Managing System Themes
|
||||
- System themes are indicated with special badges in the theme list
|
||||
- Only administrators with write permissions can modify system theme settings
|
||||
- Removing a system theme designation reverts to configuration file defaults
|
||||
|
||||
### Applying Themes to Dashboards
|
||||
|
||||
Once created, themes can be applied to individual dashboards:
|
||||
- Edit any dashboard and select your custom theme from the theme dropdown
|
||||
- Each dashboard can have its own theme, allowing for branded or context-specific styling
|
||||
|
||||
## Alternative: Instance-wide Configuration
|
||||
## Configuration Options
|
||||
|
||||
For system-wide theming, you can configure default themes via Python configuration:
|
||||
### Python Configuration
|
||||
|
||||
### Setting Default Themes
|
||||
Configure theme behavior via `superset_config.py`:
|
||||
|
||||
```python
|
||||
# superset_config.py
|
||||
# Enable UI-based theme administration for admins
|
||||
ENABLE_UI_THEME_ADMINISTRATION = True
|
||||
|
||||
# Default theme (light mode)
|
||||
# Optional: Set initial default themes via configuration
|
||||
# These can be overridden via the UI when ENABLE_UI_THEME_ADMINISTRATION = True
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"colorPrimary": "#2893B3",
|
||||
@@ -53,7 +69,7 @@ THEME_DEFAULT = {
|
||||
}
|
||||
}
|
||||
|
||||
# Dark theme configuration
|
||||
# Optional: Dark theme configuration
|
||||
THEME_DARK = {
|
||||
"algorithm": "dark",
|
||||
"token": {
|
||||
@@ -62,23 +78,28 @@ THEME_DARK = {
|
||||
}
|
||||
}
|
||||
|
||||
# Theme behavior settings
|
||||
THEME_SETTINGS = {
|
||||
"enforced": False, # If True, forces default theme always
|
||||
"allowSwitching": True, # Allow users to switch between themes
|
||||
"allowOSPreference": True, # Auto-detect system theme preference
|
||||
}
|
||||
# To force a single theme on all users, set THEME_DARK = None
|
||||
# When both themes are defined (via UI or config):
|
||||
# - Users can manually switch between themes
|
||||
# - OS preference detection is automatically enabled
|
||||
```
|
||||
|
||||
### Copying Themes from CRUD Interface
|
||||
### Migration from Configuration to UI
|
||||
|
||||
To use a theme created via the CRUD interface as your system default:
|
||||
When `ENABLE_UI_THEME_ADMINISTRATION = True`:
|
||||
|
||||
1. Navigate to **Settings > Themes** and edit your desired theme
|
||||
2. Copy the complete JSON configuration from the theme definition field
|
||||
3. Paste it directly into your `superset_config.py` as shown above
|
||||
1. System themes set via the UI take precedence over configuration file settings
|
||||
2. The UI shows which themes are currently set as system defaults
|
||||
3. Administrators can change system themes without restarting Superset
|
||||
4. Configuration file themes serve as fallbacks when no UI themes are set
|
||||
|
||||
Restart Superset to apply changes.
|
||||
### Copying Themes Between Systems
|
||||
|
||||
To export a theme for use in configuration files or another instance:
|
||||
|
||||
1. Navigate to **Settings > Themes** and click the export icon on your desired theme
|
||||
2. Extract the JSON configuration from the exported YAML file
|
||||
3. Use this JSON in your `superset_config.py` or import it into another Superset instance
|
||||
|
||||
## Theme Development Workflow
|
||||
|
||||
@@ -87,8 +108,85 @@ Restart Superset to apply changes.
|
||||
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
|
||||
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
Add font URLs to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
|
||||
CUSTOM_FONT_URLS = [
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||||
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
]
|
||||
|
||||
# Update CSP to allow font sources
|
||||
TALISMAN_CONFIG = {
|
||||
"content_security_policy": {
|
||||
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
||||
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Custom Fonts in Themes
|
||||
|
||||
Once configured, reference the fonts in your theme configuration:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
|
||||
# ... other theme tokens
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or in the CRUD interface theme JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Font Sources
|
||||
|
||||
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
|
||||
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
|
||||
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
|
||||
|
||||
This feature works with the stock Docker image - no custom build required!
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Superset includes built-in light and dark themes
|
||||
- **System Themes**: Manage system-wide default and dark themes via UI or configuration
|
||||
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
||||
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
||||
- **Custom Fonts**: Load external fonts via configuration without rebuilding
|
||||
- **OS Dark Mode Detection**: Automatically switches themes based on system preferences
|
||||
- **Theme Import/Export**: Share themes between instances via YAML files
|
||||
|
||||
## API Access
|
||||
|
||||
For programmatic theme management, Superset provides REST endpoints:
|
||||
|
||||
- `GET /api/v1/theme/` - List all themes
|
||||
- `POST /api/v1/theme/` - Create a new theme
|
||||
- `PUT /api/v1/theme/{id}` - Update a theme
|
||||
- `DELETE /api/v1/theme/{id}` - Delete a theme
|
||||
- `PUT /api/v1/theme/{id}/set_system_default` - Set as system default theme (admin only)
|
||||
- `PUT /api/v1/theme/{id}/set_system_dark` - Set as system dark theme (admin only)
|
||||
- `DELETE /api/v1/theme/unset_system_default` - Remove system default designation
|
||||
- `DELETE /api/v1/theme/unset_system_dark` - Remove system dark designation
|
||||
- `GET /api/v1/theme/export/` - Export themes as YAML
|
||||
- `POST /api/v1/theme/import/` - Import themes from YAML
|
||||
|
||||
These endpoints require appropriate permissions and are subject to RBAC controls.
|
||||
|
||||
@@ -120,6 +120,78 @@ docker volume rm superset_db_home
|
||||
docker-compose up
|
||||
```
|
||||
|
||||
## GitHub Codespaces (Cloud Development)
|
||||
|
||||
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
|
||||
- Quick contributions without local setup
|
||||
- Consistent development environments across team members
|
||||
- Working from devices that can't run Docker locally
|
||||
- Safe experimentation in isolated environments
|
||||
|
||||
:::info
|
||||
We're grateful to GitHub for providing this excellent cloud development service that makes
|
||||
contributing to Apache Superset more accessible to developers worldwide.
|
||||
:::
|
||||
|
||||
### Getting Started with Codespaces
|
||||
|
||||
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
|
||||
|
||||
:::caution
|
||||
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
|
||||
Smaller instances will not have sufficient resources to run Superset effectively.
|
||||
:::
|
||||
|
||||
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
|
||||
- Build the development container
|
||||
- Install all dependencies
|
||||
- Start all required services (PostgreSQL, Redis, etc.)
|
||||
- Initialize the database with example data
|
||||
|
||||
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
|
||||
Click the globe icon to open Superset in your browser.
|
||||
- Default credentials: `admin` / `admin`
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
|
||||
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
|
||||
- **Multiple Instances**: Run multiple Codespaces for different branches/features
|
||||
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
|
||||
- **VS Code Integration**: Works seamlessly with VS Code desktop app
|
||||
|
||||
### Managing Codespaces
|
||||
|
||||
- **List active Codespaces**: `gh cs list`
|
||||
- **SSH into a Codespace**: `gh cs ssh`
|
||||
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
|
||||
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
|
||||
|
||||
### Debugging and Logs
|
||||
|
||||
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
|
||||
|
||||
```bash
|
||||
# Stream logs from all services
|
||||
docker compose -f docker-compose-light.yml logs -f
|
||||
|
||||
# Stream logs from a specific service
|
||||
docker compose -f docker-compose-light.yml logs -f superset
|
||||
|
||||
# View last 100 lines and follow
|
||||
docker compose -f docker-compose-light.yml logs --tail=100 -f
|
||||
|
||||
# List all running services
|
||||
docker compose -f docker-compose-light.yml ps
|
||||
```
|
||||
|
||||
:::tip
|
||||
Codespaces automatically stop after 30 minutes of inactivity to save resources.
|
||||
Your work is preserved and you can restart anytime.
|
||||
:::
|
||||
|
||||
## Installing Development Tools
|
||||
|
||||
:::note
|
||||
@@ -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
|
||||
```
|
||||
|
||||
You can log anything to the browser console, including objects:
|
||||
|
||||
```python
|
||||
from superset import app
|
||||
app.logger.error('An exception occurred!')
|
||||
app.logger.info(form_data)
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.
|
||||
|
||||
@@ -2,6 +2,20 @@
|
||||
title: CVEs fixed by release
|
||||
sidebar_position: 2
|
||||
---
|
||||
#### Version 5.0.0
|
||||
|
||||
| CVE | Title | Affected |
|
||||
|:---------------|:-----------------------------------------------------------------------------------|---------:|
|
||||
| CVE-2025-55673 | Exposure of Sensitive Information to an Unauthorized Actor | < 5.0.0 |
|
||||
| CVE-2025-55674 | Improper Neutralization of Special Elements used in an SQL Command | < 5.0.0 |
|
||||
| CVE-2025-55675 | Improper Access Control leading to Information Disclosure | < 5.0.0 |
|
||||
|
||||
#### Version 4.1.3
|
||||
|
||||
| CVE | Title | Affected |
|
||||
|:---------------|:-----------------------------------------------------------------------------------|---------:|
|
||||
| CVE-2025-55672 | Improper Neutralization of Input During Web Page Generation | < 4.1.3 |
|
||||
|
||||
#### Version 4.1.2
|
||||
|
||||
| CVE | Title | Affected |
|
||||
|
||||
@@ -28,6 +28,9 @@ const globals = require('globals');
|
||||
const { defineConfig, globalIgnores } = require('eslint/config');
|
||||
|
||||
module.exports = defineConfig([
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
},
|
||||
globalIgnores(['build/**/*', '.docusaurus/**/*', 'node_modules/**/*']),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
@@ -36,7 +39,7 @@ module.exports = defineConfig([
|
||||
files: ['eslint.config.js'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-require-imports': 'off',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
languageOptions: {
|
||||
@@ -68,5 +71,5 @@ module.exports = defineConfig([
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
}
|
||||
])
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"write-translations": "docusaurus write-translations",
|
||||
"write-heading-ids": "docusaurus write-heading-ids",
|
||||
"typecheck": "tsc",
|
||||
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx"
|
||||
"eslint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.0.0",
|
||||
@@ -26,33 +26,33 @@
|
||||
"@emotion/styled": "^10.0.27",
|
||||
"@saucelabs/theme-github-codeblock": "^0.3.0",
|
||||
"@superset-ui/style": "^0.14.23",
|
||||
"antd": "^5.26.3",
|
||||
"antd": "^5.26.7",
|
||||
"docusaurus-plugin-less": "^2.0.2",
|
||||
"less": "^4.3.0",
|
||||
"less": "^4.4.0",
|
||||
"less-loader": "^12.3.0",
|
||||
"prism-react-renderer": "^2.4.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-github-btn": "^1.4.0",
|
||||
"react-svg-pan-zoom": "^3.13.1",
|
||||
"swagger-ui-react": "^5.26.0"
|
||||
"swagger-ui-react": "^5.27.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.8.1",
|
||||
"@docusaurus/tsconfig": "^3.8.1",
|
||||
"@eslint/js": "^9.31.0",
|
||||
"@eslint/js": "^9.32.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"eslint": "^9.31.0",
|
||||
"eslint-config-prettier": "^10.1.5",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"eslint": "^9.32.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.3",
|
||||
"eslint-plugin-react": "^7.37.5",
|
||||
"globals": "^16.3.0",
|
||||
"prettier": "^3.6.2",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.37.0",
|
||||
"webpack": "^5.99.9"
|
||||
"typescript-eslint": "^8.39.0",
|
||||
"webpack": "^5.101.0"
|
||||
},
|
||||
"browserslist": {
|
||||
"production": [
|
||||
|
||||
367
docs/yarn.lock
367
docs/yarn.lock
@@ -2150,14 +2150,7 @@
|
||||
resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.2.5.tgz"
|
||||
integrity sha512-6U71C2Wp7r5XtFtQzYrW5iKFT67OixrSxjI4MptCHzdSVlgabczzqLe0ZSgnub/5Kp4hSbpDB1tMytZY9pwxxA==
|
||||
|
||||
"@eslint-community/eslint-utils@^4.2.0":
|
||||
version "4.4.1"
|
||||
resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz"
|
||||
integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==
|
||||
dependencies:
|
||||
eslint-visitor-keys "^3.4.3"
|
||||
|
||||
"@eslint-community/eslint-utils@^4.7.0":
|
||||
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0":
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a"
|
||||
integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==
|
||||
@@ -2205,20 +2198,20 @@
|
||||
minimatch "^3.1.2"
|
||||
strip-json-comments "^3.1.1"
|
||||
|
||||
"@eslint/js@9.31.0", "@eslint/js@^9.31.0":
|
||||
version "9.31.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.31.0.tgz#adb1f39953d8c475c4384b67b67541b0d7206ed8"
|
||||
integrity sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==
|
||||
"@eslint/js@9.32.0", "@eslint/js@^9.32.0":
|
||||
version "9.32.0"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.32.0.tgz#a02916f58bd587ea276876cb051b579a3d75d091"
|
||||
integrity sha512-BBpRFZK3eX6uMLKz8WxFOBIFFcGFJ/g8XuwjTHCqHROSIsopI+ddn/d5Cfh36+7+e5edVS8dbSHnBNhrLEX0zg==
|
||||
|
||||
"@eslint/object-schema@^2.1.6":
|
||||
version "2.1.6"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.6.tgz#58369ab5b5b3ca117880c0f6c0b0f32f6950f24f"
|
||||
integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==
|
||||
|
||||
"@eslint/plugin-kit@^0.3.1":
|
||||
version "0.3.3"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.3.tgz#32926b59bd407d58d817941e48b2a7049359b1fd"
|
||||
integrity sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag==
|
||||
"@eslint/plugin-kit@^0.3.4":
|
||||
version "0.3.4"
|
||||
resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.3.4.tgz#c6b9f165e94bf4d9fdd493f1c028a94aaf5fc1cc"
|
||||
integrity sha512-Ul5l+lHEcw3L5+k8POx6r74mxEYKG5kOb6Xpy2gCRW6zweT6TEhAf8vhxGgjhqrd/VO/Dirhsb+1hNpD1ue9hw==
|
||||
dependencies:
|
||||
"@eslint/core" "^0.15.1"
|
||||
levn "^0.4.1"
|
||||
@@ -2383,10 +2376,10 @@
|
||||
dependencies:
|
||||
"@types/mdx" "^2.0.0"
|
||||
|
||||
"@mermaid-js/parser@^0.4.0":
|
||||
version "0.4.0"
|
||||
resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.4.0.tgz#c1de1f5669f8fcbd0d0c9d124927d36ddc00d8a6"
|
||||
integrity sha512-wla8XOWvQAwuqy+gxiZqY+c7FokraOTHRWMsbB4AgRx9Sy7zKslNyejy7E+a77qHfey5GXw/ik3IXv/NHMJgaA==
|
||||
"@mermaid-js/parser@^0.6.2":
|
||||
version "0.6.2"
|
||||
resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.6.2.tgz#6d505a33acb52ddeb592c596b14f9d92a30396a9"
|
||||
integrity sha512-+PO02uGF6L6Cs0Bw8RpGhikVvMWEysfAyl27qTlroUB8jSWr1lL0Sf6zi78ZxlSnmgSY2AMMKVgghnN9jTtwkQ==
|
||||
dependencies:
|
||||
langium "3.3.1"
|
||||
|
||||
@@ -2512,10 +2505,10 @@
|
||||
classnames "^2.3.2"
|
||||
rc-util "^5.24.4"
|
||||
|
||||
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.2.7":
|
||||
version "2.2.7"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.2.7.tgz#a2b97ecbb93280a3c424e51fa415b371b355d76a"
|
||||
integrity sha512-Qggj4Z0AA2i5dJhzlfFSmg1Qrziu8dsdHOihROL5Kl18seO2Eh/ZaTYt2c8a/CyGaTChnFry7BEYew1+/fhSbA==
|
||||
"@rc-component/trigger@^2.0.0", "@rc-component/trigger@^2.1.1", "@rc-component/trigger@^2.3.0":
|
||||
version "2.3.0"
|
||||
resolved "https://registry.yarnpkg.com/@rc-component/trigger/-/trigger-2.3.0.tgz#9499ada078daca9dd99d01f0f0743ee1ab9e398b"
|
||||
integrity sha512-iwaxZyzOuK0D7lS+0AQEtW52zUWxoGqTGkke3dRyb8pYiShmRpCjB/8TzPI4R6YySCH7Vm9BZj/31VPiiQTLBg==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.2"
|
||||
"@rc-component/portal" "^1.1.0"
|
||||
@@ -3422,10 +3415,10 @@
|
||||
dependencies:
|
||||
"@types/estree" "*"
|
||||
|
||||
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6":
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8"
|
||||
integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==
|
||||
"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@^1.0.8":
|
||||
version "1.0.8"
|
||||
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e"
|
||||
integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==
|
||||
|
||||
"@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
|
||||
version "5.0.6"
|
||||
@@ -3724,79 +3717,79 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.37.0", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.37.0.tgz#332392883f936137cd6252c8eb236d298e514e70"
|
||||
integrity sha512-jsuVWeIkb6ggzB+wPCsR4e6loj+rM72ohW6IBn2C+5NCvfUVY8s33iFPySSVXqtm5Hu29Ne/9bnA0JmyLmgenA==
|
||||
"@typescript-eslint/eslint-plugin@8.39.0", "@typescript-eslint/eslint-plugin@^8.37.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.39.0.tgz#c9afec1866ee1a6ea3d768b5f8e92201efbbba06"
|
||||
integrity sha512-bhEz6OZeUR+O/6yx9Jk6ohX6H9JSFTaiY0v9/PuKT3oGK0rn0jNplLmyFUGV+a9gfYnVNwGDwS/UkLIuXNb2Rw==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.10.0"
|
||||
"@typescript-eslint/scope-manager" "8.37.0"
|
||||
"@typescript-eslint/type-utils" "8.37.0"
|
||||
"@typescript-eslint/utils" "8.37.0"
|
||||
"@typescript-eslint/visitor-keys" "8.37.0"
|
||||
"@typescript-eslint/scope-manager" "8.39.0"
|
||||
"@typescript-eslint/type-utils" "8.39.0"
|
||||
"@typescript-eslint/utils" "8.39.0"
|
||||
"@typescript-eslint/visitor-keys" "8.39.0"
|
||||
graphemer "^1.4.0"
|
||||
ignore "^7.0.0"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/parser@8.37.0", "@typescript-eslint/parser@^8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.37.0.tgz#b87f6b61e25ad5cc5bbf8baf809b8da889c89804"
|
||||
integrity sha512-kVIaQE9vrN9RLCQMQ3iyRlVJpTiDUY6woHGb30JDkfJErqrQEmtdWH3gV0PBAfGZgQXoqzXOO0T3K6ioApbbAA==
|
||||
"@typescript-eslint/parser@8.39.0", "@typescript-eslint/parser@^8.37.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.39.0.tgz#c4b895d7a47f4cd5ee6ee77ea30e61d58b802008"
|
||||
integrity sha512-g3WpVQHngx0aLXn6kfIYCZxM6rRJlWzEkVpqEFLT3SgEDsp9cpCbxxgwnE504q4H+ruSDh/VGS6nqZIDynP+vg==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.37.0"
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/typescript-estree" "8.37.0"
|
||||
"@typescript-eslint/visitor-keys" "8.37.0"
|
||||
"@typescript-eslint/scope-manager" "8.39.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
"@typescript-eslint/typescript-estree" "8.39.0"
|
||||
"@typescript-eslint/visitor-keys" "8.39.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/project-service@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.37.0.tgz#0594352e32a4ac9258591b88af77b5653800cdfe"
|
||||
integrity sha512-BIUXYsbkl5A1aJDdYJCBAo8rCEbAvdquQ8AnLb6z5Lp1u3x5PNgSSx9A/zqYc++Xnr/0DVpls8iQ2cJs/izTXA==
|
||||
"@typescript-eslint/project-service@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.39.0.tgz#71cb29c3f8139f99a905b8705127bffc2ae84759"
|
||||
integrity sha512-CTzJqaSq30V/Z2Og9jogzZt8lJRR5TKlAdXmWgdu4hgcC9Kww5flQ+xFvMxIBWVNdxJO7OifgdOK4PokMIWPew==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.37.0"
|
||||
"@typescript-eslint/types" "^8.37.0"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.39.0"
|
||||
"@typescript-eslint/types" "^8.39.0"
|
||||
debug "^4.3.4"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.37.0.tgz#a31a3c80ca2ef4ed58de13742debb692e7d4c0a4"
|
||||
integrity sha512-0vGq0yiU1gbjKob2q691ybTg9JX6ShiVXAAfm2jGf3q0hdP6/BruaFjL/ManAR/lj05AvYCH+5bbVo0VtzmjOA==
|
||||
"@typescript-eslint/scope-manager@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.39.0.tgz#ba4bf6d8257bbc172c298febf16bc22df4856570"
|
||||
integrity sha512-8QOzff9UKxOh6npZQ/4FQu4mjdOCGSdO3p44ww0hk8Vu+IGbg0tB/H1LcTARRDzGCC8pDGbh2rissBuuoPgH8A==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/visitor-keys" "8.37.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
"@typescript-eslint/visitor-keys" "8.39.0"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.37.0", "@typescript-eslint/tsconfig-utils@^8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.37.0.tgz#47a2760d265c6125f8e7864bc5c8537cad2bd053"
|
||||
integrity sha512-1/YHvAVTimMM9mmlPvTec9NP4bobA1RkDbMydxG8omqwJJLEW/Iy2C4adsAESIXU3WGLXFHSZUU+C9EoFWl4Zg==
|
||||
"@typescript-eslint/tsconfig-utils@8.39.0", "@typescript-eslint/tsconfig-utils@^8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.39.0.tgz#b2e87fef41a3067c570533b722f6af47be213f13"
|
||||
integrity sha512-Fd3/QjmFV2sKmvv3Mrj8r6N8CryYiCS8Wdb/6/rgOXAWGcFuc+VkQuG28uk/4kVNVZBQuuDHEDUpo/pQ32zsIQ==
|
||||
|
||||
"@typescript-eslint/type-utils@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.37.0.tgz#2a682e4c6ff5886712dad57e9787b5e417124507"
|
||||
integrity sha512-SPkXWIkVZxhgwSwVq9rqj/4VFo7MnWwVaRNznfQDc/xPYHjXnPfLWn+4L6FF1cAz6e7dsqBeMawgl7QjUMj4Ow==
|
||||
"@typescript-eslint/type-utils@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.39.0.tgz#310ec781ae5e7bb0f5940bfd652573587f22786b"
|
||||
integrity sha512-6B3z0c1DXVT2vYA9+z9axjtc09rqKUPRmijD5m9iv8iQpHBRYRMBcgxSiKTZKm6FwWw1/cI4v6em35OsKCiN5Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/typescript-estree" "8.37.0"
|
||||
"@typescript-eslint/utils" "8.37.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
"@typescript-eslint/typescript-estree" "8.39.0"
|
||||
"@typescript-eslint/utils" "8.39.0"
|
||||
debug "^4.3.4"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/types@8.37.0", "@typescript-eslint/types@^8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.37.0.tgz#09517aa9625eb3c68941dde3ac8835740587b6ff"
|
||||
integrity sha512-ax0nv7PUF9NOVPs+lmQ7yIE7IQmAf8LGcXbMvHX5Gm+YJUYNAl340XkGnrimxZ0elXyoQJuN5sbg6C4evKA4SQ==
|
||||
"@typescript-eslint/types@8.39.0", "@typescript-eslint/types@^8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.39.0.tgz#80f010b7169d434a91cd0529d70a528dbc9c99c6"
|
||||
integrity sha512-ArDdaOllnCj3yn/lzKn9s0pBQYmmyme/v1HbGIGB0GB/knFI3fWMHloC+oYTJW46tVbYnGKTMDK4ah1sC2v0Kg==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.37.0.tgz#a07e4574d8e6e4355a558f61323730c987f5fcbc"
|
||||
integrity sha512-zuWDMDuzMRbQOM+bHyU4/slw27bAUEcKSKKs3hcv2aNnc/tvE/h7w60dwVw8vnal2Pub6RT1T7BI8tFZ1fE+yg==
|
||||
"@typescript-eslint/typescript-estree@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.39.0.tgz#b9477a5c47a0feceffe91adf553ad9a3cd4cb3d6"
|
||||
integrity sha512-ndWdiflRMvfIgQRpckQQLiB5qAKQ7w++V4LlCHwp62eym1HLB/kw7D9f2e8ytONls/jt89TEasgvb+VwnRprsw==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.37.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.37.0"
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/visitor-keys" "8.37.0"
|
||||
"@typescript-eslint/project-service" "8.39.0"
|
||||
"@typescript-eslint/tsconfig-utils" "8.39.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
"@typescript-eslint/visitor-keys" "8.39.0"
|
||||
debug "^4.3.4"
|
||||
fast-glob "^3.3.2"
|
||||
is-glob "^4.0.3"
|
||||
@@ -3804,22 +3797,22 @@
|
||||
semver "^7.6.0"
|
||||
ts-api-utils "^2.1.0"
|
||||
|
||||
"@typescript-eslint/utils@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.37.0.tgz#189ea59b2709f5d898614611f091a776751ee335"
|
||||
integrity sha512-TSFvkIW6gGjN2p6zbXo20FzCABbyUAuq6tBvNRGsKdsSQ6a7rnV6ADfZ7f4iI3lIiXc4F4WWvtUfDw9CJ9pO5A==
|
||||
"@typescript-eslint/utils@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.39.0.tgz#dfea42f3c7ec85f9f3e994ff0bba8f3b2f09e220"
|
||||
integrity sha512-4GVSvNA0Vx1Ktwvf4sFE+exxJ3QGUorQG1/A5mRfRNZtkBT2xrA/BCO2H0eALx/PnvCS6/vmYwRdDA41EoffkQ==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.7.0"
|
||||
"@typescript-eslint/scope-manager" "8.37.0"
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/typescript-estree" "8.37.0"
|
||||
"@typescript-eslint/scope-manager" "8.39.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
"@typescript-eslint/typescript-estree" "8.39.0"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.37.0":
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.37.0.tgz#cdb6a6bd3e8d6dd69bd70c1bdda36e2d18737455"
|
||||
integrity sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==
|
||||
"@typescript-eslint/visitor-keys@8.39.0":
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.39.0.tgz#5d619a6e810cdd3fd1913632719cbccab08bf875"
|
||||
integrity sha512-ldgiJ+VAhQCfIjeOgu8Kj5nSxds0ktPOSO9p4+0VDH2R2pLvQraaM5Oen2d7NxzMCm+Sn/vJT+mv2H5u6b/3fA==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.37.0"
|
||||
"@typescript-eslint/types" "8.39.0"
|
||||
eslint-visitor-keys "^4.2.1"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -3966,6 +3959,11 @@ accepts@~1.3.4, accepts@~1.3.8:
|
||||
mime-types "~2.1.34"
|
||||
negotiator "0.6.3"
|
||||
|
||||
acorn-import-phases@^1.0.3:
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7"
|
||||
integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==
|
||||
|
||||
acorn-jsx@^5.0.0, acorn-jsx@^5.3.2:
|
||||
version "5.3.2"
|
||||
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
|
||||
@@ -3978,12 +3976,7 @@ acorn-walk@^8.0.0:
|
||||
dependencies:
|
||||
acorn "^8.11.0"
|
||||
|
||||
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.8.2:
|
||||
version "8.14.1"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.1.tgz#721d5dc10f7d5b5609a891773d47731796935dfb"
|
||||
integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==
|
||||
|
||||
acorn@^8.15.0:
|
||||
acorn@^8.0.0, acorn@^8.0.4, acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.8.2:
|
||||
version "8.15.0"
|
||||
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
|
||||
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
|
||||
@@ -4107,10 +4100,10 @@ ansi-styles@^6.1.0:
|
||||
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
|
||||
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
|
||||
|
||||
antd@^5.26.3:
|
||||
version "5.26.3"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.3.tgz#cbbb7e1b48a972dc7b6ee8b6948f51cc91c263f8"
|
||||
integrity sha512-M/s9Q39h/+G7AWnS6fbNxmAI9waTH4ti022GVEXBLq2j810V1wJ3UOQps13nEilzDNcyxnFN/EIbqIgS7wSYaA==
|
||||
antd@^5.26.7:
|
||||
version "5.26.7"
|
||||
resolved "https://registry.yarnpkg.com/antd/-/antd-5.26.7.tgz#e2f7e37330b27eec0de7a7789767975373f61602"
|
||||
integrity sha512-iCyXN6+i2CUVEOSzzJKfbKeg115qoJhGvSkCh5uzAf9hANwHUOJQhsMn+KtN+Lx/2NQ6wfM7nGZ+7NPNO5Pn1w==
|
||||
dependencies:
|
||||
"@ant-design/colors" "^7.2.1"
|
||||
"@ant-design/cssinjs" "^1.23.0"
|
||||
@@ -4123,7 +4116,7 @@ antd@^5.26.3:
|
||||
"@rc-component/mutate-observer" "^1.1.0"
|
||||
"@rc-component/qrcode" "~1.0.0"
|
||||
"@rc-component/tour" "~1.15.1"
|
||||
"@rc-component/trigger" "^2.2.7"
|
||||
"@rc-component/trigger" "^2.3.0"
|
||||
classnames "^2.5.1"
|
||||
copy-to-clipboard "^3.3.3"
|
||||
dayjs "^1.11.11"
|
||||
@@ -4153,7 +4146,7 @@ antd@^5.26.3:
|
||||
rc-switch "~4.1.0"
|
||||
rc-table "~7.51.1"
|
||||
rc-tabs "~15.6.1"
|
||||
rc-textarea "~1.10.0"
|
||||
rc-textarea "~1.10.1"
|
||||
rc-tooltip "~6.4.0"
|
||||
rc-tree "~5.13.1"
|
||||
rc-tree-select "~5.27.0"
|
||||
@@ -4508,17 +4501,7 @@ braces@^3.0.3, braces@~3.0.2:
|
||||
dependencies:
|
||||
fill-range "^7.1.1"
|
||||
|
||||
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4:
|
||||
version "4.24.4"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.4.tgz#c6b2865a3f08bcb860a0e827389003b9fe686e4b"
|
||||
integrity sha512-KDi1Ny1gSePi1vm0q4oxSF8b4DR44GF4BbmS2YdhPLOEqd8pDviZOGH/GsmRwoWJ2+5Lr085X7naowMwKHDG1A==
|
||||
dependencies:
|
||||
caniuse-lite "^1.0.30001688"
|
||||
electron-to-chromium "^1.5.73"
|
||||
node-releases "^2.0.19"
|
||||
update-browserslist-db "^1.1.1"
|
||||
|
||||
browserslist@^4.25.0:
|
||||
browserslist@^4.0.0, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.25.0:
|
||||
version "4.25.0"
|
||||
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c"
|
||||
integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==
|
||||
@@ -4620,7 +4603,7 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001688, caniuse-lite@^1.0.30001702:
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702:
|
||||
version "1.0.30001714"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001714.tgz#cfd27ff07e6fa20a0f45c7a10d28a0ffeaba2122"
|
||||
integrity sha512-mtgapdwDLSSBnCI3JokHM7oEQBLxiJKVRtg10AxM1AyeiKcM96f0Mkbqeq+1AbiCtvMcHRulAAEMu693JrSWqg==
|
||||
@@ -5622,20 +5605,13 @@ debug@2.6.9:
|
||||
dependencies:
|
||||
ms "2.0.0"
|
||||
|
||||
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.4.0:
|
||||
debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.0.tgz#2b3f2aea2ffeb776477460267377dc8710faba8a"
|
||||
integrity sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==
|
||||
dependencies:
|
||||
ms "^2.1.3"
|
||||
|
||||
debug@^4.3.2, debug@^4.3.4:
|
||||
version "4.3.4"
|
||||
resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz"
|
||||
integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==
|
||||
dependencies:
|
||||
ms "2.1.2"
|
||||
|
||||
decode-named-character-reference@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz#5d6ce68792808901210dac42a8e9853511e2b8bf"
|
||||
@@ -5829,10 +5805,10 @@ dompurify@=3.2.4:
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
dompurify@^3.2.4:
|
||||
version "3.2.5"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.5.tgz#11b108656a5fb72b24d916df17a1421663d7129c"
|
||||
integrity sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==
|
||||
dompurify@^3.2.5:
|
||||
version "3.2.6"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.2.6.tgz#ca040a6ad2b88e2a92dc45f38c79f84a714a1cad"
|
||||
integrity sha512-/2GogDQlohXPZe6D6NOgQvXLPSYBqIWMnZ8zzOhn09REE4eyAzb+Hed3jhoM9OkuaJ8P6ZGTTVWQKAi8ieIzfQ==
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
@@ -5903,11 +5879,6 @@ electron-to-chromium@^1.5.160:
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.170.tgz#9f6697de4339e24da8b234e4492a9ecb91f5989c"
|
||||
integrity sha512-GP+M7aeluQo9uAyiTCxgIj/j+PrWhMlY7LFVj8prlsPljd0Fdg9AprlfUi+OCSFWy9Y5/2D/Jrj9HS8Z4rpKWA==
|
||||
|
||||
electron-to-chromium@^1.5.73:
|
||||
version "1.5.138"
|
||||
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.138.tgz#319e775179bd0889ed96a04d4390d355fb315a44"
|
||||
integrity sha512-FWlQc52z1dXqm+9cCJ2uyFgJkESd+16j6dBEjsgDNuHjBpuIzL8/lRc0uvh1k8RNI6waGo6tcy2DvwkTBJOLDg==
|
||||
|
||||
emoji-regex@^8.0.0:
|
||||
version "8.0.0"
|
||||
resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-8.0.0.tgz#e818fd69ce5ccfcb404594f842963bf53164cc37"
|
||||
@@ -5952,10 +5923,10 @@ encodeurl@~2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
|
||||
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
|
||||
|
||||
enhanced-resolve@^5.17.1:
|
||||
version "5.18.1"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz#728ab082f8b7b6836de51f1637aab5d3b9568faf"
|
||||
integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==
|
||||
enhanced-resolve@^5.17.2:
|
||||
version "5.18.2"
|
||||
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz#7903c5b32ffd4b2143eeb4b92472bd68effd5464"
|
||||
integrity sha512-6Jw4sE1maoRJo3q8MsSIn2onJFbLTOjY9hlx4DZXmOKvLRd1Ok2kXmAGXaafL2+ijsJZ1ClYbl/pmqr9+k4iUQ==
|
||||
dependencies:
|
||||
graceful-fs "^4.2.4"
|
||||
tapable "^2.2.0"
|
||||
@@ -6161,15 +6132,15 @@ escape-string-regexp@^5.0.0:
|
||||
resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz#4683126b500b61762f2dbebace1806e8be31b1c8"
|
||||
integrity sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==
|
||||
|
||||
eslint-config-prettier@^10.1.5:
|
||||
version "10.1.5"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.5.tgz#00c18d7225043b6fbce6a665697377998d453782"
|
||||
integrity sha512-zc1UmCpNltmVY34vuLRV61r1K27sWuX39E+uyUnY8xS2Bex88VV9cugG+UZbRSRGtGyFboj+D8JODyme1plMpw==
|
||||
eslint-config-prettier@^10.1.8:
|
||||
version "10.1.8"
|
||||
resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz#15734ce4af8c2778cc32f0b01b37b0b5cd1ecb97"
|
||||
integrity sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==
|
||||
|
||||
eslint-plugin-prettier@^5.5.1:
|
||||
version "5.5.1"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.1.tgz#470820964de9aedb37e9ce62c3266d2d26d08d15"
|
||||
integrity sha512-dobTkHT6XaEVOo8IO90Q4DOSxnm3Y151QxPJlM/vKC0bVy+d6cVWQZLlFiuZPP0wS6vZwSKeJgKkcS+KfMBlRw==
|
||||
eslint-plugin-prettier@^5.5.3:
|
||||
version "5.5.3"
|
||||
resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz#1f88e9220a72ac8be171eec5f9d4e4d529b5f4a0"
|
||||
integrity sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w==
|
||||
dependencies:
|
||||
prettier-linter-helpers "^1.0.0"
|
||||
synckit "^0.11.7"
|
||||
@@ -6224,10 +6195,10 @@ eslint-visitor-keys@^4.2.1:
|
||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
|
||||
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
|
||||
|
||||
eslint@^9.31.0:
|
||||
version "9.31.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.31.0.tgz#9a488e6da75bbe05785cd62e43c5ea99356d21ba"
|
||||
integrity sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==
|
||||
eslint@^9.32.0:
|
||||
version "9.32.0"
|
||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.32.0.tgz#4ea28df4a8dbc454e1251e0f3aed4bcf4ce50a47"
|
||||
integrity sha512-LSehfdpgMeWcTZkWZVIJl+tkZ2nuSkyyB9C27MZqFWXuph7DvaowgcTvKqxvpLW1JZIk8PN7hFY3Rj9LQ7m7lg==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.2.0"
|
||||
"@eslint-community/regexpp" "^4.12.1"
|
||||
@@ -6235,8 +6206,8 @@ eslint@^9.31.0:
|
||||
"@eslint/config-helpers" "^0.3.0"
|
||||
"@eslint/core" "^0.15.0"
|
||||
"@eslint/eslintrc" "^3.3.1"
|
||||
"@eslint/js" "9.31.0"
|
||||
"@eslint/plugin-kit" "^0.3.1"
|
||||
"@eslint/js" "9.32.0"
|
||||
"@eslint/plugin-kit" "^0.3.4"
|
||||
"@humanfs/node" "^0.16.6"
|
||||
"@humanwhocodes/module-importer" "^1.0.1"
|
||||
"@humanwhocodes/retry" "^0.4.2"
|
||||
@@ -7988,7 +7959,7 @@ jsonfile@^6.0.1:
|
||||
object.assign "^4.1.4"
|
||||
object.values "^1.1.6"
|
||||
|
||||
katex@^0.16.9:
|
||||
katex@^0.16.22:
|
||||
version "0.16.22"
|
||||
resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.22.tgz#d2b3d66464b1e6d69e6463b28a86ced5a02c5ccd"
|
||||
integrity sha512-XCHRdUw4lf3SKBaJe4EvgqIuWwkPSo9XoeO8GjQW94Bp7TWv9hNhzZjZ+OH9yf1UmLygb7DIT5GSFQiyt16zYg==
|
||||
@@ -8063,10 +8034,10 @@ less-loader@^12.3.0:
|
||||
resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340"
|
||||
integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw==
|
||||
|
||||
less@^4.3.0:
|
||||
version "4.3.0"
|
||||
resolved "https://registry.yarnpkg.com/less/-/less-4.3.0.tgz#ef0cfc260a9ca8079ed8d0e3512bda8a12c82f2a"
|
||||
integrity sha512-X9RyH9fvemArzfdP8Pi3irr7lor2Ok4rOttDXBhlwDg+wKQsXOXgHWduAJE1EsF7JJx0w0bcO6BC6tCKKYnXKA==
|
||||
less@^4.4.0:
|
||||
version "4.4.0"
|
||||
resolved "https://registry.yarnpkg.com/less/-/less-4.4.0.tgz#deaf881f4880ee80691beae925b8fac699d3a76d"
|
||||
integrity sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w==
|
||||
dependencies:
|
||||
copy-anything "^2.0.1"
|
||||
parse-node-version "^1.0.1"
|
||||
@@ -8234,10 +8205,10 @@ markdown-table@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/markdown-table/-/markdown-table-3.0.4.tgz#fe44d6d410ff9d6f2ea1797a3f60aa4d2b631c2a"
|
||||
integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==
|
||||
|
||||
marked@^15.0.7:
|
||||
version "15.0.8"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-15.0.8.tgz#39873a3fdf91a520111e48aeb2ef3746d58d7166"
|
||||
integrity sha512-rli4l2LyZqpQuRve5C0rkn6pj3hT8EWPC+zkAxFTAJLxRbENfTAhEQq9itrmf1Y81QtAX5D/MYlGlIomNgj9lA==
|
||||
marked@^16.0.0:
|
||||
version "16.2.0"
|
||||
resolved "https://registry.yarnpkg.com/marked/-/marked-16.2.0.tgz#c407a4f7ed3acc1110812525cfd1b0ed8502792c"
|
||||
integrity sha512-LbbTuye+0dWRz2TS9KJ7wsnD4KAtpj0MVkWc90XvBa6AslXsT0hTBVH5k32pcSyHH1fst9XEFJunXHktVy0zlg==
|
||||
|
||||
math-intrinsics@^1.1.0:
|
||||
version "1.1.0"
|
||||
@@ -8500,13 +8471,13 @@ merge2@^1.3.0, merge2@^1.4.1:
|
||||
integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==
|
||||
|
||||
mermaid@>=11.6.0:
|
||||
version "11.6.0"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.6.0.tgz#eee45cdc3087be561a19faf01745596d946bb575"
|
||||
integrity sha512-PE8hGUy1LDlWIHWBP05SFdqUHGmRcCcK4IzpOKPE35eOw+G9zZgcnMpyunJVUEOgb//KBORPjysKndw8bFLuRg==
|
||||
version "11.10.0"
|
||||
resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.10.0.tgz#4949f98d08cfdc4cda429372ed2f843a64c99946"
|
||||
integrity sha512-oQsFzPBy9xlpnGxUqLbVY8pvknLlsNIJ0NWwi8SUJjhbP1IT0E0o1lfhU4iYV3ubpy+xkzkaOyDUQMn06vQElQ==
|
||||
dependencies:
|
||||
"@braintree/sanitize-url" "^7.0.4"
|
||||
"@iconify/utils" "^2.1.33"
|
||||
"@mermaid-js/parser" "^0.4.0"
|
||||
"@mermaid-js/parser" "^0.6.2"
|
||||
"@types/d3" "^7.4.3"
|
||||
cytoscape "^3.29.3"
|
||||
cytoscape-cose-bilkent "^4.1.0"
|
||||
@@ -8515,11 +8486,11 @@ mermaid@>=11.6.0:
|
||||
d3-sankey "^0.12.3"
|
||||
dagre-d3-es "7.0.11"
|
||||
dayjs "^1.11.13"
|
||||
dompurify "^3.2.4"
|
||||
katex "^0.16.9"
|
||||
dompurify "^3.2.5"
|
||||
katex "^0.16.22"
|
||||
khroma "^2.1.0"
|
||||
lodash-es "^4.17.21"
|
||||
marked "^15.0.7"
|
||||
marked "^16.0.0"
|
||||
roughjs "^4.6.6"
|
||||
stylis "^4.3.6"
|
||||
ts-dedent "^2.2.0"
|
||||
@@ -9069,11 +9040,6 @@ ms@2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8"
|
||||
integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==
|
||||
|
||||
ms@2.1.2:
|
||||
version "2.1.2"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
|
||||
integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
|
||||
|
||||
ms@2.1.3, ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
@@ -10714,10 +10680,10 @@ rc-tabs@~15.6.1:
|
||||
rc-resize-observer "^1.0.0"
|
||||
rc-util "^5.34.1"
|
||||
|
||||
rc-textarea@~1.10.0:
|
||||
version "1.10.0"
|
||||
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.0.tgz#f8f962ef83be0b8e35db97cf03dbfb86ddd9c46c"
|
||||
integrity sha512-ai9IkanNuyBS4x6sOL8qu/Ld40e6cEs6pgk93R+XLYg0mDSjNBGey6/ZpDs5+gNLD7urQ14po3V6Ck2dJLt9SA==
|
||||
rc-textarea@~1.10.0, rc-textarea@~1.10.1:
|
||||
version "1.10.2"
|
||||
resolved "https://registry.yarnpkg.com/rc-textarea/-/rc-textarea-1.10.2.tgz#459e3574a95c32939c6793045a1e4db04cb514cc"
|
||||
integrity sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.10.1"
|
||||
classnames "^2.2.1"
|
||||
@@ -12100,10 +12066,10 @@ swagger-client@^3.35.5:
|
||||
ramda "^0.30.1"
|
||||
ramda-adjunct "^5.1.0"
|
||||
|
||||
swagger-ui-react@^5.26.0:
|
||||
version "5.26.0"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.26.0.tgz#b15a903d556cc0ec2a56a969beb9d5bc9ea52910"
|
||||
integrity sha512-4e6bP9bdJyh+SqQW0lxulPn/SDno4+oWrKXsuon5Z9kjtV0zeoWEJ1c70Qxp8kN/c3caFwec8OyxDNhvo14pkw==
|
||||
swagger-ui-react@^5.27.1:
|
||||
version "5.27.1"
|
||||
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.27.1.tgz#315b59970c33933a5f62ca0f702789741dcedc7c"
|
||||
integrity sha512-wwDoavIeJI/Pwiavn32FMJ5dfptz0BAOKjSrj7EdU22QdP3gdk9+MZHdzzjxWURmVj0kc0XoQfsFgjln0toJaw==
|
||||
dependencies:
|
||||
"@babel/runtime-corejs3" "^7.27.1"
|
||||
"@scarf/scarf" "=1.4.0"
|
||||
@@ -12382,15 +12348,15 @@ types-ramda@^0.30.0:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.37.0:
|
||||
version "8.37.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.37.0.tgz#2235ddfa40cdbdadb1afb05f8bda688a2294b4c2"
|
||||
integrity sha512-TnbEjzkE9EmcO0Q2zM+GE8NQLItNAJpMmED1BdgoBMYNdqMhzlbqfdSwiRlAzEK2pA9UzVW0gzaaIzXWg2BjfA==
|
||||
typescript-eslint@^8.39.0:
|
||||
version "8.39.0"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.39.0.tgz#b19c1a925cf8566831ae3875d2881ee2349808a5"
|
||||
integrity sha512-lH8FvtdtzcHJCkMOKnN73LIn6SLTpoojgJqDAxPm1jCR14eWSGPX8ul/gggBdPMk/d5+u9V854vTYQ8T5jF/1Q==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.37.0"
|
||||
"@typescript-eslint/parser" "8.37.0"
|
||||
"@typescript-eslint/typescript-estree" "8.37.0"
|
||||
"@typescript-eslint/utils" "8.37.0"
|
||||
"@typescript-eslint/eslint-plugin" "8.39.0"
|
||||
"@typescript-eslint/parser" "8.39.0"
|
||||
"@typescript-eslint/typescript-estree" "8.39.0"
|
||||
"@typescript-eslint/utils" "8.39.0"
|
||||
|
||||
typescript@~5.8.3:
|
||||
version "5.8.3"
|
||||
@@ -12525,7 +12491,7 @@ unraw@^3.0.0:
|
||||
resolved "https://registry.npmjs.org/unraw/-/unraw-3.0.0.tgz"
|
||||
integrity sha512-08/DA66UF65OlpUDIQtbJyrqTR0jTAlJ+jsnkQ4jxR7+K5g5YG1APZKQSMCE1vqqmD+2pv6+IdEjmopFatacvg==
|
||||
|
||||
update-browserslist-db@^1.1.1, update-browserslist-db@^1.1.3:
|
||||
update-browserslist-db@^1.1.3:
|
||||
version "1.1.3"
|
||||
resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz#348377dd245216f9e7060ff50b15a1b740b75420"
|
||||
integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==
|
||||
@@ -12794,26 +12760,27 @@ webpack-merge@^6.0.1:
|
||||
flat "^5.0.2"
|
||||
wildcard "^2.0.1"
|
||||
|
||||
webpack-sources@^3.2.3:
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
|
||||
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==
|
||||
webpack-sources@^3.3.3:
|
||||
version "3.3.3"
|
||||
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723"
|
||||
integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==
|
||||
|
||||
webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.9:
|
||||
version "5.99.9"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.99.9.tgz#d7de799ec17d0cce3c83b70744b4aedb537d8247"
|
||||
integrity sha512-brOPwM3JnmOa+7kd3NsmOUOwbDAj8FT9xDsG3IW0MgbN9yZV7Oi/s/+MNQ/EcSMqw7qfoRyXPoeEWT8zLVdVGg==
|
||||
webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
|
||||
version "5.101.0"
|
||||
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.101.0.tgz#4b81407ffad9857f81ff03f872e3369b9198cc9d"
|
||||
integrity sha512-B4t+nJqytPeuZlHuIKTbalhljIFXeNRqrUGAQgTGlfOl2lXXKXw+yZu6bicycP+PUlM44CxBjCFD6aciKFT3LQ==
|
||||
dependencies:
|
||||
"@types/eslint-scope" "^3.7.7"
|
||||
"@types/estree" "^1.0.6"
|
||||
"@types/estree" "^1.0.8"
|
||||
"@types/json-schema" "^7.0.15"
|
||||
"@webassemblyjs/ast" "^1.14.1"
|
||||
"@webassemblyjs/wasm-edit" "^1.14.1"
|
||||
"@webassemblyjs/wasm-parser" "^1.14.1"
|
||||
acorn "^8.14.0"
|
||||
acorn "^8.15.0"
|
||||
acorn-import-phases "^1.0.3"
|
||||
browserslist "^4.24.0"
|
||||
chrome-trace-event "^1.0.2"
|
||||
enhanced-resolve "^5.17.1"
|
||||
enhanced-resolve "^5.17.2"
|
||||
es-module-lexer "^1.2.1"
|
||||
eslint-scope "5.1.1"
|
||||
events "^3.2.0"
|
||||
@@ -12827,7 +12794,7 @@ webpack@^5.88.1, webpack@^5.95.0, webpack@^5.99.9:
|
||||
tapable "^2.1.1"
|
||||
terser-webpack-plugin "^5.3.11"
|
||||
watchpack "^2.4.1"
|
||||
webpack-sources "^3.2.3"
|
||||
webpack-sources "^3.3.3"
|
||||
|
||||
webpackbar@^6.0.1:
|
||||
version "6.0.1"
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
# limitations under the License.
|
||||
#
|
||||
apiVersion: v2
|
||||
appVersion: "4.1.2"
|
||||
appVersion: "5.0.0"
|
||||
description: Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
name: superset
|
||||
icon: https://artifacthub.io/image/68c1d717-0e97-491f-b046-754e46f46922@2x
|
||||
@@ -29,7 +29,7 @@ maintainers:
|
||||
- name: craig-rueda
|
||||
email: craig@craigrueda.com
|
||||
url: https://github.com/craig-rueda
|
||||
version: 0.14.3
|
||||
version: 0.15.0 # See [README](https://github.com/apache/superset/blob/master/helm/superset/README.md#versioning) for version details.
|
||||
dependencies:
|
||||
- name: postgresql
|
||||
version: 13.4.4
|
||||
|
||||
@@ -23,7 +23,7 @@ NOTE: This file is generated by helm-docs: https://github.com/norwoodj/helm-docs
|
||||
|
||||
# superset
|
||||
|
||||

|
||||

|
||||
|
||||
Apache Superset is a modern, enterprise-ready business intelligence web application
|
||||
|
||||
@@ -336,3 +336,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
| supersetWorker.topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to supersetWorker deployments |
|
||||
| tolerations | list | `[]` | |
|
||||
| topologySpreadConstraints | list | `[]` | TopologySpreadConstrains to be added to all deployments |
|
||||
|
||||
## Versioning
|
||||
This chart follows [semantic versioning](https://semver.org/). The chart version is independent of the Superset version. The chart version is incremented when there are changes to the chart itself, such as new features, bug fixes, or changes in configuration options. In addition to semver, the chart version is also incremented in the minor version when there is a breaking change in the Superset appVersion itself. When there are non-breaking changes in the Superset appVersion, the chart version is incremented in the patch version.
|
||||
|
||||
@@ -48,3 +48,6 @@ On helm this can be set on `extraSecretEnv.SUPERSET_SECRET_KEY` or `configOverri
|
||||
{{ template "chart.requirementsSection" . }}
|
||||
|
||||
{{ template "chart.valuesSection" . }}
|
||||
|
||||
## Versioning
|
||||
This chart follows [semantic versioning](https://semver.org/). The chart version is independent of the Superset version. The chart version is incremented when there are changes to the chart itself, such as new features, bug fixes, or changes in configuration options. In addition to semver, the chart version is also incremented in the minor version when there is a breaking change in the Superset appVersion itself. When there are non-breaking changes in the Superset appVersion, the chart version is incremented in the patch version.
|
||||
|
||||
@@ -79,12 +79,13 @@ dependencies = [
|
||||
"parsedatetime",
|
||||
"paramiko>=3.4.0",
|
||||
"pgsanity",
|
||||
"Pillow>=11.0.0, <12",
|
||||
"polyline>=2.0.0, <3.0",
|
||||
"pyparsing>=3.0.6, <4",
|
||||
"python-dateutil",
|
||||
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
|
||||
"python-geohash",
|
||||
"pyarrow>=18.1.0, <19",
|
||||
"pyarrow>=16.1.0, <17", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.0, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=4.6.0, <5.0",
|
||||
@@ -181,7 +182,7 @@ tdengine = [
|
||||
"taos-ws-py>=0.3.8"
|
||||
]
|
||||
teradata = ["teradatasql>=16.20.0.23"]
|
||||
thumbnails = ["Pillow>=10.0.1, <11"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>=0.5.9, < 0.6"]
|
||||
netezza = ["nzalchemy>=11.0.2"]
|
||||
starrocks = ["starrocks>=1.0.0"]
|
||||
@@ -195,6 +196,7 @@ development = [
|
||||
"grpcio>=1.55.3",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
"pre-commit",
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
@@ -399,6 +401,7 @@ authorized_licenses = [
|
||||
"isc license (iscl)",
|
||||
"isc license",
|
||||
"mit",
|
||||
"mit-cmu",
|
||||
"mozilla public license 2.0 (mpl 2.0)",
|
||||
"osi approved",
|
||||
"osi approved",
|
||||
|
||||
@@ -266,6 +266,8 @@ parsedatetime==2.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
pgsanity==0.2.9
|
||||
# via apache-superset (pyproject.toml)
|
||||
pillow==11.3.0
|
||||
# via apache_superset (pyproject.toml)
|
||||
platformdirs==4.3.8
|
||||
# via requests-cache
|
||||
ply==3.11
|
||||
@@ -276,7 +278,7 @@ prison==0.2.1
|
||||
# via flask-appbuilder
|
||||
prompt-toolkit==3.0.51
|
||||
# via click-repl
|
||||
pyarrow==18.1.0
|
||||
pyarrow==16.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
pyasn1==0.6.1
|
||||
# via
|
||||
|
||||
@@ -537,10 +537,12 @@ pgsanity==0.2.9
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
pillow==10.3.0
|
||||
pillow==11.3.0
|
||||
# via
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pip==25.1.1
|
||||
# via apache-superset
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
@@ -586,7 +588,7 @@ psutil==6.1.0
|
||||
# via apache-superset
|
||||
psycopg2-binary==2.9.6
|
||||
# via apache-superset
|
||||
pyarrow==18.1.0
|
||||
pyarrow==16.1.0
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
# apache-superset
|
||||
|
||||
@@ -33,4 +33,4 @@ superset load-test-users
|
||||
|
||||
echo "Running tests"
|
||||
|
||||
pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
||||
pytest --durations-min=2 --cov-report= --cov=superset ./tests/integration_tests "$@"
|
||||
|
||||
@@ -403,6 +403,7 @@ module.exports = {
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': ['error', true],
|
||||
'i18n-strings/sentence-case-buttons': 'error',
|
||||
camelcase: [
|
||||
'error',
|
||||
{
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { LOGIN } from 'cypress/utils/urls';
|
||||
|
||||
function interceptLogin() {
|
||||
cy.intercept('POST', '/login/').as('login');
|
||||
cy.intercept('POST', '**/login/').as('login');
|
||||
}
|
||||
|
||||
describe('Login view', () => {
|
||||
|
||||
@@ -94,67 +94,12 @@ describe('Charts list', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('list mode', () => {
|
||||
before(() => {
|
||||
visitChartList();
|
||||
setGridMode('list');
|
||||
});
|
||||
|
||||
it('should load rows in list mode', () => {
|
||||
cy.getBySel('listview-table').should('be.visible');
|
||||
cy.getBySel('sort-header').eq(1).contains('Name');
|
||||
cy.getBySel('sort-header').eq(2).contains('Type');
|
||||
cy.getBySel('sort-header').eq(3).contains('Dataset');
|
||||
cy.getBySel('sort-header').eq(4).contains('On dashboards');
|
||||
cy.getBySel('sort-header').eq(5).contains('Owners');
|
||||
cy.getBySel('sort-header').eq(6).contains('Last modified');
|
||||
cy.getBySel('sort-header').eq(7).contains('Actions');
|
||||
});
|
||||
|
||||
it('should bulk select in list mode', () => {
|
||||
toggleBulkSelect();
|
||||
cy.get('[aria-label="Select all"]').click();
|
||||
cy.get('input[type="checkbox"]:checked').should('have.length', 26);
|
||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
||||
cy.getBySel('bulk-select-action')
|
||||
.should('have.length', 2)
|
||||
.then($btns => {
|
||||
expect($btns).to.contain('Delete');
|
||||
expect($btns).to.contain('Export');
|
||||
});
|
||||
cy.getBySel('bulk-select-deselect-all').click();
|
||||
cy.get('input[type="checkbox"]:checked').should('have.length', 0);
|
||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
||||
cy.getBySel('bulk-select-action').should('not.exist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('card mode', () => {
|
||||
before(() => {
|
||||
visitChartList();
|
||||
setGridMode('card');
|
||||
});
|
||||
|
||||
it('should load rows in card mode', () => {
|
||||
cy.getBySel('listview-table').should('not.exist');
|
||||
cy.getBySel('styled-card').should('have.length', 25);
|
||||
});
|
||||
|
||||
it('should bulk select in card mode', () => {
|
||||
toggleBulkSelect();
|
||||
cy.getBySel('styled-card').click({ multiple: true });
|
||||
cy.getBySel('bulk-select-copy').contains('25 Selected');
|
||||
cy.getBySel('bulk-select-action')
|
||||
.should('have.length', 2)
|
||||
.then($btns => {
|
||||
expect($btns).to.contain('Delete');
|
||||
expect($btns).to.contain('Export');
|
||||
});
|
||||
cy.getBySel('bulk-select-deselect-all').click();
|
||||
cy.getBySel('bulk-select-copy').contains('0 Selected');
|
||||
cy.getBySel('bulk-select-action').should('not.exist');
|
||||
});
|
||||
|
||||
it('should preserve other filters when sorting', () => {
|
||||
cy.getBySel('styled-card').should('have.length', 25);
|
||||
setFilter('Type', 'Big Number');
|
||||
|
||||
@@ -31,6 +31,52 @@ import {
|
||||
interceptFormDataKey,
|
||||
} from '../explore/utils';
|
||||
|
||||
const interceptDrillInfo = () => {
|
||||
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
|
||||
statusCode: 200,
|
||||
body: {
|
||||
result: {
|
||||
id: 1,
|
||||
changed_on_humanized: '2 days ago',
|
||||
created_on_humanized: 'a week ago',
|
||||
table_name: 'birth_names',
|
||||
changed_by: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
created_by: {
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
owners: [
|
||||
{
|
||||
first_name: 'Admin',
|
||||
last_name: 'User',
|
||||
},
|
||||
],
|
||||
columns: [
|
||||
{
|
||||
column_name: 'gender',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'state',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'name',
|
||||
verbose_name: null,
|
||||
},
|
||||
{
|
||||
column_name: 'ds',
|
||||
verbose_name: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}).as('drillInfo');
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
cy.get('body').then($body => {
|
||||
if ($body.find('[data-test="close-drill-by-modal"]').length) {
|
||||
@@ -62,14 +108,20 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
{ timeout: 15000 },
|
||||
)
|
||||
.should('be.visible')
|
||||
.find('[role="menuitem"]')
|
||||
.then($el => {
|
||||
cy.wrap($el)
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.trigger('keydown', { keyCode: 13, which: 13, force: true });
|
||||
});
|
||||
.contains(new RegExp(`^${targetDrillByColumn}$`))
|
||||
.click();
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
|
||||
|
||||
cy.get(
|
||||
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
|
||||
).should('not.exist');
|
||||
|
||||
if (isLegacy) {
|
||||
return cy.wait('@legacyData');
|
||||
@@ -230,17 +282,19 @@ describe('Drill by modal', () => {
|
||||
closeModal();
|
||||
});
|
||||
before(() => {
|
||||
interceptDrillInfo();
|
||||
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
|
||||
});
|
||||
|
||||
describe('Modal actions + Table', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 1');
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
it('opens the modal from the context menu', () => {
|
||||
it.only('opens the modal from the context menu', () => {
|
||||
openTableContextMenu('boy');
|
||||
drillBy('state').then(intercepted => {
|
||||
verifyExpectedFormData(intercepted, {
|
||||
@@ -384,6 +438,7 @@ describe('Drill by modal', () => {
|
||||
describe('Tier 1 charts', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 1');
|
||||
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
@@ -547,6 +602,7 @@ describe('Drill by modal', () => {
|
||||
describe('Tier 2 charts', () => {
|
||||
before(() => {
|
||||
closeModal();
|
||||
interceptDrillInfo();
|
||||
openTopLevelTab('Tier 2');
|
||||
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
|
||||
});
|
||||
|
||||
@@ -155,7 +155,7 @@ describe('Horizontal FilterBar', () => {
|
||||
]);
|
||||
setFilterBarOrientation('horizontal');
|
||||
|
||||
cy.get('.filter-item-wrapper').should('have.length', 3);
|
||||
cy.get('.filter-item-wrapper').should('have.length', 4);
|
||||
openMoreFilters();
|
||||
cy.getBySel('form-item-value').should('have.length', 12);
|
||||
cy.getBySel('filter-control-name').contains('test_3').should('be.visible');
|
||||
|
||||
@@ -160,6 +160,74 @@ describe('Native filters', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('Dependent filter selects first item based on parent filter selection', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
{ name: 'country_name', column: 'country_name', datasetId: 2 },
|
||||
]);
|
||||
|
||||
enterNativeFilterEditModal();
|
||||
|
||||
selectFilter(0);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
selectFilter(1);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Values are dependent on other filters')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Can select multiple values ')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
|
||||
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
|
||||
() => {
|
||||
cy.contains('Select first filter value by default')
|
||||
.should('be.visible')
|
||||
.click();
|
||||
},
|
||||
);
|
||||
|
||||
// cannot use saveNativeFilterSettings because there is a bug which
|
||||
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
|
||||
// to be saved when using dependent filters so,
|
||||
// you reload the window.
|
||||
cy.get(nativeFilters.modal.footer)
|
||||
.contains('Save')
|
||||
.should('be.visible')
|
||||
.click({ force: true });
|
||||
|
||||
cy.get(nativeFilters.modal.container).should('not.exist');
|
||||
cy.reload();
|
||||
|
||||
applyNativeFilterValueWithIndex(0, 'North America');
|
||||
|
||||
// Check that dependent filter auto-selects the first item
|
||||
cy.get(nativeFilters.filterFromDashboardView.filterContent)
|
||||
.eq(1)
|
||||
.should('contain.text', 'Bermuda');
|
||||
});
|
||||
|
||||
it('User can create filter depend on 2 other filters', () => {
|
||||
prepareDashboardFilters([
|
||||
{ name: 'region', column: 'region', datasetId: 2 },
|
||||
@@ -275,7 +343,7 @@ describe('Native filters', () => {
|
||||
it('User can delete a native filter', () => {
|
||||
enterNativeFilterEditModal(false);
|
||||
cy.get(nativeFilters.filtersList.removeIcon).first().click();
|
||||
cy.contains('Restore Filter').should('not.exist', { timeout: 10000 });
|
||||
cy.contains('Restore filter').should('not.exist', { timeout: 10000 });
|
||||
});
|
||||
|
||||
it('User can cancel creating a new filter', () => {
|
||||
|
||||
@@ -68,11 +68,13 @@ function verifyDashboardSearch() {
|
||||
function verifyDashboardLink() {
|
||||
interceptDashboardGet();
|
||||
openDashboardsAddedTo();
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
|
||||
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
|
||||
force: true,
|
||||
});
|
||||
cy.get('.ant-dropdown-menu-submenu-popup a')
|
||||
.first()
|
||||
.invoke('removeAttr', 'target')
|
||||
.click();
|
||||
.click({ force: true });
|
||||
cy.wait('@get');
|
||||
}
|
||||
|
||||
|
||||
20
superset-frontend/cypress-base/package-lock.json
generated
20
superset-frontend/cypress-base/package-lock.json
generated
@@ -10227,14 +10227,11 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/tmp": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
|
||||
"integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
|
||||
"dependencies": {
|
||||
"rimraf": "^3.0.0"
|
||||
},
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ==",
|
||||
"engines": {
|
||||
"node": ">=8.17.0"
|
||||
"node": ">=14.14"
|
||||
}
|
||||
},
|
||||
"node_modules/to-regex-range": {
|
||||
@@ -18598,12 +18595,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"tmp": {
|
||||
"version": "0.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz",
|
||||
"integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==",
|
||||
"requires": {
|
||||
"rimraf": "^3.0.0"
|
||||
}
|
||||
"version": "0.2.4",
|
||||
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.4.tgz",
|
||||
"integrity": "sha512-UdiSoX6ypifLmrfQ/XfiawN6hkjSBpCjhKxxZcWlUUmoXLaCKQU0bx4HF/tdDK2uzRuchf1txGvrWBzYREssoQ=="
|
||||
},
|
||||
"to-regex-range": {
|
||||
"version": "5.0.1",
|
||||
|
||||
@@ -41,7 +41,7 @@ module.exports = {
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can’t handle strings that include variables",
|
||||
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -52,5 +52,67 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
},
|
||||
'sentence-case-buttons': {
|
||||
create(context) {
|
||||
function isTitleCase(str) {
|
||||
// Match "Delete Dataset", "Create Chart", etc. (2+ title-cased words)
|
||||
return /^[A-Z][a-z]+(\s+[A-Z][a-z]*)+$/.test(str);
|
||||
}
|
||||
|
||||
function isButtonContext(node) {
|
||||
const { parent } = node;
|
||||
if (!parent) return false;
|
||||
|
||||
// Check for button-specific props
|
||||
if (parent.type === 'Property') {
|
||||
const key = parent.key.name;
|
||||
return [
|
||||
'primaryButtonName',
|
||||
'secondaryButtonName',
|
||||
'confirmButtonText',
|
||||
'cancelButtonText',
|
||||
].includes(key);
|
||||
}
|
||||
|
||||
// Check for Button components
|
||||
if (parent.type === 'JSXExpressionContainer') {
|
||||
const jsx = parent.parent;
|
||||
if (jsx?.type === 'JSXElement') {
|
||||
const elementName = jsx.openingElement.name.name;
|
||||
return elementName === 'Button';
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function handler(node) {
|
||||
if (node.arguments.length) {
|
||||
const firstArg = node.arguments[0];
|
||||
if (
|
||||
firstArg.type === 'Literal' &&
|
||||
typeof firstArg.value === 'string'
|
||||
) {
|
||||
const text = firstArg.value;
|
||||
|
||||
if (isButtonContext(node) && isTitleCase(text)) {
|
||||
const sentenceCase = text
|
||||
.toLowerCase()
|
||||
.replace(/^\w/, c => c.toUpperCase());
|
||||
context.report({
|
||||
node: firstArg,
|
||||
message: `Button text should use sentence case: "${text}" should be "${sentenceCase}"`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
"CallExpression[callee.name='t']": handler,
|
||||
"CallExpression[callee.name='tn']": handler,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
3566
superset-frontend/package-lock.json
generated
3566
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -88,7 +88,7 @@
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^5.21.1",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.9",
|
||||
"@rjsf/validator-ajv8": "^5.24.12",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -121,15 +121,13 @@
|
||||
"@visx/scale": "^3.5.0",
|
||||
"@visx/tooltip": "^3.0.0",
|
||||
"@visx/xychart": "^3.5.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "34.0.2",
|
||||
"antd": "^5.24.6",
|
||||
"chrono-node": "^2.7.8",
|
||||
"classnames": "^2.2.5",
|
||||
"d3-color": "^3.1.0",
|
||||
"d3-scale": "^2.1.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"dom-to-image-more": "^3.2.0",
|
||||
"dom-to-image-more": "^3.6.0",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^5.6.0",
|
||||
"emotion-rgba": "0.0.12",
|
||||
@@ -144,7 +142,7 @@
|
||||
"geostyler-qgis-parser": "2.0.1",
|
||||
"geostyler-style": "7.5.0",
|
||||
"geostyler-wfs-parser": "^2.0.3",
|
||||
"googleapis": "^130.0.0",
|
||||
"googleapis": "^154.1.0",
|
||||
"immer": "^10.1.1",
|
||||
"interweave": "^13.1.0",
|
||||
"jquery": "^3.7.1",
|
||||
@@ -167,7 +165,6 @@
|
||||
"re-resizable": "^6.10.1",
|
||||
"react": "^17.0.2",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-color": "^2.13.8",
|
||||
"react-diff-viewer-continued": "^3.4.0",
|
||||
"react-dnd": "^11.1.3",
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
@@ -176,7 +173,7 @@
|
||||
"react-hot-loader": "^4.13.1",
|
||||
"react-intersection-observer": "^9.16.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.15.4",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
@@ -208,7 +205,7 @@
|
||||
"devDependencies": {
|
||||
"@applitools/eyes-storybook": "^3.55.6",
|
||||
"@babel/cli": "^7.27.2",
|
||||
"@babel/compat-data": "^7.26.8",
|
||||
"@babel/compat-data": "^7.28.0",
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/eslint-parser": "^7.25.9",
|
||||
"@babel/node": "^7.22.6",
|
||||
@@ -216,11 +213,11 @@
|
||||
"@babel/plugin-transform-modules-commonjs": "^7.26.3",
|
||||
"@babel/plugin-transform-runtime": "^7.27.1",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@babel/register": "^7.23.7",
|
||||
"@babel/runtime": "^7.26.0",
|
||||
"@babel/runtime-corejs3": "^7.26.0",
|
||||
"@babel/runtime": "^7.28.2",
|
||||
"@babel/runtime-corejs3": "^7.28.2",
|
||||
"@babel/types": "^7.26.9",
|
||||
"@cypress/react": "^8.0.2",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
@@ -243,7 +240,6 @@
|
||||
"@testing-library/react": "^12.1.5",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/classnames": "^2.2.10",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
@@ -285,7 +281,7 @@
|
||||
"eslint-config-airbnb": "^19.0.4",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^3.7.0",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
"eslint-plugin-file-progress": "^1.5.0",
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
@@ -331,13 +327,13 @@
|
||||
"ts-jest": "^29.4.0",
|
||||
"ts-loader": "^9.5.1",
|
||||
"tscw-config": "^1.1.2",
|
||||
"tsx": "^4.19.2",
|
||||
"tsx": "^4.20.3",
|
||||
"typescript": "5.4.5",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-bundle-analyzer": "^4.10.1",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-dev-server": "^5.2.1",
|
||||
"webpack-dev-server": "^5.2.2",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-sources": "^3.3.3",
|
||||
"webpack-visualizer-plugin2": "^1.2.0"
|
||||
@@ -345,6 +341,7 @@
|
||||
"peerDependencies": {
|
||||
"ace-builds": "^1.41.0",
|
||||
"core-js": "^3.38.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"react-ace": "^10.1.0",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
export interface <%= packageLabel %>StylesProps {
|
||||
height: number;
|
||||
width: number;
|
||||
headerFontSize: keyof typeof supersetTheme.typography.sizes;
|
||||
headerFontSize: 'fontSizeSM' | 'fontSize' | 'fontSizeLG' | 'fontSizeXL' | 'fontSizeHeading1' | 'fontSizeHeading2' | 'fontSizeHeading3' | 'fontSizeHeading4' | 'fontSizeHeading5';
|
||||
boldText: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"devDependencies": {
|
||||
"cross-env": "^7.0.3",
|
||||
"fs-extra": "^11.3.0",
|
||||
"jest": "^30.0.2",
|
||||
"jest": "^30.0.5",
|
||||
"yeoman-test": "^10.1.1"
|
||||
},
|
||||
"engines": {
|
||||
|
||||
@@ -24,7 +24,7 @@ import { css, styled, useTheme, t } from '@superset-ui/core';
|
||||
|
||||
const StyledCalculatorIcon = styled(CalculatorOutlined)`
|
||||
${({ theme }) => css`
|
||||
color: ${theme.colors.grayscale.base};
|
||||
color: ${theme.colorIcon};
|
||||
font-size: ${theme.fontSizeSM}px;
|
||||
& svg {
|
||||
margin-left: ${theme.sizeUnit}px;
|
||||
|
||||
@@ -36,18 +36,19 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
|
||||
const columns = ensureIsArray(
|
||||
queryObject.series_columns || queryObject.columns,
|
||||
);
|
||||
const timeOffsets = ensureIsArray(formData.time_compare);
|
||||
const { truncate_metric } = formData;
|
||||
const xAxisLabel = getXAxisLabel(formData);
|
||||
const isTimeComparisonValue = isTimeComparison(formData, queryObject);
|
||||
|
||||
// remove or rename top level of column name(metric name) in the MultiIndex when
|
||||
// 1) at least 1 metric
|
||||
// 2) dimension exist
|
||||
// 2) dimension exist or multiple time shift metrics exist
|
||||
// 3) xAxis exist
|
||||
// 4) truncate_metric in form_data and truncate_metric is true
|
||||
if (
|
||||
metrics.length > 0 &&
|
||||
columns.length > 0 &&
|
||||
(columns.length > 0 || timeOffsets.length > 1) &&
|
||||
xAxisLabel &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric
|
||||
@@ -84,7 +85,8 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
|
||||
ComparisonType.Percentage,
|
||||
ComparisonType.Ratio,
|
||||
].includes(formData.comparison_type) &&
|
||||
metrics.length === 1
|
||||
metrics.length === 1 &&
|
||||
renamePairs.length === 0
|
||||
) {
|
||||
renamePairs.push([getMetricLabel(metrics[0]), null]);
|
||||
}
|
||||
|
||||
@@ -24,3 +24,4 @@ export * from './forecastInterval';
|
||||
export * from './chartTitle';
|
||||
export * from './echartsTimeSeriesQuery';
|
||||
export * from './timeComparison';
|
||||
export * from './matrixify';
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* 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 { t } from '@superset-ui/core';
|
||||
import { ControlPanelSectionConfig } from '../types';
|
||||
|
||||
export const matrixifyEnableSection: ControlPanelSectionConfig = {
|
||||
label: t('Enable matrixify'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'matrixify_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Enable matrixify'),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
tabOverride: 'matrixify',
|
||||
};
|
||||
|
||||
export const matrixifySection: ControlPanelSectionConfig = {
|
||||
label: t('Matrixify'),
|
||||
expanded: false,
|
||||
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'matrixify_row_height',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Row height'),
|
||||
default: 300,
|
||||
isInt: true,
|
||||
renderTrigger: true,
|
||||
description: t('Height of each row in pixels'),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'matrixify_fit_columns_dynamically',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Fit columns dynamically'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
description: t('Automatically adjust column width based on content'),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'matrixify_charts_per_row',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Charts per row'),
|
||||
default: 3,
|
||||
isInt: true,
|
||||
renderTrigger: true,
|
||||
description: t(
|
||||
'Number of charts per row when not fitting dynamically',
|
||||
),
|
||||
visibility: ({ controls }) =>
|
||||
!controls?.matrixify_fit_columns_dynamically?.value,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'matrixify_cell_title_template',
|
||||
config: {
|
||||
type: 'TextControl',
|
||||
label: t('Cell title template'),
|
||||
default: '',
|
||||
description: t(
|
||||
'Template for cell titles. Use Handlebars templating syntax (a popular templating library that uses double curly brackets for variable substitution): {{row}}, {{column}}, {{rowLabel}}, {{columnLabel}}',
|
||||
),
|
||||
placeholder: '{{rowLabel}} by {{colLabel}}',
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
tabOverride: 'customize',
|
||||
};
|
||||
|
||||
export const matrixifyRowSection: ControlPanelSectionConfig = {
|
||||
label: t('Vertical layout'),
|
||||
expanded: false,
|
||||
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
|
||||
controlSetRows: [
|
||||
['matrixify_show_row_labels'],
|
||||
['matrixify_mode_rows'],
|
||||
['matrixify_rows'],
|
||||
['matrixify_dimension_rows'],
|
||||
['matrixify_dimension_selection_mode_rows'],
|
||||
['matrixify_topn_value_rows'],
|
||||
['matrixify_topn_metric_rows'],
|
||||
['matrixify_topn_order_rows'],
|
||||
],
|
||||
tabOverride: 'data',
|
||||
};
|
||||
|
||||
export const matrixifyColumnSection: ControlPanelSectionConfig = {
|
||||
label: t('Horizontal layout'),
|
||||
expanded: false,
|
||||
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
|
||||
controlSetRows: [
|
||||
['matrixify_show_column_headers'],
|
||||
['matrixify_mode_columns'],
|
||||
['matrixify_columns'],
|
||||
['matrixify_dimension_columns'],
|
||||
['matrixify_dimension_selection_mode_columns'],
|
||||
['matrixify_topn_value_columns'],
|
||||
['matrixify_topn_metric_columns'],
|
||||
['matrixify_topn_order_columns'],
|
||||
],
|
||||
tabOverride: 'data',
|
||||
};
|
||||
@@ -41,6 +41,53 @@ import {
|
||||
import { checkColumnType } from '../utils/checkColumnType';
|
||||
import { isSortable } from '../utils/isSortable';
|
||||
|
||||
// Aggregation choices with computation methods for plugins and controls
|
||||
export const aggregationChoices = {
|
||||
raw: {
|
||||
label: 'Overall value',
|
||||
compute: (data: number[]) => {
|
||||
if (!data.length) return null;
|
||||
return data[0];
|
||||
},
|
||||
},
|
||||
LAST_VALUE: {
|
||||
label: 'Last Value',
|
||||
compute: (data: number[]) => {
|
||||
if (!data.length) return null;
|
||||
return data[0];
|
||||
},
|
||||
},
|
||||
sum: {
|
||||
label: 'Total (Sum)',
|
||||
compute: (data: number[]) =>
|
||||
data.length ? data.reduce((a, b) => a + b, 0) : null,
|
||||
},
|
||||
mean: {
|
||||
label: 'Average (Mean)',
|
||||
compute: (data: number[]) =>
|
||||
data.length ? data.reduce((a, b) => a + b, 0) / data.length : null,
|
||||
},
|
||||
min: {
|
||||
label: 'Minimum',
|
||||
compute: (data: number[]) => (data.length ? Math.min(...data) : null),
|
||||
},
|
||||
max: {
|
||||
label: 'Maximum',
|
||||
compute: (data: number[]) => (data.length ? Math.max(...data) : null),
|
||||
},
|
||||
median: {
|
||||
label: 'Median',
|
||||
compute: (data: number[]) => {
|
||||
if (!data.length) return null;
|
||||
const sorted = [...data].sort((a, b) => a - b);
|
||||
const mid = Math.floor(sorted.length / 2);
|
||||
return sorted.length % 2 === 0
|
||||
? (sorted[mid - 1] + sorted[mid]) / 2
|
||||
: sorted[mid];
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const contributionModeControl = {
|
||||
name: 'contributionMode',
|
||||
config: {
|
||||
@@ -69,17 +116,12 @@ export const aggregationControl = {
|
||||
default: 'LAST_VALUE',
|
||||
clearable: false,
|
||||
renderTrigger: false,
|
||||
choices: [
|
||||
['raw', t('None')],
|
||||
['LAST_VALUE', t('Last Value')],
|
||||
['sum', t('Total (Sum)')],
|
||||
['mean', t('Average (Mean)')],
|
||||
['min', t('Minimum')],
|
||||
['max', t('Maximum')],
|
||||
['median', t('Median')],
|
||||
],
|
||||
choices: Object.entries(aggregationChoices).map(([value, { label }]) => [
|
||||
value,
|
||||
t(label),
|
||||
]),
|
||||
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,
|
||||
mapStateToProps: ({ form_data }: ControlPanelState) => ({
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
/* eslint-disable camelcase */
|
||||
/**
|
||||
* 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 { t } from '@superset-ui/core';
|
||||
import { SharedControlConfig } from '../types';
|
||||
import { dndAdhocMetricControl } from './dndControls';
|
||||
|
||||
/**
|
||||
* Matrixify control definitions
|
||||
* Controls for transforming charts into matrix/grid layouts
|
||||
*/
|
||||
|
||||
// Initialize the controls object that will be populated dynamically
|
||||
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
|
||||
|
||||
// Dynamically add axis-specific controls (rows and columns)
|
||||
['columns', 'rows'].forEach(axisParam => {
|
||||
const axis = axisParam; // Capture the value in a local variable
|
||||
|
||||
matrixifyControls[`matrixify_mode_${axis}`] = {
|
||||
type: 'RadioButtonControl',
|
||||
label: t(`Metrics / Dimensions`),
|
||||
default: 'metrics',
|
||||
options: [
|
||||
['metrics', t('Metrics')],
|
||||
['dimensions', t('Dimension members')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
};
|
||||
|
||||
matrixifyControls[`matrixify_${axis}`] = {
|
||||
...dndAdhocMetricControl,
|
||||
label: t(`Metrics`),
|
||||
multi: true,
|
||||
validators: [], // Not required
|
||||
// description: t(`Select metrics for ${axis}`),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'metrics',
|
||||
};
|
||||
|
||||
// Combined dimension and values control
|
||||
matrixifyControls[`matrixify_dimension_${axis}`] = {
|
||||
type: 'MatrixifyDimensionControl',
|
||||
label: t(`Dimension selection`),
|
||||
description: t(`Select dimension and values`),
|
||||
default: { dimension: '', values: [] },
|
||||
validators: [], // Not required
|
||||
renderTrigger: true,
|
||||
shouldMapStateToProps: (prevState, state) => {
|
||||
// Recalculate when any relevant form_data field changes
|
||||
const fieldsToCheck = [
|
||||
`matrixify_topn_value_${axis}`,
|
||||
`matrixify_topn_metric_${axis}`,
|
||||
`matrixify_topn_order_${axis}`,
|
||||
`matrixify_dimension_selection_mode_${axis}`,
|
||||
];
|
||||
|
||||
return fieldsToCheck.some(
|
||||
field => prevState?.form_data?.[field] !== state?.form_data?.[field],
|
||||
);
|
||||
},
|
||||
mapStateToProps: ({ datasource, controls, form_data }) => {
|
||||
// Helper to get value from form_data or controls
|
||||
const getValue = (key: string, defaultValue?: any) =>
|
||||
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
|
||||
|
||||
return {
|
||||
datasource,
|
||||
selectionMode: getValue(
|
||||
`matrixify_dimension_selection_mode_${axis}`,
|
||||
'members',
|
||||
),
|
||||
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
|
||||
topNValue: getValue(`matrixify_topn_value_${axis}`),
|
||||
topNOrder: getValue(`matrixify_topn_order_${axis}`),
|
||||
formData: form_data,
|
||||
};
|
||||
},
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
|
||||
};
|
||||
|
||||
// Dimension picker for TopN mode (just dimension, no values)
|
||||
// NOTE: This is now handled by matrixify_dimension control, so hiding it
|
||||
matrixifyControls[`matrixify_topn_dimension_${axis}`] = {
|
||||
type: 'SelectControl',
|
||||
label: t('Dimension'),
|
||||
description: t(`Select dimension for Top N`),
|
||||
default: null,
|
||||
mapStateToProps: ({ datasource }) => ({
|
||||
choices:
|
||||
datasource?.columns?.map((col: any) => [
|
||||
col.column_name,
|
||||
col.column_name,
|
||||
]) || [],
|
||||
}),
|
||||
renderTrigger: true,
|
||||
// Hide this control - now handled by matrixify_dimension control
|
||||
visibility: () => false,
|
||||
};
|
||||
|
||||
// Add selection mode control (Dimension Members vs TopN)
|
||||
matrixifyControls[`matrixify_dimension_selection_mode_${axis}`] = {
|
||||
type: 'RadioButtonControl',
|
||||
label: t(`Selection method`),
|
||||
default: 'members',
|
||||
options: [
|
||||
['members', t('Dimension members')],
|
||||
['topn', t('Top n')],
|
||||
],
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
|
||||
};
|
||||
|
||||
// TopN controls
|
||||
matrixifyControls[`matrixify_topn_value_${axis}`] = {
|
||||
type: 'TextControl',
|
||||
label: t(`Number of top values`),
|
||||
description: t(`How many top values to select`),
|
||||
default: 10,
|
||||
isInt: true,
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
||||
'topn',
|
||||
};
|
||||
|
||||
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
|
||||
...dndAdhocMetricControl,
|
||||
label: t(`Metric for ordering`),
|
||||
multi: false,
|
||||
validators: [], // Not required
|
||||
description: t(`Metric to use for ordering Top N values`),
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
||||
'topn',
|
||||
};
|
||||
|
||||
matrixifyControls[`matrixify_topn_order_${axis}`] = {
|
||||
type: 'RadioButtonControl',
|
||||
label: t(`Sort order`),
|
||||
default: 'desc',
|
||||
options: [
|
||||
['asc', t('Ascending')],
|
||||
['desc', t('Descending')],
|
||||
],
|
||||
visibility: ({ controls }) =>
|
||||
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
|
||||
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
|
||||
'topn',
|
||||
};
|
||||
});
|
||||
|
||||
// Grid layout controls (added once, not per axis)
|
||||
matrixifyControls.matrixify_row_height = {
|
||||
type: 'TextControl',
|
||||
label: t('Row height'),
|
||||
description: t('Height of each row in pixels'),
|
||||
default: 300,
|
||||
isInt: true,
|
||||
validators: [],
|
||||
renderTrigger: true,
|
||||
};
|
||||
|
||||
matrixifyControls.matrixify_fit_columns_dynamically = {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Fit columns dynamically'),
|
||||
description: t('Automatically adjust column width based on available space'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
};
|
||||
|
||||
matrixifyControls.matrixify_charts_per_row = {
|
||||
type: 'SelectControl',
|
||||
label: t('Charts per row'),
|
||||
description: t('Number of charts to display per row'),
|
||||
default: 4,
|
||||
choices: [
|
||||
[1, '1'],
|
||||
[2, '2'],
|
||||
[3, '3'],
|
||||
[4, '4'],
|
||||
[5, '5'],
|
||||
[6, '6'],
|
||||
[8, '8'],
|
||||
[10, '10'],
|
||||
[12, '12'],
|
||||
],
|
||||
freeForm: true,
|
||||
clearable: false,
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!controls?.matrixify_fit_columns_dynamically?.value,
|
||||
};
|
||||
|
||||
// Main enable control
|
||||
matrixifyControls.matrixify_enabled = {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Enable matrixify'),
|
||||
description: t(
|
||||
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
|
||||
),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
};
|
||||
|
||||
// Cell title control for Matrixify
|
||||
matrixifyControls.matrixify_cell_title_template = {
|
||||
type: 'TextControl',
|
||||
label: t('Title'),
|
||||
description: t(
|
||||
'Customize cell titles using Handlebars template syntax. Available variables: {{rowLabel}}, {{colLabel}}',
|
||||
),
|
||||
default: '',
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
(controls?.matrixify_mode_rows?.value ||
|
||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
||||
};
|
||||
|
||||
// Matrix display controls
|
||||
matrixifyControls.matrixify_show_row_labels = {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show row labels'),
|
||||
description: t('Display labels for each row on the left side of the matrix'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
(controls?.matrixify_mode_rows?.value ||
|
||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
||||
};
|
||||
|
||||
matrixifyControls.matrixify_show_column_headers = {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show column headers'),
|
||||
description: t('Display headers for each column at the top of the matrix'),
|
||||
default: true,
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
(controls?.matrixify_mode_rows?.value ||
|
||||
controls?.matrixify_mode_columns?.value) !== undefined,
|
||||
};
|
||||
|
||||
export { matrixifyControls };
|
||||
@@ -17,7 +17,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* This file exports all controls available for use in chart plugins internal to Superset.
|
||||
* It is not recommended to use the controls here for any third-party plugins.
|
||||
@@ -86,6 +85,7 @@ import {
|
||||
dndTooltipColumnsControl,
|
||||
dndTooltipMetricsControl,
|
||||
} from './dndControls';
|
||||
import { matrixifyControls } from './matrixifyControls';
|
||||
|
||||
const categoricalSchemeRegistry = getCategoricalSchemeRegistry();
|
||||
const sequentialSchemeRegistry = getSequentialSchemeRegistry();
|
||||
@@ -177,6 +177,7 @@ const granularity: SharedControlConfig<'SelectControl'> = {
|
||||
'can type and use simple natural language as in `10 seconds`, ' +
|
||||
'`1 day` or `56 weeks`',
|
||||
),
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
@@ -204,6 +205,7 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
|
||||
choices: (datasource as Dataset)?.time_grain_sqla || [],
|
||||
}),
|
||||
visibility: displayTimeRelatedControls,
|
||||
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
|
||||
};
|
||||
|
||||
const time_range: SharedControlConfig<'DateFilterControl'> = {
|
||||
@@ -425,7 +427,7 @@ const order_by_cols: SharedControlConfig<'SelectControl'> = {
|
||||
resetOnHide: false,
|
||||
};
|
||||
|
||||
export default {
|
||||
const sharedControls: Record<string, SharedControlConfig<any>> = {
|
||||
metrics: dndAdhocMetricsControl,
|
||||
metric: dndAdhocMetricControl,
|
||||
datasource: datasourceControl,
|
||||
@@ -470,4 +472,9 @@ export default {
|
||||
currency_format,
|
||||
sort_by_metric,
|
||||
order_by_cols,
|
||||
|
||||
// Add all Matrixify controls
|
||||
...matrixifyControls,
|
||||
};
|
||||
|
||||
export default sharedControls;
|
||||
|
||||
@@ -190,7 +190,7 @@ export type InternalControlType =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type ControlType = InternalControlType | ComponentType<any>;
|
||||
|
||||
export type TabOverride = 'data' | 'customize' | boolean;
|
||||
export type TabOverride = 'data' | 'customize' | 'matrixify' | boolean;
|
||||
|
||||
/**
|
||||
* Control config specifying how chart controls appear in the control panel, all
|
||||
@@ -458,6 +458,10 @@ export enum Comparator {
|
||||
BetweenOrEqual = '≤ x ≤',
|
||||
BetweenOrLeftEqual = '≤ x <',
|
||||
BetweenOrRightEqual = '< x ≤',
|
||||
BeginsWith = 'begins with',
|
||||
EndsWith = 'ends with',
|
||||
Containing = 'containing',
|
||||
NotContaining = 'not containing',
|
||||
}
|
||||
|
||||
export const MultipleValueComparators = [
|
||||
@@ -469,7 +473,7 @@ export const MultipleValueComparators = [
|
||||
|
||||
export type ConditionalFormattingConfig = {
|
||||
operator?: Comparator;
|
||||
targetValue?: number;
|
||||
targetValue?: number | string;
|
||||
targetValueLeft?: number;
|
||||
targetValueRight?: number;
|
||||
column?: string;
|
||||
@@ -478,7 +482,7 @@ export type ConditionalFormattingConfig = {
|
||||
|
||||
export type ColorFormatters = {
|
||||
column: string;
|
||||
getColorFromValue: (value: number) => string | undefined;
|
||||
getColorFromValue: (value: number | string) => string | undefined;
|
||||
}[];
|
||||
|
||||
export default {};
|
||||
|
||||
@@ -32,13 +32,18 @@ const MIN_OPACITY_BOUNDED = 0.05;
|
||||
const MIN_OPACITY_UNBOUNDED = 0;
|
||||
const MAX_OPACITY = 1;
|
||||
export const getOpacity = (
|
||||
value: number,
|
||||
cutoffPoint: number,
|
||||
extremeValue: number,
|
||||
value: number | string,
|
||||
cutoffPoint: number | string,
|
||||
extremeValue: number | string,
|
||||
minOpacity = MIN_OPACITY_BOUNDED,
|
||||
maxOpacity = MAX_OPACITY,
|
||||
) => {
|
||||
if (extremeValue === cutoffPoint) {
|
||||
if (
|
||||
extremeValue === cutoffPoint ||
|
||||
typeof cutoffPoint !== 'number' ||
|
||||
typeof extremeValue !== 'number' ||
|
||||
typeof value !== 'number'
|
||||
) {
|
||||
return maxOpacity;
|
||||
}
|
||||
return Math.min(
|
||||
@@ -61,16 +66,16 @@ export const getColorFunction = (
|
||||
targetValueRight,
|
||||
colorScheme,
|
||||
}: ConditionalFormattingConfig,
|
||||
columnValues: number[],
|
||||
columnValues: number[] | string[],
|
||||
alpha?: boolean,
|
||||
) => {
|
||||
let minOpacity = MIN_OPACITY_BOUNDED;
|
||||
const maxOpacity = MAX_OPACITY;
|
||||
|
||||
let comparatorFunction: (
|
||||
value: number,
|
||||
allValues: number[],
|
||||
) => false | { cutoffValue: number; extremeValue: number };
|
||||
value: number | string,
|
||||
allValues: number[] | string[],
|
||||
) => false | { cutoffValue: number | string; extremeValue: number | string };
|
||||
if (operator === undefined || colorScheme === undefined) {
|
||||
return () => undefined;
|
||||
}
|
||||
@@ -90,7 +95,10 @@ export const getColorFunction = (
|
||||
switch (operator) {
|
||||
case Comparator.None:
|
||||
minOpacity = MIN_OPACITY_UNBOUNDED;
|
||||
comparatorFunction = (value: number, allValues: number[]) => {
|
||||
comparatorFunction = (value: number | string, allValues: number[]) => {
|
||||
if (typeof value !== 'number') {
|
||||
return { cutoffValue: value!, extremeValue: value! };
|
||||
}
|
||||
const cutoffValue = Math.min(...allValues);
|
||||
const extremeValue = Math.max(...allValues);
|
||||
return value >= cutoffValue && value <= extremeValue
|
||||
@@ -100,49 +108,65 @@ export const getColorFunction = (
|
||||
break;
|
||||
case Comparator.GreaterThan:
|
||||
comparatorFunction = (value: number, allValues: number[]) =>
|
||||
value > targetValue!
|
||||
? { cutoffValue: targetValue!, extremeValue: Math.max(...allValues) }
|
||||
typeof targetValue === 'number' && value > targetValue!
|
||||
? {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue: Math.max(...allValues),
|
||||
}
|
||||
: false;
|
||||
break;
|
||||
case Comparator.LessThan:
|
||||
comparatorFunction = (value: number, allValues: number[]) =>
|
||||
value < targetValue!
|
||||
? { cutoffValue: targetValue!, extremeValue: Math.min(...allValues) }
|
||||
typeof targetValue === 'number' && value < targetValue!
|
||||
? {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue: Math.min(...allValues),
|
||||
}
|
||||
: false;
|
||||
break;
|
||||
case Comparator.GreaterOrEqual:
|
||||
comparatorFunction = (value: number, allValues: number[]) =>
|
||||
value >= targetValue!
|
||||
? { cutoffValue: targetValue!, extremeValue: Math.max(...allValues) }
|
||||
typeof targetValue === 'number' && value >= targetValue!
|
||||
? {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue: Math.max(...allValues),
|
||||
}
|
||||
: false;
|
||||
break;
|
||||
case Comparator.LessOrEqual:
|
||||
comparatorFunction = (value: number, allValues: number[]) =>
|
||||
value <= targetValue!
|
||||
? { cutoffValue: targetValue!, extremeValue: Math.min(...allValues) }
|
||||
typeof targetValue === 'number' && value <= targetValue!
|
||||
? {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue: Math.min(...allValues),
|
||||
}
|
||||
: false;
|
||||
break;
|
||||
case Comparator.Equal:
|
||||
comparatorFunction = (value: number) =>
|
||||
comparatorFunction = (value: number | string) =>
|
||||
value === targetValue!
|
||||
? { cutoffValue: targetValue!, extremeValue: targetValue! }
|
||||
: false;
|
||||
break;
|
||||
case Comparator.NotEqual:
|
||||
comparatorFunction = (value: number, allValues: number[]) => {
|
||||
if (value === targetValue!) {
|
||||
return false;
|
||||
if (typeof targetValue === 'number') {
|
||||
if (value === targetValue!) {
|
||||
return false;
|
||||
}
|
||||
const max = Math.max(...allValues);
|
||||
const min = Math.min(...allValues);
|
||||
return {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue:
|
||||
Math.abs(targetValue! - min) > Math.abs(max - targetValue!)
|
||||
? min
|
||||
: max,
|
||||
};
|
||||
}
|
||||
const max = Math.max(...allValues);
|
||||
const min = Math.min(...allValues);
|
||||
return {
|
||||
cutoffValue: targetValue!,
|
||||
extremeValue:
|
||||
Math.abs(targetValue! - min) > Math.abs(max - targetValue!)
|
||||
? min
|
||||
: max,
|
||||
};
|
||||
return false;
|
||||
};
|
||||
|
||||
break;
|
||||
case Comparator.Between:
|
||||
comparatorFunction = (value: number) =>
|
||||
@@ -168,12 +192,38 @@ export const getColorFunction = (
|
||||
? { cutoffValue: targetValueLeft!, extremeValue: targetValueRight! }
|
||||
: false;
|
||||
break;
|
||||
case Comparator.BeginsWith:
|
||||
comparatorFunction = (value: string) =>
|
||||
isString(value) && value?.startsWith(targetValue as string)
|
||||
? { cutoffValue: targetValue!, extremeValue: targetValue! }
|
||||
: false;
|
||||
break;
|
||||
case Comparator.EndsWith:
|
||||
comparatorFunction = (value: string) =>
|
||||
isString(value) && value?.endsWith(targetValue as string)
|
||||
? { cutoffValue: targetValue!, extremeValue: targetValue! }
|
||||
: false;
|
||||
break;
|
||||
case Comparator.Containing:
|
||||
comparatorFunction = (value: string) =>
|
||||
isString(value) &&
|
||||
value?.toLowerCase().includes((targetValue as string).toLowerCase())
|
||||
? { cutoffValue: targetValue!, extremeValue: targetValue! }
|
||||
: false;
|
||||
break;
|
||||
case Comparator.NotContaining:
|
||||
comparatorFunction = (value: string) =>
|
||||
isString(value) &&
|
||||
!value?.toLowerCase().includes((targetValue as string).toLowerCase())
|
||||
? { cutoffValue: targetValue!, extremeValue: targetValue! }
|
||||
: false;
|
||||
break;
|
||||
default:
|
||||
comparatorFunction = () => false;
|
||||
break;
|
||||
}
|
||||
|
||||
return (value: number) => {
|
||||
return (value: number | string) => {
|
||||
const compareResult = comparatorFunction(value, columnValues);
|
||||
if (compareResult === false) return undefined;
|
||||
const { cutoffValue, extremeValue } = compareResult;
|
||||
@@ -218,3 +268,7 @@ export const getColorFormatters = memoizeOne(
|
||||
[],
|
||||
) ?? [],
|
||||
);
|
||||
|
||||
function isString(value: unknown) {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
@@ -29,3 +29,4 @@ export * from './getStandardizedControls';
|
||||
export * from './getTemporalColumns';
|
||||
export * from './displayTimeRelatedControls';
|
||||
export * from './colorControls';
|
||||
export * from './metricColumnFilter';
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 { QueryFormMetric, SqlaFormData } from '@superset-ui/core';
|
||||
import {
|
||||
shouldSkipMetricColumn,
|
||||
isRegularMetric,
|
||||
isPercentMetric,
|
||||
} from './metricColumnFilter';
|
||||
|
||||
const createMetric = (label: string): QueryFormMetric =>
|
||||
({
|
||||
label,
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: label },
|
||||
aggregate: 'SUM',
|
||||
}) as QueryFormMetric;
|
||||
|
||||
describe('metricColumnFilter', () => {
|
||||
const createFormData = (
|
||||
metrics: string[],
|
||||
percentMetrics: string[],
|
||||
): SqlaFormData =>
|
||||
({
|
||||
datasource: 'test_datasource',
|
||||
viz_type: 'table',
|
||||
metrics: metrics.map(createMetric),
|
||||
percent_metrics: percentMetrics.map(createMetric),
|
||||
}) as SqlaFormData;
|
||||
|
||||
describe('shouldSkipMetricColumn', () => {
|
||||
it('should skip unprefixed percent metric columns if prefixed version exists', () => {
|
||||
const colnames = ['metric1', '%metric1'];
|
||||
const formData = createFormData([], ['metric1']);
|
||||
|
||||
const result = shouldSkipMetricColumn({
|
||||
colname: 'metric1',
|
||||
colnames,
|
||||
formData,
|
||||
});
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should not skip if column is also a regular metric', () => {
|
||||
const colnames = ['metric1', '%metric1'];
|
||||
const formData = createFormData(['metric1'], ['metric1']);
|
||||
|
||||
const result = shouldSkipMetricColumn({
|
||||
colname: 'metric1',
|
||||
colnames,
|
||||
formData,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not skip if column starts with %', () => {
|
||||
const colnames = ['%metric1'];
|
||||
const formData = createFormData(['metric1'], []);
|
||||
|
||||
const result = shouldSkipMetricColumn({
|
||||
colname: '%metric1',
|
||||
colnames,
|
||||
formData,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should not skip if no prefixed version exists', () => {
|
||||
const colnames = ['metric1'];
|
||||
const formData = createFormData([], ['metric1']);
|
||||
|
||||
const result = shouldSkipMetricColumn({
|
||||
colname: 'metric1',
|
||||
colnames,
|
||||
formData,
|
||||
});
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRegularMetric', () => {
|
||||
it('should return true for regular metrics', () => {
|
||||
const formData = createFormData(['metric1', 'metric2'], []);
|
||||
expect(isRegularMetric('metric1', formData)).toBe(true);
|
||||
expect(isRegularMetric('metric2', formData)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-metrics', () => {
|
||||
const formData = createFormData(['metric1'], []);
|
||||
expect(isRegularMetric('non_metric', formData)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for percentage metrics', () => {
|
||||
const formData = createFormData([], ['percent_metric1']);
|
||||
expect(isRegularMetric('percent_metric1', formData)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPercentMetric', () => {
|
||||
it('should return true for percentage metrics', () => {
|
||||
const formData = createFormData([], ['percent_metric1']);
|
||||
expect(isPercentMetric('%percent_metric1', formData)).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false for non-percentage metrics', () => {
|
||||
const formData = createFormData(['regular_metric'], []);
|
||||
expect(isPercentMetric('regular_metric', formData)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for regular metrics', () => {
|
||||
const formData = createFormData(['metric1'], []);
|
||||
expect(isPercentMetric('metric1', formData)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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 {
|
||||
QueryFormMetric,
|
||||
getMetricLabel,
|
||||
SqlaFormData,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
export interface MetricColumnFilterParams {
|
||||
colname: string;
|
||||
colnames: string[];
|
||||
formData: SqlaFormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a column should be skipped based on metric filtering logic.
|
||||
*
|
||||
* This function implements the logic to skip unprefixed percent metric columns
|
||||
* if a prefixed version exists, but doesn't skip if it's also a regular metric.
|
||||
*
|
||||
* @param params - The parameters for metric column filtering
|
||||
* @returns true if the column should be skipped, false otherwise
|
||||
*/
|
||||
export function shouldSkipMetricColumn({
|
||||
colname,
|
||||
colnames,
|
||||
formData,
|
||||
}: MetricColumnFilterParams): boolean {
|
||||
if (!colname) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this column name exists as a percent metric in form data
|
||||
const isPercentMetric = formData.percent_metrics?.some(
|
||||
(metric: QueryFormMetric) => getMetricLabel(metric) === colname,
|
||||
);
|
||||
|
||||
// Check if this column name exists as a regular metric in form data
|
||||
const isRegularMetric = formData.metrics?.some(
|
||||
(metric: QueryFormMetric) => getMetricLabel(metric) === colname,
|
||||
);
|
||||
|
||||
// Check if there's a prefixed version of this column in the column list
|
||||
const hasPrefixedVersion = colnames.includes(`%${colname}`);
|
||||
|
||||
// Skip if: has prefixed version AND is percent metric AND is NOT regular metric
|
||||
return hasPrefixedVersion && isPercentMetric && !isRegularMetric;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a column is a regular metric.
|
||||
*
|
||||
* @param colname - The column name to check
|
||||
* @param formData - The form data containing metrics
|
||||
* @returns true if the column is a regular metric, false otherwise
|
||||
*/
|
||||
export function isRegularMetric(
|
||||
colname: string,
|
||||
formData: SqlaFormData,
|
||||
): boolean {
|
||||
return !!formData.metrics?.some(metric => getMetricLabel(metric) === colname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if a column is a percentage metric.
|
||||
*
|
||||
* @param colname: string,
|
||||
* @param formData - The form data containing percent_metrics
|
||||
* @returns true if the column is a percentage metric, false otherwise
|
||||
*/
|
||||
export function isPercentMetric(
|
||||
colname: string,
|
||||
formData: SqlaFormData,
|
||||
): boolean {
|
||||
return !!formData.percent_metrics?.some(
|
||||
(metric: QueryFormMetric) => `%${getMetricLabel(metric)}` === colname,
|
||||
);
|
||||
}
|
||||
@@ -65,6 +65,20 @@ test('should skip renameOperator if series does not exist', () => {
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should skip renameOperator if series does not exist and a single time shift exists', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
{ ...formData, ...{ time_compare: ['1 year ago'] } },
|
||||
{
|
||||
...queryObject,
|
||||
...{
|
||||
columns: [],
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual(undefined);
|
||||
});
|
||||
|
||||
test('should skip renameOperator if does not exist x_axis and is_timeseries', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
@@ -93,6 +107,26 @@ test('should add renameOperator', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if a metric exists and multiple time shift', () => {
|
||||
expect(
|
||||
renameOperator(
|
||||
{
|
||||
...formData,
|
||||
...{ time_compare: ['1 year ago', '2 years ago'] },
|
||||
},
|
||||
{
|
||||
...queryObject,
|
||||
...{
|
||||
columns: [],
|
||||
},
|
||||
},
|
||||
),
|
||||
).toEqual({
|
||||
operation: 'rename',
|
||||
options: { columns: { 'count(*)': null }, inplace: true, level: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
test('should add renameOperator if exists derived metrics', () => {
|
||||
[
|
||||
ComparisonType.Difference,
|
||||
@@ -176,7 +210,6 @@ test('should add renameOperator if exist "actual value" time comparison', () =>
|
||||
operation: 'rename',
|
||||
options: {
|
||||
columns: {
|
||||
'count(*)': null,
|
||||
'count(*)__1 year ago': '1 year ago',
|
||||
'count(*)__1 year later': '1 year later',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,393 @@
|
||||
/**
|
||||
* 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 { ControlPanelState } from '../../src/types';
|
||||
|
||||
// Mock the utilities to avoid complex dependencies
|
||||
jest.mock('../../src/utils', () => ({
|
||||
formatSelectOptions: jest.fn((options: any[]) =>
|
||||
options.map((opt: any) => [opt, opt]),
|
||||
),
|
||||
displayTimeRelatedControls: jest.fn(() => true),
|
||||
getColorControlsProps: jest.fn(() => ({})),
|
||||
D3_FORMAT_OPTIONS: [],
|
||||
D3_FORMAT_DOCS: '',
|
||||
D3_TIME_FORMAT_OPTIONS: [],
|
||||
D3_TIME_FORMAT_DOCS: '',
|
||||
DEFAULT_TIME_FORMAT: '%Y-%m-%d',
|
||||
DEFAULT_NUMBER_FORMAT: '',
|
||||
}));
|
||||
|
||||
// Mock shared controls
|
||||
const mockSharedControls = {
|
||||
matrixify_dimension_x: {
|
||||
shouldMapStateToProps: (
|
||||
prevState: ControlPanelState,
|
||||
state: ControlPanelState,
|
||||
) => {
|
||||
const fieldsToCheck = [
|
||||
'matrixify_topn_value_x',
|
||||
'matrixify_topn_metric_x',
|
||||
'matrixify_topn_order_x',
|
||||
'matrixify_dimension_selection_mode_x',
|
||||
];
|
||||
return fieldsToCheck.some(
|
||||
field => prevState?.form_data?.[field] !== state?.form_data?.[field],
|
||||
);
|
||||
},
|
||||
mapStateToProps: ({ datasource, controls, form_data }: any) => {
|
||||
const getValue = (key: string, defaultValue?: any) =>
|
||||
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
|
||||
|
||||
return {
|
||||
datasource,
|
||||
selectionMode: getValue(
|
||||
'matrixify_dimension_selection_mode_x',
|
||||
'members',
|
||||
),
|
||||
topNMetric: getValue('matrixify_topn_metric_x'),
|
||||
topNValue: getValue('matrixify_topn_value_x'),
|
||||
topNOrder: getValue('matrixify_topn_order_x'),
|
||||
formData: form_data,
|
||||
};
|
||||
},
|
||||
},
|
||||
matrixify_dimension_y: {
|
||||
shouldMapStateToProps: (
|
||||
prevState: ControlPanelState,
|
||||
state: ControlPanelState,
|
||||
) => {
|
||||
const fieldsToCheck = [
|
||||
'matrixify_topn_value_y',
|
||||
'matrixify_topn_metric_y',
|
||||
'matrixify_topn_order_y',
|
||||
'matrixify_dimension_selection_mode_y',
|
||||
];
|
||||
return fieldsToCheck.some(
|
||||
field => prevState?.form_data?.[field] !== state?.form_data?.[field],
|
||||
);
|
||||
},
|
||||
mapStateToProps: ({ datasource, controls, form_data }: any) => {
|
||||
const getValue = (key: string, defaultValue?: any) =>
|
||||
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
|
||||
|
||||
return {
|
||||
datasource,
|
||||
selectionMode: getValue(
|
||||
'matrixify_dimension_selection_mode_y',
|
||||
'members',
|
||||
),
|
||||
topNMetric: getValue('matrixify_topn_metric_y'),
|
||||
topNValue: getValue('matrixify_topn_value_y'),
|
||||
topNOrder: getValue('matrixify_topn_order_y'),
|
||||
formData: form_data,
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const createMockState = (
|
||||
formData: any = {},
|
||||
controls: any = {},
|
||||
): ControlPanelState => ({
|
||||
slice: { slice_id: 123 },
|
||||
form_data: formData,
|
||||
datasource: null,
|
||||
controls,
|
||||
common: {},
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const createMockControlState = (value: any = null) => ({ value });
|
||||
|
||||
test('matrixify_dimension_x should return true when topN value changes', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 10, // Changed
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should return true when topN metric changes', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric2', // Changed
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should return true when topN order changes', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'asc', // Changed
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should return true when selection mode changes', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'members', // Changed
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should return false when no relevant fields change', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
unrelated_field: 'value1',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
unrelated_field: 'value2', // Changed, but not relevant
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(false);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should return false when states are identical', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const state = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_order_x: 'desc',
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(state, state)).toBe(false);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should handle missing form_data gracefully', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState(); // No form_data
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_x should handle undefined values gracefully', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: undefined,
|
||||
matrixify_topn_metric_x: null,
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 5,
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_y should check y-axis specific fields', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_y;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_y: 5,
|
||||
matrixify_topn_metric_y: 'metric1',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_y: 10, // Changed
|
||||
matrixify_topn_metric_y: 'metric1',
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(true);
|
||||
});
|
||||
|
||||
test('matrixify_dimension_y should not trigger on x-axis changes', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_y;
|
||||
|
||||
const prevState = createMockState({
|
||||
matrixify_topn_value_x: 5, // x-axis field
|
||||
matrixify_topn_value_y: 5, // y-axis field (unchanged)
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
matrixify_topn_value_x: 10, // x-axis field changed
|
||||
matrixify_topn_value_y: 5, // y-axis field (unchanged)
|
||||
});
|
||||
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(false);
|
||||
});
|
||||
|
||||
test('mapStateToProps should map form_data values correctly', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const state = createMockState({
|
||||
matrixify_dimension_selection_mode_x: 'topn',
|
||||
matrixify_topn_metric_x: 'metric1',
|
||||
matrixify_topn_value_x: 10,
|
||||
matrixify_topn_order_x: 'desc',
|
||||
});
|
||||
|
||||
const mockDatasource: any = { id: 1, columns: [] };
|
||||
state.datasource = mockDatasource;
|
||||
|
||||
const result = control.mapStateToProps!(state);
|
||||
|
||||
expect(result).toEqual({
|
||||
datasource: mockDatasource,
|
||||
selectionMode: 'topn',
|
||||
topNMetric: 'metric1',
|
||||
topNValue: 10,
|
||||
topNOrder: 'desc',
|
||||
formData: state.form_data,
|
||||
});
|
||||
});
|
||||
|
||||
test('mapStateToProps should fall back to control values when form_data is missing', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const state = createMockState(
|
||||
{}, // Empty form_data
|
||||
{
|
||||
matrixify_dimension_selection_mode_x: createMockControlState('members'),
|
||||
matrixify_topn_metric_x: createMockControlState('metric2'),
|
||||
matrixify_topn_value_x: createMockControlState(15),
|
||||
},
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state);
|
||||
|
||||
expect(result.selectionMode).toBe('members');
|
||||
expect(result.topNMetric).toBe('metric2');
|
||||
expect(result.topNValue).toBe(15);
|
||||
});
|
||||
|
||||
test('mapStateToProps should use default values when both form_data and controls are missing', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const state = createMockState({}, {});
|
||||
|
||||
const result = control.mapStateToProps!(state);
|
||||
|
||||
expect(result.selectionMode).toBe('members'); // Default value
|
||||
expect(result.topNMetric).toBeUndefined();
|
||||
expect(result.topNValue).toBeUndefined();
|
||||
expect(result.topNOrder).toBeUndefined();
|
||||
});
|
||||
|
||||
test('mapStateToProps should prioritize form_data over control values', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const state = createMockState(
|
||||
{
|
||||
matrixify_dimension_selection_mode_x: 'topn', // form_data value
|
||||
},
|
||||
{
|
||||
matrixify_dimension_selection_mode_x: createMockControlState('members'), // control value
|
||||
},
|
||||
);
|
||||
|
||||
const result = control.mapStateToProps!(state);
|
||||
|
||||
expect(result.selectionMode).toBe('topn'); // Should use form_data value
|
||||
});
|
||||
|
||||
test('should efficiently check only relevant fields', () => {
|
||||
const control = mockSharedControls.matrixify_dimension_x;
|
||||
|
||||
const prevState = createMockState({
|
||||
// Many fields, only some relevant
|
||||
field1: 'value1',
|
||||
field2: 'value2',
|
||||
matrixify_topn_value_x: 5, // Relevant
|
||||
field3: 'value3',
|
||||
matrixify_topn_metric_x: 'metric1', // Relevant
|
||||
field4: 'value4',
|
||||
matrixify_other_control: 'value5',
|
||||
});
|
||||
|
||||
const nextState = createMockState({
|
||||
field1: 'value1_changed', // Not relevant
|
||||
field2: 'value2_changed', // Not relevant
|
||||
matrixify_topn_value_x: 5, // Relevant, unchanged
|
||||
field3: 'value3_changed', // Not relevant
|
||||
matrixify_topn_metric_x: 'metric1', // Relevant, unchanged
|
||||
field4: 'value4_changed', // Not relevant
|
||||
matrixify_other_control: 'value5_changed', // Not relevant
|
||||
});
|
||||
|
||||
// Should return false because no relevant fields changed
|
||||
expect(control.shouldMapStateToProps!(prevState, nextState)).toBe(false);
|
||||
});
|
||||
@@ -32,6 +32,9 @@ const mockData = [
|
||||
];
|
||||
const countValues = mockData.map(row => row.count);
|
||||
|
||||
const strData = [{ name: 'Brian' }, { name: 'Carlos' }, { name: 'Diana' }];
|
||||
const strValues = strData.map(row => row.name);
|
||||
|
||||
describe('round', () => {
|
||||
it('round', () => {
|
||||
expect(round(1)).toEqual(1);
|
||||
@@ -339,6 +342,90 @@ describe('getColorFunction()', () => {
|
||||
expect(colorFunction(50)).toBeUndefined();
|
||||
expect(colorFunction(100)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('getColorFunction BeginsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'C',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Brian')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction EndsWith', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction Containing', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction NotContaining', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toBeUndefined();
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction Equal', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.Equal,
|
||||
targetValue: 'Diana',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Carlos')).toBeUndefined();
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
it('getColorFunction None', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
|
||||
expect(colorFunction('Brian')).toEqual('#FF0000FF');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getColorFormatters()', () => {
|
||||
@@ -388,4 +475,47 @@ describe('getColorFormatters()', () => {
|
||||
const colorFormatters = getColorFormatters(undefined, mockData);
|
||||
expect(colorFormatters.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('correct column string config', () => {
|
||||
const columnConfigString = [
|
||||
{
|
||||
operator: Comparator.BeginsWith,
|
||||
targetValue: 'D',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.EndsWith,
|
||||
targetValue: 'n',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.Containing,
|
||||
targetValue: 'o',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
{
|
||||
operator: Comparator.NotContaining,
|
||||
targetValue: 'i',
|
||||
colorScheme: '#FF0000',
|
||||
column: 'name',
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfigString, strData);
|
||||
expect(colorFormatters.length).toEqual(4);
|
||||
|
||||
expect(colorFormatters[0].column).toEqual('name');
|
||||
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[1].column).toEqual('name');
|
||||
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[2].column).toEqual('name');
|
||||
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
|
||||
expect(colorFormatters[3].column).toEqual('name');
|
||||
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,11 +25,13 @@
|
||||
],
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.2.6",
|
||||
"@babel/runtime": "^7.25.6",
|
||||
"@babel/runtime": "^7.28.2",
|
||||
"@fontsource/fira-code": "^5.2.6",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"ace-builds": "^1.43.1",
|
||||
"ag-grid-community": "^34.0.2",
|
||||
"ag-grid-react": "34.0.2",
|
||||
"brace": "^0.11.1",
|
||||
"classnames": "^2.2.5",
|
||||
"csstype": "^3.1.3",
|
||||
@@ -37,19 +39,20 @@
|
||||
"d3-format": "^1.3.2",
|
||||
"dayjs": "^1.11.13",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^3.0.0",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.8",
|
||||
"jed": "^1.1.1",
|
||||
"lodash": "^4.17.21",
|
||||
"math-expression-evaluator": "^2.0.6",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"re-resizable": "^6.10.1",
|
||||
"react-ace": "^10.1.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-js-cron": "^5.2.0",
|
||||
"react-draggable": "^4.4.6",
|
||||
"react-draggable": "^4.5.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-syntax-highlighter": "^15.4.5",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
@@ -59,7 +62,7 @@
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"reselect": "^4.0.0",
|
||||
"reselect": "^5.1.1",
|
||||
"rison": "^0.1.1",
|
||||
"seedrandom": "^3.0.5",
|
||||
"@visx/responsive": "^3.12.0",
|
||||
@@ -78,7 +81,7 @@
|
||||
"@types/lodash": "^4.17.20",
|
||||
"@types/math-expression-evaluator": "^1.3.3",
|
||||
"@types/node": "^22.10.3",
|
||||
"@types/prop-types": "^15.7.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/seedrandom": "^3.0.8",
|
||||
"fetch-mock": "^11.1.4",
|
||||
|
||||
@@ -27,8 +27,8 @@ export default function FallbackComponent({ error, height, width }: Props) {
|
||||
return (
|
||||
<div
|
||||
css={(theme: SupersetTheme) => ({
|
||||
backgroundColor: theme.colors.grayscale.dark2,
|
||||
color: theme.colors.grayscale.light5,
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
color: theme.colorText,
|
||||
overflow: 'auto',
|
||||
padding: 32,
|
||||
})}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* 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 } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ThemeProvider, supersetTheme } from '../../..';
|
||||
import MatrixifyGridCell from './MatrixifyGridCell';
|
||||
import { MatrixifyGridCell as MatrixifyGridCellType } from '../../types/matrixify';
|
||||
|
||||
// Mock StatefulChart component
|
||||
jest.mock('../StatefulChart', () => {
|
||||
/* eslint-disable no-restricted-syntax, global-require, @typescript-eslint/no-var-requires */
|
||||
const React = require('react');
|
||||
/* eslint-enable no-restricted-syntax, global-require, @typescript-eslint/no-var-requires */
|
||||
|
||||
return {
|
||||
__esModule: true,
|
||||
default: ({ formData, height, width }: any) =>
|
||||
React.createElement(
|
||||
'div',
|
||||
{
|
||||
'data-testid': 'superchart',
|
||||
'data-viz-type': formData.viz_type,
|
||||
style: { height, width },
|
||||
},
|
||||
'SuperChart Mock',
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const mockDatasource = {
|
||||
id: 1,
|
||||
type: 'table',
|
||||
uid: '1__table',
|
||||
datasource_name: 'test_datasource',
|
||||
table_name: 'test_table',
|
||||
database: {
|
||||
id: 1,
|
||||
name: 'test_database',
|
||||
},
|
||||
};
|
||||
|
||||
const mockCell: MatrixifyGridCellType = {
|
||||
id: 'matrixify-0-0',
|
||||
row: 0,
|
||||
col: 0,
|
||||
rowLabel: 'Revenue',
|
||||
colLabel: 'Q1 2024',
|
||||
title: 'Revenue - Q1 2024',
|
||||
formData: {
|
||||
viz_type: 'big_number_total',
|
||||
metrics: ['revenue'],
|
||||
adhoc_filters: [],
|
||||
},
|
||||
};
|
||||
|
||||
const defaultProps = {
|
||||
cell: mockCell,
|
||||
datasource: mockDatasource,
|
||||
rowHeight: 200,
|
||||
};
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
test('should render the cell with title', () => {
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
|
||||
|
||||
expect(screen.getByText('Revenue - Q1 2024')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render the cell without title when not provided', () => {
|
||||
const cellWithoutTitle = {
|
||||
...mockCell,
|
||||
title: undefined,
|
||||
};
|
||||
|
||||
renderWithTheme(
|
||||
<MatrixifyGridCell {...defaultProps} cell={cellWithoutTitle} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByText('Revenue - Q1 2024')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render SuperChart with correct props', () => {
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
|
||||
|
||||
const superChart = screen.getByText('SuperChart Mock');
|
||||
expect(superChart).toBeInTheDocument();
|
||||
expect(superChart).toHaveAttribute('data-viz-type', 'big_number_total');
|
||||
expect(superChart).toHaveStyle({ height: '100%', width: '100%' });
|
||||
});
|
||||
|
||||
test('should calculate chart height correctly with title', () => {
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
|
||||
|
||||
const superChart = screen.getByText('SuperChart Mock');
|
||||
// StatefulChart uses 100% height within the chart wrapper
|
||||
expect(superChart).toHaveStyle({ height: '100%' });
|
||||
});
|
||||
|
||||
test('should calculate chart height correctly without title', () => {
|
||||
const cellWithoutTitle = {
|
||||
...mockCell,
|
||||
title: undefined,
|
||||
};
|
||||
|
||||
renderWithTheme(
|
||||
<MatrixifyGridCell {...defaultProps} cell={cellWithoutTitle} />,
|
||||
);
|
||||
|
||||
const superChart = screen.getByText('SuperChart Mock');
|
||||
// StatefulChart uses 100% height within the chart wrapper
|
||||
expect(superChart).toHaveStyle({ height: '100%' });
|
||||
});
|
||||
|
||||
test('should apply correct styling to container', () => {
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridCell {...defaultProps} />,
|
||||
);
|
||||
|
||||
const cellContainer = container.firstChild as HTMLElement;
|
||||
expect(cellContainer).toHaveStyle({
|
||||
height: '100%',
|
||||
display: 'flex',
|
||||
});
|
||||
});
|
||||
|
||||
test('should apply correct styling to title', () => {
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} />);
|
||||
|
||||
const title = screen.getByText('Revenue - Q1 2024');
|
||||
expect(title).toHaveStyle({
|
||||
overflow: 'hidden',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle different viz types', () => {
|
||||
const cellWithLineChart = {
|
||||
...mockCell,
|
||||
formData: {
|
||||
...mockCell.formData,
|
||||
viz_type: 'line',
|
||||
},
|
||||
};
|
||||
|
||||
renderWithTheme(
|
||||
<MatrixifyGridCell {...defaultProps} cell={cellWithLineChart} />,
|
||||
);
|
||||
|
||||
const superChart = screen.getByText('SuperChart Mock');
|
||||
expect(superChart).toHaveAttribute('data-viz-type', 'line');
|
||||
});
|
||||
|
||||
test('should pass through additional formData properties', () => {
|
||||
const cellWithExtraProps = {
|
||||
...mockCell,
|
||||
formData: {
|
||||
...mockCell.formData,
|
||||
time_range: 'Last month',
|
||||
row_limit: 100,
|
||||
},
|
||||
};
|
||||
|
||||
renderWithTheme(
|
||||
<MatrixifyGridCell {...defaultProps} cell={cellWithExtraProps} />,
|
||||
);
|
||||
|
||||
// The SuperChart mock would receive these props
|
||||
expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle small cell dimensions', () => {
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} rowHeight={80} />);
|
||||
|
||||
const superChart = screen.getByText('SuperChart Mock');
|
||||
const cellContainer = superChart.parentElement?.parentElement;
|
||||
expect(cellContainer).toHaveStyle({ height: '100%' });
|
||||
|
||||
// StatefulChart uses 100% dimensions within its wrapper
|
||||
expect(superChart).toHaveStyle({ height: '100%', width: '100%' });
|
||||
});
|
||||
|
||||
test('should handle empty cell data gracefully', () => {
|
||||
const emptyCell = {
|
||||
...mockCell,
|
||||
rowLabel: '',
|
||||
colLabel: '',
|
||||
title: '',
|
||||
};
|
||||
|
||||
renderWithTheme(<MatrixifyGridCell {...defaultProps} cell={emptyCell} />);
|
||||
|
||||
// Should still render but with empty title
|
||||
expect(screen.getByText('SuperChart Mock')).toBeInTheDocument();
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* 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 { memo, useMemo } from 'react';
|
||||
import { styled, useTheme } from '../../../theme';
|
||||
import { MatrixifyGridCell as GridCellData } from '../../types/matrixify';
|
||||
import StatefulChart from '../StatefulChart';
|
||||
|
||||
const CellContainer = styled.div`
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
background-color: ${({ theme }) => theme.colorBgContainer};
|
||||
overflow: hidden;
|
||||
`;
|
||||
|
||||
const CellHeader = styled.div`
|
||||
flex-shrink: 0;
|
||||
padding: ${({ theme }) => theme.sizeUnit}px
|
||||
${({ theme }) => theme.sizeUnit * 2}px;
|
||||
background-color: ${({ theme }) => theme.colorFillAlter};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
`;
|
||||
|
||||
const ChartWrapper = styled.div`
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
|
||||
/* Remove any padding/margins that might be causing title height issues */
|
||||
& .chart-container {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
/* Target title elements inside the chart container */
|
||||
& .superchart-container .header-title,
|
||||
& .superchart-container [class*='title'] {
|
||||
display: none !important;
|
||||
}
|
||||
`;
|
||||
|
||||
const NoDataMessage = styled.div<{ theme: any }>`
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: ${({ theme }) => theme.colorTextQuaternary};
|
||||
font-size: ${({ theme }) => theme.fontSizeSM}px;
|
||||
text-align: center;
|
||||
user-select: none;
|
||||
`;
|
||||
|
||||
interface MatrixifyGridCellProps {
|
||||
cell: GridCellData;
|
||||
rowHeight: number;
|
||||
datasource?: any;
|
||||
hooks?: any;
|
||||
}
|
||||
|
||||
// Simple No Data component for matrix cells
|
||||
const MatrixNoDataComponent = () => {
|
||||
const theme = useTheme();
|
||||
return <NoDataMessage theme={theme}>No data</NoDataMessage>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Individual grid cell component - memoized to prevent unnecessary re-renders
|
||||
*/
|
||||
const MatrixifyGridCell = memo(
|
||||
({ cell, rowHeight, datasource, hooks }: MatrixifyGridCellProps) => {
|
||||
// Use computed title from template (will be empty string if no template)
|
||||
const cellLabel = cell.title || '';
|
||||
|
||||
// Only show label if it has content
|
||||
const showLabel = cellLabel && cellLabel.trim() !== '';
|
||||
|
||||
// Create enhanced hooks that merge cell filters with drill filters
|
||||
const enhancedHooks = useMemo(() => {
|
||||
if (!hooks) return undefined;
|
||||
|
||||
// Create a new hooks object with wrapped onContextMenu
|
||||
const wrappedHooks = { ...hooks };
|
||||
|
||||
if (hooks.onContextMenu) {
|
||||
wrappedHooks.onContextMenu = (
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
filters?: any,
|
||||
) => {
|
||||
// Get the cell's adhoc filters
|
||||
const cellFilters = cell.formData.adhoc_filters || [];
|
||||
|
||||
// Merge the cell filters with any drill filters
|
||||
const enhancedFilters = {
|
||||
...filters,
|
||||
// Add cell-specific context to help identify this is from a matrix cell
|
||||
matrixifyContext: {
|
||||
rowLabel: cell.rowLabel,
|
||||
colLabel: cell.colLabel,
|
||||
row: cell.row,
|
||||
col: cell.col,
|
||||
// Include the cell's filters so they can be applied to drill operations
|
||||
cellFilters,
|
||||
// Include the cell's formData which has adhoc_filters for drill-to-detail
|
||||
cellFormData: cell.formData,
|
||||
},
|
||||
};
|
||||
|
||||
// Call the original handler with enhanced filters
|
||||
hooks.onContextMenu(offsetX, offsetY, enhancedFilters);
|
||||
};
|
||||
}
|
||||
|
||||
return wrappedHooks;
|
||||
}, [hooks, cell]);
|
||||
|
||||
return (
|
||||
<CellContainer
|
||||
className="matrixify-cell"
|
||||
data-row={cell.row}
|
||||
data-col={cell.col}
|
||||
data-row-label={cell.rowLabel}
|
||||
data-col-label={cell.colLabel}
|
||||
>
|
||||
{showLabel && <CellHeader title={cellLabel}>{cellLabel}</CellHeader>}
|
||||
<ChartWrapper>
|
||||
<StatefulChart
|
||||
id={cell.id}
|
||||
formData={cell.formData}
|
||||
width="100%"
|
||||
height="100%"
|
||||
enableNoResults
|
||||
noDataComponent={MatrixNoDataComponent}
|
||||
showLoading
|
||||
hooks={enhancedHooks}
|
||||
/>
|
||||
</ChartWrapper>
|
||||
</CellContainer>
|
||||
);
|
||||
},
|
||||
// Custom comparison function to prevent unnecessary re-renders
|
||||
// Returns true to skip re-render, false to re-render
|
||||
(prevProps, nextProps) => {
|
||||
// Always re-render if formData changes
|
||||
if (
|
||||
JSON.stringify(prevProps.cell.formData) !==
|
||||
JSON.stringify(nextProps.cell.formData)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-render if rowHeight changes
|
||||
if (prevProps.rowHeight !== nextProps.rowHeight) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-render if cell position changes (shouldn't happen, but just in case)
|
||||
if (prevProps.cell.id !== nextProps.cell.id) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Re-render if title changes
|
||||
if (prevProps.cell.title !== nextProps.cell.title) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Skip re-render if nothing important changed
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
MatrixifyGridCell.displayName = 'MatrixifyGridCell';
|
||||
|
||||
export default MatrixifyGridCell;
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* 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 { generateMatrixifyGrid } from './MatrixifyGridGenerator';
|
||||
import { AdhocMetric } from '../../../query/types/Metric';
|
||||
|
||||
// Use 'any' to bypass strict typing in tests
|
||||
type TestFormData = any;
|
||||
|
||||
const createAdhocMetric = (label: string): AdhocMetric => ({
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'value' },
|
||||
aggregate: 'SUM',
|
||||
label,
|
||||
});
|
||||
|
||||
const createSqlMetric = (label: string, sql: string): AdhocMetric => ({
|
||||
expressionType: 'SQL',
|
||||
sqlExpression: sql,
|
||||
label,
|
||||
});
|
||||
|
||||
const baseFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createAdhocMetric('Revenue'), createAdhocMetric('Profit')],
|
||||
matrixify_columns: [
|
||||
createSqlMetric('Q1', 'SUM(CASE WHEN quarter = 1 THEN value END)'),
|
||||
createSqlMetric('Q2', 'SUM(CASE WHEN quarter = 2 THEN value END)'),
|
||||
],
|
||||
matrixify_cell_title_template: '{{row}} - {{column}}',
|
||||
};
|
||||
|
||||
test('should generate a 2x2 grid for metrics mode', () => {
|
||||
const grid = generateMatrixifyGrid(baseFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual(['Revenue', 'Profit']);
|
||||
expect(grid!.colHeaders).toEqual(['Q1', 'Q2']);
|
||||
expect(grid!.cells).toHaveLength(2);
|
||||
expect(grid!.cells[0]).toHaveLength(2);
|
||||
|
||||
// Check first cell
|
||||
const firstCell = grid!.cells[0][0];
|
||||
expect(firstCell).toBeDefined();
|
||||
expect(firstCell!.id).toBe('cell-0-0');
|
||||
expect(firstCell!.row).toBe(0);
|
||||
expect(firstCell!.col).toBe(0);
|
||||
expect(firstCell!.rowLabel).toBe('Revenue');
|
||||
expect(firstCell!.colLabel).toBe('Q1');
|
||||
expect(firstCell!.title).toBe('Revenue - Q1');
|
||||
expect(firstCell!.formData.metrics).toEqual([
|
||||
createAdhocMetric('Revenue'),
|
||||
createSqlMetric('Q1', 'SUM(CASE WHEN quarter = 1 THEN value END)'),
|
||||
]);
|
||||
});
|
||||
|
||||
test('should generate grid for dimensions mode', () => {
|
||||
const dimensionFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: {
|
||||
dimension: 'country',
|
||||
values: ['USA', 'Canada'],
|
||||
},
|
||||
matrixify_dimension_columns: {
|
||||
dimension: 'product',
|
||||
values: ['Widget', 'Gadget'],
|
||||
},
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(dimensionFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual(['USA', 'Canada']);
|
||||
expect(grid!.colHeaders).toEqual(['Widget', 'Gadget']);
|
||||
expect(grid!.cells).toHaveLength(2);
|
||||
expect(grid!.cells[0]).toHaveLength(2);
|
||||
|
||||
// Check that filters are applied correctly
|
||||
const firstCell = grid!.cells[0][0];
|
||||
expect(firstCell!.formData.adhoc_filters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subject: 'country',
|
||||
comparator: 'USA',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
subject: 'product',
|
||||
comparator: 'Widget',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should generate grid for mixed mode (metrics rows, dimensions columns)', () => {
|
||||
const mixedFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_rows: [createAdhocMetric('Total Sales')],
|
||||
matrixify_dimension_columns: {
|
||||
dimension: 'region',
|
||||
values: ['North', 'South', 'East', 'West'],
|
||||
},
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(mixedFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual(['Total Sales']);
|
||||
expect(grid!.colHeaders).toEqual(['North', 'South', 'East', 'West']);
|
||||
expect(grid!.cells).toHaveLength(1);
|
||||
expect(grid!.cells[0]).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle empty configuration', () => {
|
||||
const emptyFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [],
|
||||
matrixify_columns: [],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(emptyFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual([]);
|
||||
expect(grid!.colHeaders).toEqual([]);
|
||||
expect(grid!.cells).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle single row and column', () => {
|
||||
const singleCellFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createAdhocMetric('Count')],
|
||||
matrixify_columns: [createAdhocMetric('Total')],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(singleCellFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual(['Count']);
|
||||
expect(grid!.colHeaders).toEqual(['Total']);
|
||||
expect(grid!.cells).toHaveLength(1);
|
||||
expect(grid!.cells[0]).toHaveLength(1);
|
||||
expect(grid!.cells[0][0]!.title).toBe(''); // No template provided
|
||||
});
|
||||
|
||||
test('should handle string metrics', () => {
|
||||
const stringMetricFormData: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: ['count', 'sum'],
|
||||
matrixify_columns: ['avg', 'max'],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(stringMetricFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.rowHeaders).toEqual(['count', 'sum']);
|
||||
expect(grid!.colHeaders).toEqual(['avg', 'max']);
|
||||
});
|
||||
|
||||
test('should not escape HTML entities in cell titles', () => {
|
||||
const formDataWithSpecialChars: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createAdhocMetric('Sales & Revenue')],
|
||||
matrixify_columns: [createAdhocMetric('Q1 > Q2')],
|
||||
matrixify_cell_title_template: '{{row}} < {{column}}',
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(formDataWithSpecialChars);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
const firstCell = grid!.cells[0][0];
|
||||
// Should NOT escape HTML entities
|
||||
expect(firstCell!.title).toBe('Sales & Revenue < Q1 > Q2');
|
||||
expect(firstCell!.title).not.toContain('&');
|
||||
expect(firstCell!.title).not.toContain('<');
|
||||
expect(firstCell!.title).not.toContain('>');
|
||||
});
|
||||
|
||||
test('should apply chart-specific configurations', () => {
|
||||
const chartConfigFormData: TestFormData = {
|
||||
...baseFormData,
|
||||
row_limit: 100,
|
||||
time_range: 'Last month',
|
||||
granularity_sqla: 'day',
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(chartConfigFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
// Check that chart-specific configs are preserved
|
||||
const cell = grid!.cells[0][0];
|
||||
expect(cell!.formData.row_limit).toBe(100);
|
||||
expect(cell!.formData.time_range).toBe('Last month');
|
||||
expect(cell!.formData.granularity_sqla).toBe('day');
|
||||
});
|
||||
|
||||
test('should generate unique cell IDs', () => {
|
||||
const grid = generateMatrixifyGrid(baseFormData);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
const cellIds = new Set<string>();
|
||||
|
||||
const nonNullCells: { id: string }[] = [];
|
||||
grid!.cells.forEach(row => {
|
||||
row.forEach(cell => {
|
||||
if (cell) {
|
||||
nonNullCells.push(cell);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
nonNullCells.forEach(cell => {
|
||||
expect(cellIds.has(cell.id)).toBe(false);
|
||||
cellIds.add(cell.id);
|
||||
});
|
||||
|
||||
expect(cellIds.size).toBe(4); // 2x2 grid
|
||||
});
|
||||
|
||||
test('should handle template with special characters', () => {
|
||||
const formDataWithSpecialTemplate: TestFormData = {
|
||||
...baseFormData,
|
||||
matrixify_cell_title_template: '{{row}} | {{column}} (%)',
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(formDataWithSpecialTemplate);
|
||||
expect(grid).not.toBeNull();
|
||||
expect(grid!.cells[0][0]!.title).toBe('Revenue | Q1 (%)');
|
||||
});
|
||||
|
||||
test('should preserve existing adhoc filters', () => {
|
||||
const formDataWithFilters: TestFormData = {
|
||||
...baseFormData,
|
||||
adhoc_filters: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
subject: 'year',
|
||||
operator: '==',
|
||||
comparator: 2024,
|
||||
clause: 'WHERE',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(formDataWithFilters);
|
||||
expect(grid).not.toBeNull();
|
||||
const cell = grid!.cells[0][0];
|
||||
|
||||
// In metrics mode, filters are not added per cell
|
||||
expect(cell!.formData.adhoc_filters).toHaveLength(1);
|
||||
expect(cell!.formData.adhoc_filters).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subject: 'year',
|
||||
comparator: 2024,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('should handle metrics without labels', () => {
|
||||
const metricsWithoutLabels: TestFormData = {
|
||||
viz_type: 'table',
|
||||
datasource: '1__table',
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [
|
||||
{
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'value' },
|
||||
aggregate: 'SUM',
|
||||
optionName: 'SUM(value)',
|
||||
},
|
||||
],
|
||||
matrixify_columns: ['count'],
|
||||
};
|
||||
|
||||
const grid = generateMatrixifyGrid(metricsWithoutLabels);
|
||||
|
||||
expect(grid).not.toBeNull();
|
||||
// Metrics without labels show empty string
|
||||
expect(grid!.rowHeaders).toEqual(['']);
|
||||
expect(grid!.colHeaders).toEqual(['count']);
|
||||
});
|
||||
@@ -0,0 +1,312 @@
|
||||
/**
|
||||
* 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 Handlebars from 'handlebars';
|
||||
import type { QueryFormData } from '../../../query';
|
||||
import type {
|
||||
AdhocFilter,
|
||||
BinaryAdhocFilter,
|
||||
} from '../../../query/types/Filter';
|
||||
import {
|
||||
MatrixifyGrid,
|
||||
MatrixifyGridCell,
|
||||
MatrixifyFormData,
|
||||
getMatrixifyConfig,
|
||||
MatrixifyAxisConfig,
|
||||
MatrixifyFilterConstants,
|
||||
} from '../../types/matrixify';
|
||||
|
||||
/**
|
||||
* Generate title from template using Handlebars
|
||||
*/
|
||||
function generateCellTitle(
|
||||
rowLabel: string,
|
||||
colLabel: string,
|
||||
template?: string,
|
||||
): string {
|
||||
if (!template) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
// Compile the Handlebars template with noEscape option to prevent HTML entity encoding
|
||||
const compiledTemplate = Handlebars.compile(template, { noEscape: true });
|
||||
|
||||
// Create context with both naming conventions for flexibility
|
||||
const context = {
|
||||
row: rowLabel,
|
||||
rowLabel,
|
||||
column: colLabel,
|
||||
columnLabel: colLabel,
|
||||
col: colLabel,
|
||||
colLabel,
|
||||
};
|
||||
|
||||
// Render the template with the context
|
||||
return compiledTemplate(context);
|
||||
} catch (error) {
|
||||
// If template compilation fails, return empty string
|
||||
console.warn('Failed to compile Handlebars template:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract label from a metric or dimension value
|
||||
*/
|
||||
function getAxisLabel(axisConfig: MatrixifyAxisConfig, index: number): string {
|
||||
if (axisConfig.mode === 'metrics') {
|
||||
const metric = axisConfig.metrics?.[index];
|
||||
if (!metric) return '';
|
||||
// Handle both saved metrics and adhoc metrics
|
||||
if (typeof metric === 'string') {
|
||||
return metric;
|
||||
}
|
||||
return metric.label || '';
|
||||
}
|
||||
|
||||
// For dimensions mode
|
||||
const dimensionValue = axisConfig.dimension?.values[index];
|
||||
return dimensionValue?.toString() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Create filter for a specific dimension value
|
||||
* Using Matrixify-specific constants that match the literal types defined in Filter.ts
|
||||
*/
|
||||
function createDimensionFilter(
|
||||
dimension: string,
|
||||
value: any,
|
||||
): BinaryAdhocFilter {
|
||||
return {
|
||||
expressionType: MatrixifyFilterConstants.ExpressionType.SIMPLE,
|
||||
subject: dimension,
|
||||
operator: MatrixifyFilterConstants.Operator.EQUALS,
|
||||
comparator: value,
|
||||
clause: MatrixifyFilterConstants.Clause.WHERE,
|
||||
isExtra: false,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate form data for a specific grid cell
|
||||
*/
|
||||
function generateCellFormData(
|
||||
baseFormData: QueryFormData & MatrixifyFormData,
|
||||
rowConfig: MatrixifyAxisConfig | null,
|
||||
colConfig: MatrixifyAxisConfig | null,
|
||||
rowIndex: number | null,
|
||||
colIndex: number | null,
|
||||
): QueryFormData {
|
||||
// Start with a clean copy of the base formData
|
||||
const cellFormData: any = { ...baseFormData };
|
||||
|
||||
// Remove Matrixify-specific fields since cells shouldn't be matrixified
|
||||
Object.keys(cellFormData).forEach(key => {
|
||||
if (key.startsWith('matrixify_')) {
|
||||
delete cellFormData[key];
|
||||
}
|
||||
});
|
||||
|
||||
// Override fields that could cause issues in grid cells
|
||||
const overrides: Partial<QueryFormData> = {
|
||||
slice_name: undefined,
|
||||
slice_id: undefined,
|
||||
header_font_size: undefined,
|
||||
subheader: undefined,
|
||||
show_title: undefined,
|
||||
header_title_text_align: undefined,
|
||||
header_text: undefined,
|
||||
};
|
||||
|
||||
// Apply overrides
|
||||
Object.assign(cellFormData, overrides);
|
||||
|
||||
// Add filters for dimension-based axes
|
||||
const additionalFilters: AdhocFilter[] = [];
|
||||
|
||||
if (
|
||||
rowConfig &&
|
||||
rowIndex !== null &&
|
||||
rowConfig.mode === 'dimensions' &&
|
||||
rowConfig.dimension
|
||||
) {
|
||||
const value = rowConfig.dimension.values[rowIndex];
|
||||
if (value !== undefined) {
|
||||
additionalFilters.push(
|
||||
createDimensionFilter(rowConfig.dimension.dimension, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
colConfig &&
|
||||
colIndex !== null &&
|
||||
colConfig.mode === 'dimensions' &&
|
||||
colConfig.dimension
|
||||
) {
|
||||
const value = colConfig.dimension.values[colIndex];
|
||||
if (value !== undefined) {
|
||||
additionalFilters.push(
|
||||
createDimensionFilter(colConfig.dimension.dimension, value),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add filters to existing adhoc_filters
|
||||
if (additionalFilters.length > 0) {
|
||||
cellFormData.adhoc_filters = [
|
||||
...(cellFormData.adhoc_filters || []),
|
||||
...additionalFilters,
|
||||
];
|
||||
}
|
||||
|
||||
// Set metrics based on row/column configuration
|
||||
const metrics = [];
|
||||
|
||||
if (rowConfig && rowIndex !== null && rowConfig.mode === 'metrics') {
|
||||
const metric = rowConfig.metrics?.[rowIndex];
|
||||
if (metric) {
|
||||
metrics.push(metric);
|
||||
}
|
||||
}
|
||||
|
||||
if (colConfig && colIndex !== null && colConfig.mode === 'metrics') {
|
||||
const metric = colConfig.metrics?.[colIndex];
|
||||
if (metric) {
|
||||
metrics.push(metric);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have metrics from the matrix, use them; otherwise keep original
|
||||
if (metrics.length > 0) {
|
||||
cellFormData.metrics = metrics;
|
||||
}
|
||||
|
||||
return cellFormData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a complete grid structure from Matrixify configuration
|
||||
*/
|
||||
export function generateMatrixifyGrid(
|
||||
formData: QueryFormData & MatrixifyFormData,
|
||||
): MatrixifyGrid | null {
|
||||
const config = getMatrixifyConfig(formData);
|
||||
if (!config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Determine row headers and count
|
||||
let rowHeaders: string[] = [];
|
||||
let rowCount = 0;
|
||||
|
||||
if (config.rows.mode === 'metrics' && config.rows.metrics) {
|
||||
rowCount = config.rows.metrics.length;
|
||||
rowHeaders = config.rows.metrics.map((_, idx) =>
|
||||
getAxisLabel(config.rows, idx),
|
||||
);
|
||||
} else if (
|
||||
config.rows.mode === 'dimensions' &&
|
||||
config.rows.dimension?.values
|
||||
) {
|
||||
rowCount = config.rows.dimension.values.length;
|
||||
rowHeaders = config.rows.dimension.values.map((_, idx) =>
|
||||
getAxisLabel(config.rows, idx),
|
||||
);
|
||||
}
|
||||
|
||||
// Determine column headers and count
|
||||
let colHeaders: string[] = [];
|
||||
let colCount = 0;
|
||||
|
||||
if (config.columns.mode === 'metrics' && config.columns.metrics) {
|
||||
colCount = config.columns.metrics.length;
|
||||
colHeaders = config.columns.metrics.map((_, idx) =>
|
||||
getAxisLabel(config.columns, idx),
|
||||
);
|
||||
} else if (
|
||||
config.columns.mode === 'dimensions' &&
|
||||
config.columns.dimension?.values
|
||||
) {
|
||||
colCount = config.columns.dimension.values.length;
|
||||
colHeaders = config.columns.dimension.values.map((_, idx) =>
|
||||
getAxisLabel(config.columns, idx),
|
||||
);
|
||||
}
|
||||
|
||||
// If only rows are configured, create a single column grid
|
||||
if (rowCount > 0 && colCount === 0) {
|
||||
colCount = 1;
|
||||
colHeaders = [''];
|
||||
}
|
||||
|
||||
// If only columns are configured, create a single row grid
|
||||
if (colCount > 0 && rowCount === 0) {
|
||||
rowCount = 1;
|
||||
rowHeaders = [''];
|
||||
}
|
||||
|
||||
// Generate grid cells
|
||||
const cells: (MatrixifyGridCell | null)[][] = [];
|
||||
|
||||
for (let row = 0; row < rowCount; row += 1) {
|
||||
const rowCells: (MatrixifyGridCell | null)[] = [];
|
||||
|
||||
for (let col = 0; col < colCount; col += 1) {
|
||||
const id = `cell-${row}-${col}`;
|
||||
const rowLabel = rowHeaders[row];
|
||||
const colLabel = colHeaders[col];
|
||||
|
||||
const cellFormData = generateCellFormData(
|
||||
formData,
|
||||
rowCount > 1 ? config.rows : null,
|
||||
colCount > 1 ? config.columns : null,
|
||||
rowCount > 1 ? row : null,
|
||||
colCount > 1 ? col : null,
|
||||
);
|
||||
|
||||
// Generate title using template if provided
|
||||
const title = generateCellTitle(
|
||||
rowLabel,
|
||||
colLabel,
|
||||
formData.matrixify_cell_title_template,
|
||||
);
|
||||
|
||||
rowCells.push({
|
||||
id,
|
||||
row,
|
||||
col,
|
||||
rowLabel,
|
||||
colLabel,
|
||||
title,
|
||||
formData: cellFormData,
|
||||
});
|
||||
}
|
||||
|
||||
cells.push(rowCells);
|
||||
}
|
||||
|
||||
return {
|
||||
rowHeaders,
|
||||
colHeaders,
|
||||
cells,
|
||||
baseFormData: formData,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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 } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ThemeProvider } from '@superset-ui/core';
|
||||
import MatrixifyGridRenderer from './MatrixifyGridRenderer';
|
||||
import { generateMatrixifyGrid } from './MatrixifyGridGenerator';
|
||||
import { supersetTheme } from '../../../theme';
|
||||
|
||||
// Mock the MatrixifyGridGenerator
|
||||
jest.mock('./MatrixifyGridGenerator', () => ({
|
||||
generateMatrixifyGrid: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock MatrixifyGridCell component
|
||||
jest.mock('./MatrixifyGridCell', () =>
|
||||
// eslint-disable-next-line react/display-name, @typescript-eslint/no-unused-vars
|
||||
({ cell, rowHeight, datasource, hooks }: any) => (
|
||||
<div data-testid={`grid-cell-${cell.id}`}>Cell: {cell.id}</div>
|
||||
),
|
||||
);
|
||||
|
||||
const mockGenerateMatrixifyGrid = generateMatrixifyGrid as jest.MockedFunction<
|
||||
typeof generateMatrixifyGrid
|
||||
>;
|
||||
|
||||
const renderWithTheme = (component: React.ReactElement) =>
|
||||
render(<ThemeProvider theme={supersetTheme}>{component}</ThemeProvider>);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should create single group when fitting columns dynamically', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1', 'Row 2'],
|
||||
colHeaders: ['Col 1', 'Col 2', 'Col 3', 'Col 4', 'Col 5'],
|
||||
cells: [
|
||||
[
|
||||
{ id: 'cell-0-0' },
|
||||
{ id: 'cell-0-1' },
|
||||
{ id: 'cell-0-2' },
|
||||
{ id: 'cell-0-3' },
|
||||
{ id: 'cell-0-4' },
|
||||
],
|
||||
[
|
||||
{ id: 'cell-1-0' },
|
||||
{ id: 'cell-1-1' },
|
||||
{ id: 'cell-1-2' },
|
||||
{ id: 'cell-1-3' },
|
||||
{ id: 'cell-1-4' },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: true,
|
||||
matrixify_charts_per_row: 3,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// When fitting dynamically, should have only one column group with all 5 columns
|
||||
// Check for the presence of the grid structure
|
||||
const gridContainers = container.querySelectorAll('div[class*="css-"]');
|
||||
expect(gridContainers.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify all 5 column headers are present in single group
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders).toHaveLength(5);
|
||||
expect(columnHeaders[0]).toHaveTextContent('Col 1');
|
||||
expect(columnHeaders[4]).toHaveTextContent('Col 5');
|
||||
});
|
||||
|
||||
test('should create multiple groups when not fitting columns dynamically', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1', 'Row 2'],
|
||||
colHeaders: ['Col 1', 'Col 2', 'Col 3', 'Col 4', 'Col 5'],
|
||||
cells: [
|
||||
[
|
||||
{ id: 'cell-0-0' },
|
||||
{ id: 'cell-0-1' },
|
||||
{ id: 'cell-0-2' },
|
||||
{ id: 'cell-0-3' },
|
||||
{ id: 'cell-0-4' },
|
||||
],
|
||||
[
|
||||
{ id: 'cell-1-0' },
|
||||
{ id: 'cell-1-1' },
|
||||
{ id: 'cell-1-2' },
|
||||
{ id: 'cell-1-3' },
|
||||
{ id: 'cell-1-4' },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: false,
|
||||
matrixify_charts_per_row: 3,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// With 5 columns and charts_per_row=3, should have 2 groups (3+2)
|
||||
// With 2 rows and wrapping, we should see headers repeated
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders.length).toBeGreaterThanOrEqual(5); // At least the base headers
|
||||
});
|
||||
|
||||
test('should handle exact division of columns', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1'],
|
||||
colHeaders: ['Col 1', 'Col 2', 'Col 3', 'Col 4'],
|
||||
cells: [
|
||||
[
|
||||
{ id: 'cell-0-0' },
|
||||
{ id: 'cell-0-1' },
|
||||
{ id: 'cell-0-2' },
|
||||
{ id: 'cell-0-3' },
|
||||
],
|
||||
],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: false,
|
||||
matrixify_charts_per_row: 2,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// With 4 columns and charts_per_row=2, should have exactly 2 groups (2+2)
|
||||
// Check that we have column headers - should be 4 total (2 per group)
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('should handle case where charts_per_row exceeds total columns', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1'],
|
||||
colHeaders: ['Col 1', 'Col 2'],
|
||||
cells: [[{ id: 'cell-0-0' }, { id: 'cell-0-1' }]],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: false,
|
||||
matrixify_charts_per_row: 5,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// Should create only one group with all columns
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders).toHaveLength(2);
|
||||
});
|
||||
|
||||
test('should show headers for each group when wrapping occurs', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1', 'Row 2'],
|
||||
colHeaders: ['Col 1', 'Col 2', 'Col 3'],
|
||||
cells: [
|
||||
[{ id: 'cell-0-0' }, { id: 'cell-0-1' }, { id: 'cell-0-2' }],
|
||||
[{ id: 'cell-1-0' }, { id: 'cell-1-1' }, { id: 'cell-1-2' }],
|
||||
],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: false,
|
||||
matrixify_charts_per_row: 2,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// With wrapping (multiple column groups), headers should appear for each group
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders.length).toBeGreaterThan(3); // More than just one set of headers
|
||||
|
||||
// Row headers should appear only once per row (for first column group)
|
||||
const rowHeaders = container.querySelectorAll('.matrixify-row-header');
|
||||
expect(rowHeaders).toHaveLength(2); // One for each row
|
||||
});
|
||||
|
||||
test('should show headers only on first row when not wrapping', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1', 'Row 2'],
|
||||
colHeaders: ['Col 1', 'Col 2'],
|
||||
cells: [
|
||||
[{ id: 'cell-0-0' }, { id: 'cell-0-1' }],
|
||||
[{ id: 'cell-1-0' }, { id: 'cell-1-1' }],
|
||||
],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: true, // No wrapping
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// Without wrapping, headers should appear only once (first row)
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders).toHaveLength(2); // Just Col 1 and Col 2
|
||||
|
||||
const rowHeaders = container.querySelectorAll('.matrixify-row-header');
|
||||
expect(rowHeaders).toHaveLength(2); // One for each row
|
||||
});
|
||||
|
||||
test('should hide headers when disabled', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1'],
|
||||
colHeaders: ['Col 1', 'Col 2'],
|
||||
cells: [[{ id: 'cell-0-0' }, { id: 'cell-0-1' }]],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_show_row_labels: false,
|
||||
matrixify_show_column_headers: false,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
const columnHeaders = container.querySelectorAll('.matrixify-col-header');
|
||||
expect(columnHeaders).toHaveLength(0);
|
||||
|
||||
const rowHeaders = container.querySelectorAll('.matrixify-row-header');
|
||||
expect(rowHeaders).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should place cells correctly in wrapped layout', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1'],
|
||||
colHeaders: ['Col 1', 'Col 2', 'Col 3'],
|
||||
cells: [[{ id: 'cell-0-0' }, { id: 'cell-0-1' }, { id: 'cell-0-2' }]],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
matrixify_fit_columns_dynamically: false,
|
||||
matrixify_charts_per_row: 2,
|
||||
matrixify_show_row_labels: true,
|
||||
matrixify_show_column_headers: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// All cells should be rendered
|
||||
const cells = container.querySelectorAll('[data-testid^="grid-cell-"]');
|
||||
expect(cells).toHaveLength(3);
|
||||
expect(
|
||||
container.querySelector('[data-testid="grid-cell-cell-0-0"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-testid="grid-cell-cell-0-1"]'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelector('[data-testid="grid-cell-cell-0-2"]'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should handle null grid gracefully', () => {
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(null);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
test('should handle empty grid gracefully', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: [],
|
||||
colHeaders: [],
|
||||
cells: [],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// Should render container but no cells
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
const gridCells = container.querySelectorAll('[data-testid^="grid-cell-"]');
|
||||
expect(gridCells).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should use default values for missing configuration', () => {
|
||||
const mockGrid: any = {
|
||||
rowHeaders: ['Row 1'],
|
||||
colHeaders: ['Col 1', 'Col 2'],
|
||||
cells: [[{ id: 'cell-0-0' }, { id: 'cell-0-1' }]],
|
||||
};
|
||||
|
||||
mockGenerateMatrixifyGrid.mockReturnValue(mockGrid);
|
||||
|
||||
const formData = {
|
||||
viz_type: 'test_chart',
|
||||
matrixify_enabled: true,
|
||||
// Missing optional configurations
|
||||
};
|
||||
|
||||
const { container } = renderWithTheme(
|
||||
<MatrixifyGridRenderer formData={formData} />,
|
||||
);
|
||||
|
||||
// Should still render with defaults
|
||||
expect(container).not.toBeEmptyDOMElement();
|
||||
const gridCells = container.querySelectorAll('[data-testid^="grid-cell-"]');
|
||||
expect(gridCells).toHaveLength(2);
|
||||
});
|
||||
@@ -0,0 +1,272 @@
|
||||
/**
|
||||
* 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 { styled } from '../../../theme';
|
||||
import { MatrixifyFormData } from '../../types/matrixify';
|
||||
import { generateMatrixifyGrid } from './MatrixifyGridGenerator';
|
||||
import MatrixifyGridCell from './MatrixifyGridCell';
|
||||
|
||||
// Layout constants
|
||||
const HEADER_HEIGHT = 24; // Height for column headers and width for row headers (reduced from 32)
|
||||
const HEADER_MIN_WIDTH = 20; // Minimum width for row headers (reduced from 24)
|
||||
const HEADER_MAX_WIDTH = 24; // Maximum width for row headers (reduced from 32)
|
||||
const GRID_GAP = 8; // Gap between grid cells (reduced from 16 for more density)
|
||||
const GROUP_SPACING = 16; // Spacing between column groups when wrapping (reduced from 32)
|
||||
const DEFAULT_ROW_HEIGHT = 300; // Default height for each row
|
||||
const DEFAULT_CHARTS_PER_ROW = 3; // Default number of charts per row when not fitting dynamically
|
||||
|
||||
const GridContainer = styled.div<{ height?: number }>`
|
||||
width: 100%;
|
||||
${({ height }) => height && `height: ${height}px;`}
|
||||
padding: ${({ theme }) =>
|
||||
theme.sizeUnit}px; /* Reduced padding for more density */
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`;
|
||||
|
||||
const GridScrollContainer = styled.div`
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const GridLayout = styled.div<{
|
||||
columns: number;
|
||||
hasRowHeaders: boolean;
|
||||
rowHeight: number;
|
||||
hasColumnHeaders: boolean;
|
||||
maxColumns: number; // Maximum columns to maintain consistent width
|
||||
}>`
|
||||
display: grid;
|
||||
grid-template-columns: ${({ maxColumns, hasRowHeaders }) =>
|
||||
hasRowHeaders
|
||||
? `${HEADER_HEIGHT}px repeat(${maxColumns}, minmax(0, 1fr))`
|
||||
: `repeat(${maxColumns}, minmax(0, 1fr))`};
|
||||
${({ hasColumnHeaders, rowHeight }) =>
|
||||
hasColumnHeaders
|
||||
? `grid-template-rows: ${HEADER_HEIGHT}px; grid-auto-rows: ${rowHeight}px;`
|
||||
: `grid-auto-rows: ${rowHeight}px;`}
|
||||
gap: ${GRID_GAP}px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
`;
|
||||
|
||||
const GridGroup = styled.div<{ isLast: boolean }>`
|
||||
margin-bottom: ${({ isLast }) => (isLast ? 0 : GROUP_SPACING)}px;
|
||||
`;
|
||||
|
||||
const GridHeader = styled.div`
|
||||
background-color: ${({ theme }) => theme.colorFillAlter};
|
||||
padding: ${({ theme }) => theme.sizeUnit / 2}px; /* Reduced padding */
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
text-align: center;
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
border-radius: ${({ theme }) => theme.borderRadius}px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: ${({ theme }) =>
|
||||
theme.fontSizeSM}px; /* Back to small (readable) font */
|
||||
|
||||
&.matrixify-row-header {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(-180deg);
|
||||
padding: ${({ theme }) => theme.sizeUnit}px
|
||||
${({ theme }) => theme.sizeUnit / 4}px; /* Tighter padding */
|
||||
min-width: ${HEADER_MIN_WIDTH}px;
|
||||
max-width: ${HEADER_MAX_WIDTH}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&.matrixify-col-header {
|
||||
height: ${HEADER_HEIGHT}px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
`;
|
||||
|
||||
interface MatrixifyGridRendererProps {
|
||||
formData: MatrixifyFormData;
|
||||
datasource?: any;
|
||||
width?: number;
|
||||
height?: number;
|
||||
hooks?: any;
|
||||
}
|
||||
|
||||
function MatrixifyGridRenderer({
|
||||
formData,
|
||||
datasource,
|
||||
width,
|
||||
height,
|
||||
hooks,
|
||||
}: MatrixifyGridRendererProps) {
|
||||
// Generate grid structure from form data
|
||||
const grid = useMemo(
|
||||
() => generateMatrixifyGrid(formData as any),
|
||||
[formData],
|
||||
);
|
||||
|
||||
// Determine layout parameters
|
||||
const showRowLabels = formData.matrixify_show_row_labels ?? true;
|
||||
const showColumnHeaders = formData.matrixify_show_column_headers ?? true;
|
||||
const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT;
|
||||
const fitColumnsDynamically =
|
||||
formData.matrixify_fit_columns_dynamically ?? true;
|
||||
const chartsPerRow =
|
||||
formData.matrixify_charts_per_row || DEFAULT_CHARTS_PER_ROW;
|
||||
|
||||
// Calculate column groups for wrapping - must be before conditional return
|
||||
const columnGroups = useMemo(() => {
|
||||
if (!grid) {
|
||||
return [];
|
||||
}
|
||||
const { colHeaders: headers } = grid;
|
||||
const totalCols = headers.length;
|
||||
const colsPerRow = fitColumnsDynamically
|
||||
? totalCols
|
||||
: Math.min(chartsPerRow, totalCols);
|
||||
|
||||
const groups = [];
|
||||
for (let i = 0; i < totalCols; i += colsPerRow) {
|
||||
groups.push({
|
||||
startIdx: i,
|
||||
endIdx: Math.min(i + colsPerRow, totalCols),
|
||||
headers: headers.slice(i, Math.min(i + colsPerRow, totalCols)),
|
||||
});
|
||||
}
|
||||
return groups;
|
||||
}, [grid, fitColumnsDynamically, chartsPerRow]);
|
||||
|
||||
if (!grid) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { rowHeaders, colHeaders, cells } = grid;
|
||||
|
||||
// Calculate actual columns per row
|
||||
const totalColumns = colHeaders.length;
|
||||
const columnsPerRow = fitColumnsDynamically
|
||||
? totalColumns
|
||||
: Math.min(chartsPerRow, totalColumns);
|
||||
|
||||
const hasRowHeaders = showRowLabels && rowHeaders.length > 0;
|
||||
const hasColumnHeaders = showColumnHeaders && colHeaders.length > 0;
|
||||
|
||||
return (
|
||||
<GridContainer height={height}>
|
||||
<GridScrollContainer>
|
||||
{/* Iterate through each row first */}
|
||||
{cells.map((row, rowIdx) => (
|
||||
<div key={`row-${rowIdx}`}>
|
||||
{/* Then iterate through column groups for this row */}
|
||||
{columnGroups.map((colGroup, groupIdx) => {
|
||||
const groupColumns = colGroup.endIdx - colGroup.startIdx;
|
||||
const emptyColumns = columnsPerRow - groupColumns;
|
||||
const isLastGroup = groupIdx === columnGroups.length - 1;
|
||||
const isLastRow = rowIdx === cells.length - 1;
|
||||
|
||||
// Show headers: always when wrapping (multiple column groups), only first row when not wrapping
|
||||
const showHeadersForThisGroup =
|
||||
hasColumnHeaders && (columnGroups.length > 1 || rowIdx === 0);
|
||||
|
||||
return (
|
||||
<GridGroup
|
||||
key={`row-${rowIdx}-col-group-${groupIdx}`}
|
||||
isLast={isLastGroup && isLastRow}
|
||||
>
|
||||
<GridLayout
|
||||
columns={groupColumns}
|
||||
maxColumns={columnsPerRow}
|
||||
hasRowHeaders={hasRowHeaders}
|
||||
rowHeight={rowHeight}
|
||||
hasColumnHeaders={showHeadersForThisGroup}
|
||||
>
|
||||
{/* Corner cell (empty) - when showing headers */}
|
||||
{showHeadersForThisGroup && hasRowHeaders && <div />}
|
||||
|
||||
{/* Column headers - show based on wrapping logic */}
|
||||
{showHeadersForThisGroup && (
|
||||
<>
|
||||
{colGroup.headers.map((header, idx) => (
|
||||
<GridHeader
|
||||
key={`col-header-${rowIdx}-${groupIdx}-${idx}`}
|
||||
className="matrixify-col-header"
|
||||
title={header}
|
||||
>
|
||||
{header}
|
||||
</GridHeader>
|
||||
))}
|
||||
{/* Empty cells to maintain grid structure */}
|
||||
{Array.from({ length: emptyColumns }).map((_, idx) => (
|
||||
<div
|
||||
key={`empty-header-${rowIdx}-${groupIdx}-${idx}`}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Row header - only for first column group */}
|
||||
{hasRowHeaders && groupIdx === 0 && (
|
||||
<GridHeader
|
||||
key={`row-header-${rowIdx}`}
|
||||
className="matrixify-row-header"
|
||||
title={rowHeaders[rowIdx]}
|
||||
>
|
||||
{rowHeaders[rowIdx]}
|
||||
</GridHeader>
|
||||
)}
|
||||
{/* Empty cell if not first column group but has row headers */}
|
||||
{hasRowHeaders && groupIdx > 0 && <div />}
|
||||
|
||||
{/* Row cells for this column group */}
|
||||
{row
|
||||
.slice(colGroup.startIdx, colGroup.endIdx)
|
||||
.map((cell, colIdx) =>
|
||||
cell ? (
|
||||
<MatrixifyGridCell
|
||||
key={cell.id}
|
||||
cell={cell}
|
||||
rowHeight={rowHeight}
|
||||
datasource={datasource}
|
||||
hooks={hooks}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
{/* Empty cells to maintain grid structure */}
|
||||
{Array.from({ length: emptyColumns }).map((_, idx) => (
|
||||
<div key={`empty-cell-${rowIdx}-${groupIdx}-${idx}`} />
|
||||
))}
|
||||
</GridLayout>
|
||||
</GridGroup>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</GridScrollContainer>
|
||||
</GridContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default MatrixifyGridRenderer;
|
||||
@@ -0,0 +1,473 @@
|
||||
/**
|
||||
* 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, waitFor, configure } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import StatefulChart from './StatefulChart';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
|
||||
// Configure testing library to use data-test attribute
|
||||
configure({ testIdAttribute: 'data-test' });
|
||||
|
||||
// Mock the registries
|
||||
jest.mock('../registries/ChartControlPanelRegistrySingleton');
|
||||
jest.mock('../registries/ChartMetadataRegistrySingleton');
|
||||
jest.mock('../registries/ChartBuildQueryRegistrySingleton');
|
||||
jest.mock('../clients/ChartClient');
|
||||
|
||||
// Mock SuperChart component
|
||||
jest.mock('./SuperChart', () => ({
|
||||
__esModule: true,
|
||||
// eslint-disable-next-line react/display-name
|
||||
default: ({ formData }: any) => (
|
||||
<div data-test="super-chart">SuperChart: {JSON.stringify(formData)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock Loading component
|
||||
jest.mock('../../components/Loading', () => ({
|
||||
// eslint-disable-next-line react/display-name
|
||||
Loading: () => <div data-test="loading">Loading...</div>,
|
||||
}));
|
||||
|
||||
const mockChartClient = {
|
||||
client: {
|
||||
post: jest.fn().mockResolvedValue({
|
||||
json: [{ data: 'test data' }],
|
||||
}),
|
||||
},
|
||||
loadFormData: jest.fn(),
|
||||
};
|
||||
|
||||
const mockFormData = {
|
||||
viz_type: 'test_chart',
|
||||
datasource: '1__table',
|
||||
color_scheme: 'default',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup default registry mocks
|
||||
(getChartMetadataRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue({
|
||||
useLegacyApi: false,
|
||||
}),
|
||||
});
|
||||
|
||||
(getChartBuildQueryRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
// Mock ChartClient constructor
|
||||
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
|
||||
const ChartClient = require('../clients/ChartClient').default; // eslint-disable-line
|
||||
ChartClient.mockImplementation(() => mockChartClient);
|
||||
});
|
||||
|
||||
test('should refetch data when non-renderTrigger control changes', async () => {
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'color_scheme',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'datasource',
|
||||
config: {
|
||||
renderTrigger: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change a non-renderTrigger control (datasource)
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
datasource: '2__table',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch data
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should NOT refetch data when only renderTrigger controls change', async () => {
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'color_scheme',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'show_legend',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
});
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Verify initial render
|
||||
expect(getByTestId('super-chart')).toBeInTheDocument();
|
||||
|
||||
// Change only renderTrigger controls
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
color_scheme: 'new_scheme',
|
||||
show_legend: true,
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should NOT refetch data (still only 1 call)
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
// But should re-render with new formData
|
||||
expect(getByTestId('super-chart')).toHaveTextContent(
|
||||
JSON.stringify(updatedFormData),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refetch when control panel config is not available', async () => {
|
||||
// No control panel config available
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(null),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change any control
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
color_scheme: 'new_scheme',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch data (conservative approach when no config)
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should refetch when viz_type changes', async () => {
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'color_scheme',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change viz_type
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
viz_type: 'different_chart',
|
||||
};
|
||||
|
||||
rerender(
|
||||
<StatefulChart formData={updatedFormData} chartType="different_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should always refetch when viz_type changes
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle mixed renderTrigger and non-renderTrigger changes', async () => {
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'color_scheme',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'metrics',
|
||||
config: {
|
||||
renderTrigger: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change both renderTrigger and non-renderTrigger controls
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
color_scheme: 'new_scheme', // renderTrigger
|
||||
metrics: ['new_metric'], // non-renderTrigger
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch because a non-renderTrigger control changed
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle controls with complex structure', async () => {
|
||||
const controlPanelConfig = {
|
||||
controlPanelSections: [
|
||||
{
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
config: {
|
||||
name: 'nested_control',
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'direct_control',
|
||||
config: {
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockReturnValue(controlPanelConfig),
|
||||
});
|
||||
|
||||
const { rerender, getByTestId } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change controls that have different config structures
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
nested_control: 'value1',
|
||||
direct_control: 'value2',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should NOT refetch (both are renderTrigger)
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// But should re-render
|
||||
expect(getByTestId('super-chart')).toHaveTextContent(
|
||||
JSON.stringify(updatedFormData),
|
||||
);
|
||||
});
|
||||
|
||||
test('should not refetch when formData has not changed', async () => {
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Re-render with same formData
|
||||
rerender(<StatefulChart formData={mockFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should not refetch
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle errors gracefully when accessing registry', async () => {
|
||||
// Mock registry to throw an error
|
||||
(getChartControlPanelRegistry as any).mockReturnValue({
|
||||
get: jest.fn().mockImplementation(() => {
|
||||
throw new Error('Registry error');
|
||||
}),
|
||||
});
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change a control
|
||||
const updatedFormData = {
|
||||
...mockFormData,
|
||||
color_scheme: 'new_scheme',
|
||||
};
|
||||
|
||||
rerender(<StatefulChart formData={updatedFormData} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch data (conservative approach on error)
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle force prop correctly', async () => {
|
||||
const { rerender } = render(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Re-render with force=true
|
||||
rerender(
|
||||
<StatefulChart formData={mockFormData} chartType="test_chart" force />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should refetch when force changes
|
||||
expect(mockChartClient.client.post).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle chartId changes', async () => {
|
||||
mockChartClient.loadFormData.mockResolvedValue(mockFormData);
|
||||
|
||||
const { rerender } = render(
|
||||
<StatefulChart chartId={1} chartType="test_chart" />,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockChartClient.loadFormData).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Change chartId
|
||||
rerender(<StatefulChart chartId={2} chartType="test_chart" />);
|
||||
|
||||
await waitFor(() => {
|
||||
// Should load new formData
|
||||
expect(mockChartClient.loadFormData).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,476 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import {
|
||||
QueryFormData,
|
||||
QueryData,
|
||||
SupersetClientInterface,
|
||||
buildQueryContext,
|
||||
RequestConfig,
|
||||
} from '../..';
|
||||
import { Loading } from '../../components/Loading';
|
||||
import ChartClient from '../clients/ChartClient';
|
||||
import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySingleton';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import SuperChart from './SuperChart';
|
||||
|
||||
// Using more specific states that align with chart loading process
|
||||
type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
|
||||
|
||||
/**
|
||||
* Helper function to determine if data should be refetched based on formData changes
|
||||
* @param prevFormData Previous formData
|
||||
* @param nextFormData New formData
|
||||
* @param vizType Chart visualization type
|
||||
* @returns true if data should be refetched, false if only re-render is needed
|
||||
*/
|
||||
function shouldRefetchData(
|
||||
prevFormData: QueryFormData | undefined,
|
||||
nextFormData: QueryFormData | undefined,
|
||||
vizType: string | undefined,
|
||||
): boolean {
|
||||
// If no previous formData or viz types don't match, always refetch
|
||||
if (!prevFormData || !nextFormData || !vizType) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If viz_type changed, always refetch
|
||||
if (prevFormData.viz_type !== nextFormData.viz_type) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
// Try to get control panel configuration
|
||||
const controlPanel = getChartControlPanelRegistry().get(vizType);
|
||||
if (!controlPanel || !controlPanel.controlPanelSections) {
|
||||
// If no control panel config available, be conservative and refetch
|
||||
return true;
|
||||
}
|
||||
|
||||
// Build a map of control names to their renderTrigger status
|
||||
const renderTriggerControls = new Set<string>();
|
||||
controlPanel.controlPanelSections.forEach((section: any) => {
|
||||
if (section.controlSetRows) {
|
||||
section.controlSetRows.forEach((row: any) => {
|
||||
row.forEach((control: any) => {
|
||||
if (control && typeof control === 'object') {
|
||||
const controlName = control.name || control.config?.name;
|
||||
if (controlName && control.config?.renderTrigger === true) {
|
||||
renderTriggerControls.add(controlName);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Check which fields changed
|
||||
const changedFields = Object.keys(nextFormData).filter(
|
||||
key =>
|
||||
JSON.stringify(prevFormData[key]) !== JSON.stringify(nextFormData[key]),
|
||||
);
|
||||
|
||||
// If no fields changed, no need to refetch
|
||||
if (changedFields.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if all changed fields are renderTrigger controls
|
||||
const allChangesAreRenderTrigger = changedFields.every(field =>
|
||||
renderTriggerControls.has(field),
|
||||
);
|
||||
|
||||
// Only skip refetch if ALL changes are renderTrigger-only
|
||||
return !allChangesAreRenderTrigger;
|
||||
} catch (error) {
|
||||
// If there's any error accessing the registry, be conservative and refetch
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export interface StatefulChartProps {
|
||||
// Option 1: Provide chartId to load saved chart
|
||||
chartId?: number;
|
||||
|
||||
// Option 2: Provide formData directly
|
||||
formData?: QueryFormData;
|
||||
|
||||
// Option 3: Override specific formData fields
|
||||
formDataOverrides?: Partial<QueryFormData>;
|
||||
|
||||
// Chart type (required if using formData without viz_type)
|
||||
chartType?: string;
|
||||
|
||||
// Chart dimensions
|
||||
width?: number | string;
|
||||
height?: number | string;
|
||||
|
||||
// Event handlers
|
||||
onLoad?: (data: QueryData[]) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onRenderSuccess?: () => void;
|
||||
onRenderFailure?: (error: Error) => void;
|
||||
|
||||
// Data fetching options
|
||||
force?: boolean;
|
||||
timeout?: number;
|
||||
|
||||
// UI options
|
||||
showLoading?: boolean;
|
||||
loadingComponent?: React.ComponentType;
|
||||
errorComponent?: React.ComponentType<{ error: Error }>;
|
||||
noDataComponent?: React.ComponentType;
|
||||
|
||||
// Advanced options
|
||||
client?: SupersetClientInterface;
|
||||
disableErrorBoundary?: boolean;
|
||||
enableNoResults?: boolean;
|
||||
|
||||
// Additional SuperChart props
|
||||
id?: string;
|
||||
className?: string;
|
||||
|
||||
// Hooks for chart interactions (drill, cross-filter, etc.)
|
||||
hooks?: any;
|
||||
}
|
||||
|
||||
export default function StatefulChart(props: StatefulChartProps) {
|
||||
const [status, setStatus] = useState<LoadingState>('uninitialized');
|
||||
const [data, setData] = useState<QueryData[]>();
|
||||
const [error, setError] = useState<Error>();
|
||||
const [formData, setFormData] = useState<QueryFormData>();
|
||||
|
||||
const chartClientRef = useRef<ChartClient>();
|
||||
const abortControllerRef = useRef<AbortController>();
|
||||
|
||||
// Initialize chart client
|
||||
if (!chartClientRef.current) {
|
||||
chartClientRef.current = new ChartClient({ client: props.client });
|
||||
}
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
const {
|
||||
chartId,
|
||||
formData: propsFormData,
|
||||
formDataOverrides,
|
||||
onError,
|
||||
onLoad,
|
||||
chartType,
|
||||
force,
|
||||
timeout,
|
||||
} = props;
|
||||
|
||||
// Cancel any in-flight requests
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
// Create new abort controller
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
setStatus('loading');
|
||||
setError(undefined);
|
||||
|
||||
try {
|
||||
let finalFormData: QueryFormData;
|
||||
|
||||
if (chartId && !propsFormData) {
|
||||
// Load formData from chartId
|
||||
finalFormData = await chartClientRef.current!.loadFormData(
|
||||
{ sliceId: chartId },
|
||||
{ signal: abortControllerRef.current.signal } as RequestConfig,
|
||||
);
|
||||
} else if (propsFormData) {
|
||||
// Use provided formData
|
||||
finalFormData = propsFormData;
|
||||
} else {
|
||||
throw new Error('Either chartId or formData must be provided');
|
||||
}
|
||||
|
||||
// Apply overrides if provided
|
||||
if (formDataOverrides) {
|
||||
finalFormData = { ...finalFormData, ...formDataOverrides };
|
||||
}
|
||||
|
||||
// Ensure viz_type is set
|
||||
const vizType = finalFormData.viz_type || chartType;
|
||||
if (!vizType) {
|
||||
throw new Error('Chart type (viz_type) must be specified');
|
||||
}
|
||||
finalFormData.viz_type = vizType;
|
||||
|
||||
// Get chart metadata
|
||||
const { useLegacyApi } = getChartMetadataRegistry().get(vizType) || {};
|
||||
|
||||
// Build query using the chart's buildQuery function
|
||||
const buildQuery = await getChartBuildQueryRegistry().get(vizType);
|
||||
let queryContext;
|
||||
|
||||
if (buildQuery) {
|
||||
queryContext = buildQuery(finalFormData);
|
||||
} else {
|
||||
// Fallback to default query context builder
|
||||
queryContext = buildQueryContext(finalFormData);
|
||||
}
|
||||
|
||||
// Ensure query_context is properly formatted for new API
|
||||
if (!useLegacyApi && !queryContext.queries) {
|
||||
queryContext = { queries: [queryContext] };
|
||||
}
|
||||
const endpoint = useLegacyApi
|
||||
? '/superset/explore_json/'
|
||||
: '/api/v1/chart/data';
|
||||
|
||||
const requestConfig: RequestConfig = {
|
||||
endpoint,
|
||||
signal: abortControllerRef.current.signal,
|
||||
...(timeout && { timeout: timeout * 1000 }),
|
||||
};
|
||||
|
||||
if (useLegacyApi) {
|
||||
requestConfig.postPayload = {
|
||||
form_data: {
|
||||
...finalFormData,
|
||||
...(force && { force: true }),
|
||||
},
|
||||
};
|
||||
} else {
|
||||
requestConfig.jsonPayload = {
|
||||
...queryContext,
|
||||
...(force && { force: true }),
|
||||
};
|
||||
}
|
||||
|
||||
const response = await chartClientRef.current!.client.post(requestConfig);
|
||||
let responseData = Array.isArray(response.json)
|
||||
? response.json
|
||||
: [response.json];
|
||||
|
||||
// Handle the nested result structure from the new API
|
||||
if (!useLegacyApi && responseData[0]?.result) {
|
||||
responseData = responseData[0].result;
|
||||
}
|
||||
|
||||
setStatus('loaded');
|
||||
setData(responseData);
|
||||
setFormData(finalFormData);
|
||||
|
||||
if (onLoad) {
|
||||
onLoad(responseData);
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore abort errors
|
||||
if (err.name === 'AbortError') {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorObj = err as Error;
|
||||
setStatus('error');
|
||||
setError(errorObj);
|
||||
|
||||
if (onError) {
|
||||
onError(errorObj);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Combined effect for all prop changes and lifecycle
|
||||
const prevPropsRef = useRef<StatefulChartProps>();
|
||||
useEffect(() => {
|
||||
const currentProps = props;
|
||||
const prevProps = prevPropsRef.current;
|
||||
|
||||
// Update ref for next render
|
||||
prevPropsRef.current = currentProps;
|
||||
|
||||
// Initial mount or fundamental props changed - always refetch
|
||||
if (
|
||||
!prevProps ||
|
||||
currentProps.chartId !== prevProps.chartId ||
|
||||
currentProps.formDataOverrides !== prevProps.formDataOverrides ||
|
||||
currentProps.force !== prevProps.force
|
||||
) {
|
||||
fetchData();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if formData changed
|
||||
if (currentProps.formData !== prevProps.formData) {
|
||||
// Determine the viz type
|
||||
const vizType = currentProps.formData?.viz_type || currentProps.chartType;
|
||||
|
||||
// Check if we need to refetch data or just re-render
|
||||
if (
|
||||
shouldRefetchData(prevProps.formData, currentProps.formData, vizType)
|
||||
) {
|
||||
fetchData();
|
||||
} else {
|
||||
// Just update the state to trigger re-render without fetching
|
||||
setFormData(currentProps.formData);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
props.chartId,
|
||||
props.formData,
|
||||
props.formDataOverrides,
|
||||
props.force,
|
||||
props.chartType,
|
||||
]);
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Render logic
|
||||
const {
|
||||
width = '100%',
|
||||
height = 400,
|
||||
showLoading = true,
|
||||
loadingComponent: LoadingComponent,
|
||||
errorComponent: ErrorComponent,
|
||||
noDataComponent: NoDataComponent,
|
||||
disableErrorBoundary,
|
||||
enableNoResults = true,
|
||||
id,
|
||||
className,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
hooks,
|
||||
} = props;
|
||||
|
||||
if (status === 'loading' && showLoading) {
|
||||
if (LoadingComponent) {
|
||||
return <LoadingComponent />;
|
||||
}
|
||||
|
||||
// If using percentage sizing, wrap Loading in a container
|
||||
if (width === '100%' || height === '100%') {
|
||||
return (
|
||||
<div style={{ width, height, position: 'relative' }}>
|
||||
<Loading position="floating" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Loading position="floating" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'error' && error) {
|
||||
if (ErrorComponent) {
|
||||
return <ErrorComponent error={error} />;
|
||||
}
|
||||
|
||||
const errorDiv = (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
color: 'var(--danger-color)',
|
||||
fontSize: '14px',
|
||||
padding: '16px',
|
||||
textAlign: 'center',
|
||||
}}
|
||||
>
|
||||
Error: {error.message}
|
||||
</div>
|
||||
);
|
||||
|
||||
// If using percentage sizing, wrap in a container
|
||||
if (width === '100%' || height === '100%') {
|
||||
return <div style={{ width, height }}>{errorDiv}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
width: typeof width === 'number' ? `${width}px` : width,
|
||||
height: typeof height === 'number' ? `${height}px` : height,
|
||||
}}
|
||||
>
|
||||
{errorDiv}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'loaded' && formData && data) {
|
||||
// Check if we need dynamic sizing
|
||||
const needsDynamicSizing = width === '100%' || height === '100%';
|
||||
|
||||
const renderChart = (
|
||||
chartWidth: number | string,
|
||||
chartHeight: number | string,
|
||||
) => (
|
||||
<SuperChart
|
||||
id={id}
|
||||
className={className}
|
||||
chartType={formData.viz_type}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
formData={formData}
|
||||
queriesData={data}
|
||||
disableErrorBoundary={disableErrorBoundary}
|
||||
enableNoResults={enableNoResults}
|
||||
noResults={NoDataComponent && <NoDataComponent />}
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
hooks={hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
if (needsDynamicSizing) {
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%' }}>
|
||||
<ParentSize>
|
||||
{({ width: parentWidth, height: parentHeight }) => {
|
||||
const finalWidth = width === '100%' ? parentWidth : width;
|
||||
const finalHeight = height === '100%' ? parentHeight : height;
|
||||
return renderChart(finalWidth, finalHeight);
|
||||
}}
|
||||
</ParentSize>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return renderChart(width, height);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -39,6 +39,8 @@ import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
|
||||
import DefaultFallbackComponent from './FallbackComponent';
|
||||
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
|
||||
import NoResultsComponent from './NoResultsComponent';
|
||||
import { isMatrixifyEnabled } from '../types/matrixify';
|
||||
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
|
||||
|
||||
const defaultProps = {
|
||||
FallbackComponent: DefaultFallbackComponent,
|
||||
@@ -186,8 +188,47 @@ class SuperChart extends PureComponent<Props, {}> {
|
||||
theme,
|
||||
});
|
||||
|
||||
let chart;
|
||||
// Render the no results component if the query data is null or empty
|
||||
// Check if Matrixify is enabled - use rawFormData (snake_case)
|
||||
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
|
||||
|
||||
if (matrixifyEnabled) {
|
||||
// When matrixify is enabled, queriesData is expected to be empty
|
||||
// since each cell fetches its own data via StatefulChart
|
||||
const matrixifyChart = (
|
||||
<MatrixifyGridRenderer
|
||||
formData={chartProps.rawFormData}
|
||||
datasource={chartProps.datasource}
|
||||
width={width}
|
||||
height={height}
|
||||
hooks={chartProps.hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply wrapper if provided
|
||||
const wrappedChart = Wrapper ? (
|
||||
<Wrapper width={width} height={height}>
|
||||
{matrixifyChart}
|
||||
</Wrapper>
|
||||
) : (
|
||||
matrixifyChart
|
||||
);
|
||||
|
||||
// Include error boundary unless disabled
|
||||
return disableErrorBoundary === true ? (
|
||||
wrappedChart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent width={width} height={height} {...props} />
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{wrappedChart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for no results only for non-matrixified charts
|
||||
const noResultQueries =
|
||||
enableNoResults &&
|
||||
(!queriesData ||
|
||||
@@ -196,6 +237,8 @@ class SuperChart extends PureComponent<Props, {}> {
|
||||
.every(
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart;
|
||||
if (noResultQueries) {
|
||||
chart = noResults || (
|
||||
<NoResultsComponent
|
||||
|
||||
@@ -37,11 +37,13 @@ export { default as getChartTransformPropsRegistry } from './registries/ChartTra
|
||||
export type { BuildQuery } from './registries/ChartBuildQueryRegistrySingleton';
|
||||
|
||||
export { default as ChartDataProvider } from './components/ChartDataProvider';
|
||||
export { default as StatefulChart } from './components/StatefulChart';
|
||||
|
||||
export * from './types/Base';
|
||||
export * from './types/TransformFunction';
|
||||
export * from './types/QueryResponse';
|
||||
export * from './types/VizType';
|
||||
export * from './types/matrixify';
|
||||
|
||||
export { default as __hack_reexport_chart_Base } from './types/Base';
|
||||
export { default as __hack_reexport_chart_TransformFunction } from './types/TransformFunction';
|
||||
|
||||
@@ -17,11 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/** Type checking is disabled for this file due to reselect only supporting
|
||||
* TS declarations for selectors with up to 12 arguments. */
|
||||
// @ts-nocheck
|
||||
import { RefObject } from 'react';
|
||||
import { createSelector } from 'reselect';
|
||||
import { createSelector, lruMemoize } from 'reselect';
|
||||
import {
|
||||
AppSection,
|
||||
Behavior,
|
||||
@@ -37,7 +34,7 @@ import {
|
||||
SetDataMaskHook,
|
||||
} from '../types/Base';
|
||||
import { QueryData, DataRecordFilters } from '..';
|
||||
import { SupersetTheme } from '../../theme';
|
||||
import { supersetTheme, SupersetTheme } from '../../theme';
|
||||
|
||||
// TODO: more specific typing for these fields of ChartProps
|
||||
type AnnotationData = PlainObject;
|
||||
@@ -109,6 +106,8 @@ export interface ChartPropsConfig {
|
||||
theme: SupersetTheme;
|
||||
/* legend index */
|
||||
legendIndex?: number;
|
||||
inContextMenu?: boolean;
|
||||
emitCrossFilters?: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_WIDTH = 800;
|
||||
@@ -161,7 +160,11 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
|
||||
|
||||
theme: SupersetTheme;
|
||||
|
||||
constructor(config: ChartPropsConfig & { formData?: FormData } = {}) {
|
||||
constructor(
|
||||
config: ChartPropsConfig & { formData?: FormData } = {
|
||||
theme: supersetTheme,
|
||||
},
|
||||
) {
|
||||
const {
|
||||
annotationData = {},
|
||||
datasource = {},
|
||||
@@ -276,5 +279,16 @@ ChartProps.createSelector = function create(): ChartPropsSelector {
|
||||
emitCrossFilters,
|
||||
theme,
|
||||
}),
|
||||
// Below config is to retain usage of 1-sized `lruMemoize` object in Reselect v4
|
||||
// Reselect v5 introduces `weakMapMemoize` which is more performant but potentially memory-leaky
|
||||
// due to infinite cache size.
|
||||
// Source: https://github.com/reduxjs/reselect/releases/tag/v5.0.1
|
||||
{
|
||||
memoize: lruMemoize,
|
||||
argsMemoize: lruMemoize,
|
||||
memoizeOptions: {
|
||||
maxSize: 10,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export const isMatrixifyEnabled = jest.fn(() => false);
|
||||
|
||||
export const MatrixifyGridRenderer = jest.fn(() => null);
|
||||
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 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 {
|
||||
isMatrixifyEnabled,
|
||||
getMatrixifyConfig,
|
||||
getMatrixifyValidationErrors,
|
||||
MatrixifyFormData,
|
||||
} from './matrixify';
|
||||
import { AdhocMetric } from '../../query/types/Metric';
|
||||
|
||||
const createMetric = (label: string): AdhocMetric => ({
|
||||
expressionType: 'SIMPLE',
|
||||
column: { column_name: 'value' },
|
||||
aggregate: 'SUM',
|
||||
label,
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return false when no matrixify configuration exists', () => {
|
||||
const formData = { viz_type: 'table' } as MatrixifyFormData;
|
||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return false when matrixify_enabled is false', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: false,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
matrixify_columns: [createMetric('Q1')],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
|
||||
matrixify_dimension_columns: { dimension: 'product', values: ['Widget'] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return true for mixed mode configuration', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
matrixify_dimension_columns: { dimension: 'country', values: ['USA'] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return true for topn dimension selection mode', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: {
|
||||
dimension: 'country',
|
||||
values: [],
|
||||
selectionMode: 'topn',
|
||||
topNMetric: 'revenue',
|
||||
topNValue: 5,
|
||||
},
|
||||
matrixify_dimension_columns: { dimension: 'product', values: ['Widget'] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(true);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [],
|
||||
matrixify_columns: [],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
||||
});
|
||||
|
||||
test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: { dimension: 'country', values: [] },
|
||||
matrixify_dimension_columns: { dimension: 'product', values: [] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
||||
});
|
||||
|
||||
test('getMatrixifyConfig should return null when no matrixify configuration exists', () => {
|
||||
const formData = { viz_type: 'table' } as MatrixifyFormData;
|
||||
expect(getMatrixifyConfig(formData)).toBeNull();
|
||||
});
|
||||
|
||||
test('getMatrixifyConfig should return valid config for metrics mode', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
matrixify_columns: [createMetric('Q1')],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
const config = getMatrixifyConfig(formData);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.rows.mode).toBe('metrics');
|
||||
expect(config!.columns.mode).toBe('metrics');
|
||||
expect(config!.rows.metrics).toEqual([createMetric('Revenue')]);
|
||||
expect(config!.columns.metrics).toEqual([createMetric('Q1')]);
|
||||
});
|
||||
|
||||
test('getMatrixifyConfig should return valid config for dimensions mode', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
|
||||
matrixify_dimension_columns: { dimension: 'product', values: ['Widget'] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
const config = getMatrixifyConfig(formData);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.rows.mode).toBe('dimensions');
|
||||
expect(config!.columns.mode).toBe('dimensions');
|
||||
expect(config!.rows.dimension).toEqual({
|
||||
dimension: 'country',
|
||||
values: ['USA'],
|
||||
});
|
||||
expect(config!.columns.dimension).toEqual({
|
||||
dimension: 'product',
|
||||
values: ['Widget'],
|
||||
});
|
||||
});
|
||||
|
||||
test('getMatrixifyConfig should handle topn selection mode', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'dimensions',
|
||||
matrixify_mode_columns: 'dimensions',
|
||||
matrixify_dimension_rows: {
|
||||
dimension: 'country',
|
||||
values: [],
|
||||
selectionMode: 'topn',
|
||||
topNMetric: 'revenue',
|
||||
topNValue: 10,
|
||||
},
|
||||
matrixify_dimension_columns: { dimension: 'product', values: ['Widget'] },
|
||||
} as MatrixifyFormData;
|
||||
|
||||
const config = getMatrixifyConfig(formData);
|
||||
expect(config).not.toBeNull();
|
||||
expect(config!.rows.dimension).toEqual(formData.matrixify_dimension_rows);
|
||||
});
|
||||
|
||||
test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: false,
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
|
||||
});
|
||||
|
||||
test('getMatrixifyValidationErrors should return empty array when properly configured', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_mode_columns: 'metrics',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
matrixify_columns: [createMetric('Q1')],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
|
||||
});
|
||||
|
||||
test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
} as MatrixifyFormData;
|
||||
|
||||
const errors = getMatrixifyValidationErrors(formData);
|
||||
expect(errors).toContain('Please configure at least one row or column axis');
|
||||
});
|
||||
|
||||
test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_rows: [],
|
||||
matrixify_columns: [],
|
||||
} as MatrixifyFormData;
|
||||
|
||||
const errors = getMatrixifyValidationErrors(formData);
|
||||
expect(errors.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should handle undefined form data', () => {
|
||||
expect(() => isMatrixifyEnabled(undefined as any)).toThrow();
|
||||
});
|
||||
|
||||
test('should handle null form data', () => {
|
||||
expect(() => isMatrixifyEnabled(null as any)).toThrow();
|
||||
});
|
||||
|
||||
test('should handle empty form data object', () => {
|
||||
const formData = {} as MatrixifyFormData;
|
||||
expect(isMatrixifyEnabled(formData)).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle partial configuration with one axis only', () => {
|
||||
const formData = {
|
||||
viz_type: 'table',
|
||||
matrixify_enabled: true,
|
||||
matrixify_mode_rows: 'metrics',
|
||||
matrixify_rows: [createMetric('Revenue')],
|
||||
// No columns configuration
|
||||
} as MatrixifyFormData;
|
||||
|
||||
expect(isMatrixifyEnabled(formData)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,338 @@
|
||||
/**
|
||||
* 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 { AdhocMetric } from '../../query';
|
||||
|
||||
/**
|
||||
* Constants for Matrixify filter generation
|
||||
* These match the literal types used in Filter.ts
|
||||
*/
|
||||
export const MatrixifyFilterConstants = {
|
||||
// Filter expression types
|
||||
ExpressionType: {
|
||||
SIMPLE: 'SIMPLE' as const,
|
||||
SQL: 'SQL' as const,
|
||||
},
|
||||
// Filter clauses
|
||||
Clause: {
|
||||
WHERE: 'WHERE' as const,
|
||||
HAVING: 'HAVING' as const,
|
||||
},
|
||||
// Filter operators
|
||||
Operator: {
|
||||
EQUALS: '==' as const,
|
||||
NOT_EQUALS: '!=' as const,
|
||||
IN: 'IN' as const,
|
||||
NOT_IN: 'NOT IN' as const,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Mode for selecting matrix axis values
|
||||
*/
|
||||
export type MatrixifyMode = 'metrics' | 'dimensions';
|
||||
|
||||
/**
|
||||
* Selection method for dimension values
|
||||
*/
|
||||
export type MatrixifySelectionMode = 'members' | 'topn';
|
||||
|
||||
/**
|
||||
* Sort order for top N selection
|
||||
*/
|
||||
export type MatrixifySortOrder = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* Dimension value selection containing both the dimension column and selected values
|
||||
*/
|
||||
export interface MatrixifyDimensionValue {
|
||||
dimension: string;
|
||||
values: any[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for a single axis (rows or columns) in the matrix
|
||||
*/
|
||||
export interface MatrixifyAxisConfig {
|
||||
/** Whether to use metrics or dimensions for this axis */
|
||||
mode: MatrixifyMode;
|
||||
|
||||
/** Selected metrics when mode is 'metrics' */
|
||||
metrics?: AdhocMetric[];
|
||||
|
||||
/** Dimension selection mode when mode is 'dimensions' */
|
||||
selectionMode?: MatrixifySelectionMode;
|
||||
|
||||
/** Selected dimension and values when mode is 'dimensions' */
|
||||
dimension?: MatrixifyDimensionValue;
|
||||
|
||||
/** Top N value when selectionMode is 'topn' */
|
||||
topnValue?: number;
|
||||
|
||||
/** Metric for ordering top N values */
|
||||
topnMetric?: AdhocMetric;
|
||||
|
||||
/** Sort order for top N values */
|
||||
topnOrder?: MatrixifySortOrder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete Matrixify configuration in form data
|
||||
*/
|
||||
export interface MatrixifyFormData {
|
||||
// Enable/disable matrixify functionality
|
||||
matrixify_enabled?: boolean;
|
||||
|
||||
// Row axis configuration
|
||||
matrixify_mode_rows?: MatrixifyMode;
|
||||
matrixify_rows?: AdhocMetric[];
|
||||
matrixify_dimension_selection_mode_rows?: MatrixifySelectionMode;
|
||||
matrixify_dimension_rows?: MatrixifyDimensionValue;
|
||||
matrixify_topn_value_rows?: number;
|
||||
matrixify_topn_metric_rows?: AdhocMetric;
|
||||
matrixify_topn_order_rows?: MatrixifySortOrder;
|
||||
|
||||
// Column axis configuration
|
||||
matrixify_mode_columns?: MatrixifyMode;
|
||||
matrixify_columns?: AdhocMetric[];
|
||||
matrixify_dimension_selection_mode_columns?: MatrixifySelectionMode;
|
||||
matrixify_dimension_columns?: MatrixifyDimensionValue;
|
||||
matrixify_topn_value_columns?: number;
|
||||
matrixify_topn_metric_columns?: AdhocMetric;
|
||||
matrixify_topn_order_columns?: MatrixifySortOrder;
|
||||
|
||||
// Grid layout configuration
|
||||
matrixify_row_height?: number;
|
||||
matrixify_fit_columns_dynamically?: boolean;
|
||||
matrixify_charts_per_row?: number;
|
||||
|
||||
// Cell configuration
|
||||
matrixify_cell_title_template?: string;
|
||||
|
||||
// Matrix display configuration
|
||||
matrixify_show_row_labels?: boolean;
|
||||
matrixify_show_column_headers?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Processed matrix configuration after form data is transformed
|
||||
*/
|
||||
export interface MatrixifyConfig {
|
||||
rows: MatrixifyAxisConfig;
|
||||
columns: MatrixifyAxisConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to extract Matrixify configuration from form data
|
||||
*/
|
||||
export function getMatrixifyConfig(
|
||||
formData: MatrixifyFormData & any,
|
||||
): MatrixifyConfig | null {
|
||||
const hasRowConfig = formData.matrixify_mode_rows;
|
||||
const hasColumnConfig = formData.matrixify_mode_columns;
|
||||
|
||||
if (!hasRowConfig && !hasColumnConfig) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
rows: {
|
||||
mode: formData.matrixify_mode_rows || 'metrics',
|
||||
metrics: formData.matrixify_rows,
|
||||
selectionMode: formData.matrixify_dimension_selection_mode_rows,
|
||||
dimension: formData.matrixify_dimension_rows,
|
||||
topnValue: formData.matrixify_topn_value_rows,
|
||||
topnMetric: formData.matrixify_topn_metric_rows,
|
||||
topnOrder: formData.matrixify_topn_order_rows,
|
||||
},
|
||||
columns: {
|
||||
mode: formData.matrixify_mode_columns || 'metrics',
|
||||
metrics: formData.matrixify_columns,
|
||||
selectionMode: formData.matrixify_dimension_selection_mode_columns,
|
||||
dimension: formData.matrixify_dimension_columns,
|
||||
topnValue: formData.matrixify_topn_value_columns,
|
||||
topnMetric: formData.matrixify_topn_metric_columns,
|
||||
topnOrder: formData.matrixify_topn_order_columns,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Matrixify is enabled and properly configured
|
||||
*/
|
||||
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
|
||||
// First check if matrixify is explicitly enabled via checkbox
|
||||
if (!formData.matrixify_enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Then validate that we have proper configuration
|
||||
const config = getMatrixifyConfig(formData);
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasRowData =
|
||||
config.rows.mode === 'metrics'
|
||||
? config.rows.metrics && config.rows.metrics.length > 0
|
||||
: config.rows.dimension?.dimension &&
|
||||
(config.rows.selectionMode === 'topn' ||
|
||||
(config.rows.dimension.values &&
|
||||
config.rows.dimension.values.length > 0));
|
||||
|
||||
const hasColumnData =
|
||||
config.columns.mode === 'metrics'
|
||||
? config.columns.metrics && config.columns.metrics.length > 0
|
||||
: config.columns.dimension?.dimension &&
|
||||
(config.columns.selectionMode === 'topn' ||
|
||||
(config.columns.dimension.values &&
|
||||
config.columns.dimension.values.length > 0));
|
||||
|
||||
return Boolean(hasRowData || hasColumnData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get validation errors for matrixify configuration
|
||||
*/
|
||||
export function getMatrixifyValidationErrors(
|
||||
formData: MatrixifyFormData,
|
||||
): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Only validate if matrixify is enabled
|
||||
if (!formData.matrixify_enabled) {
|
||||
return errors;
|
||||
}
|
||||
|
||||
const config = getMatrixifyConfig(formData);
|
||||
if (!config) {
|
||||
errors.push('Please configure at least one row or column axis');
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Check row configuration (only if explicitly set in form data)
|
||||
const hasRowMode = Boolean(formData.matrixify_mode_rows);
|
||||
if (hasRowMode) {
|
||||
const hasRowData =
|
||||
config.rows.mode === 'metrics'
|
||||
? config.rows.metrics && config.rows.metrics.length > 0
|
||||
: config.rows.dimension?.dimension &&
|
||||
(config.rows.selectionMode === 'topn' ||
|
||||
(config.rows.dimension.values &&
|
||||
config.rows.dimension.values.length > 0));
|
||||
|
||||
if (!hasRowData) {
|
||||
if (config.rows.mode === 'metrics') {
|
||||
errors.push('Please select at least one metric for rows');
|
||||
} else {
|
||||
errors.push('Please select a dimension and values for rows');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check column configuration (only if explicitly set in form data)
|
||||
const hasColumnMode = Boolean(formData.matrixify_mode_columns);
|
||||
if (hasColumnMode) {
|
||||
const hasColumnData =
|
||||
config.columns.mode === 'metrics'
|
||||
? config.columns.metrics && config.columns.metrics.length > 0
|
||||
: config.columns.dimension?.dimension &&
|
||||
(config.columns.selectionMode === 'topn' ||
|
||||
(config.columns.dimension.values &&
|
||||
config.columns.dimension.values.length > 0));
|
||||
|
||||
if (!hasColumnData) {
|
||||
if (config.columns.mode === 'metrics') {
|
||||
errors.push('Please select at least one metric for columns');
|
||||
} else {
|
||||
errors.push('Please select a dimension and values for columns');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Must have at least one valid axis
|
||||
if (hasRowMode || hasColumnMode) {
|
||||
const hasRowData =
|
||||
config.rows.mode === 'metrics'
|
||||
? config.rows.metrics && config.rows.metrics.length > 0
|
||||
: config.rows.dimension?.dimension &&
|
||||
(config.rows.selectionMode === 'topn' ||
|
||||
(config.rows.dimension.values &&
|
||||
config.rows.dimension.values.length > 0));
|
||||
|
||||
const hasColumnData =
|
||||
config.columns.mode === 'metrics'
|
||||
? config.columns.metrics && config.columns.metrics.length > 0
|
||||
: config.columns.dimension?.dimension &&
|
||||
(config.columns.selectionMode === 'topn' ||
|
||||
(config.columns.dimension.values &&
|
||||
config.columns.dimension.values.length > 0));
|
||||
|
||||
if (!hasRowData && !hasColumnData) {
|
||||
errors.push('Configure at least one complete row or column axis');
|
||||
}
|
||||
} else {
|
||||
errors.push('Please configure at least one row or column axis');
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
/**
|
||||
* Grid cell representing a single chart in the matrix
|
||||
*/
|
||||
export interface MatrixifyGridCell {
|
||||
/** Unique identifier for this cell */
|
||||
id: string;
|
||||
|
||||
/** Row index in the grid */
|
||||
row: number;
|
||||
|
||||
/** Column index in the grid */
|
||||
col: number;
|
||||
|
||||
/** Row label (metric name or dimension value) */
|
||||
rowLabel: string;
|
||||
|
||||
/** Column label (metric name or dimension value) */
|
||||
colLabel: string;
|
||||
|
||||
/** Computed title for the cell (from template or default) */
|
||||
title?: string;
|
||||
|
||||
/** Form data for this specific cell's chart */
|
||||
formData: any; // This will be QueryFormData with appropriate filters/metrics
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete grid structure for rendering
|
||||
*/
|
||||
export interface MatrixifyGrid {
|
||||
/** Row headers */
|
||||
rowHeaders: string[];
|
||||
|
||||
/** Column headers */
|
||||
colHeaders: string[];
|
||||
|
||||
/** 2D array of cells [row][col] */
|
||||
cells: (MatrixifyGridCell | null)[][];
|
||||
|
||||
/** Original form data used to generate the grid */
|
||||
baseFormData: MatrixifyFormData;
|
||||
}
|
||||
@@ -24,6 +24,7 @@ export const Badge = styled((props: BadgeProps) => <AntdBadge {...props} />)`
|
||||
${({ theme, color, count }) => `
|
||||
& > sup,
|
||||
& > sup.ant-badge-count {
|
||||
box-shadow: none;
|
||||
${
|
||||
count !== undefined ? `background: ${color || theme.colorPrimary};` : ''
|
||||
}
|
||||
|
||||
@@ -132,11 +132,12 @@ export function Button(props: ButtonProps) {
|
||||
'& > span > :first-of-type': {
|
||||
marginRight: firstChildMargin,
|
||||
},
|
||||
':not(:hover)': effectiveButtonStyle === 'secondary' && {
|
||||
// NOTE: This is the best we can do contrast wise for the secondary button using antd tokens
|
||||
// and abusing the semantics. Should be revisited when possible. https://github.com/apache/superset/pull/34253#issuecomment-3104834692
|
||||
color: `${theme.colorPrimaryTextHover} !important`,
|
||||
},
|
||||
':not(:hover)': effectiveButtonStyle === 'secondary' &&
|
||||
!disabled && {
|
||||
// NOTE: This is the best we can do contrast wise for the secondary button using antd tokens
|
||||
// and abusing the semantics. Should be revisited when possible. https://github.com/apache/superset/pull/34253#issuecomment-3104834692
|
||||
color: `${theme.colorPrimaryTextHover} !important`,
|
||||
},
|
||||
}}
|
||||
icon={icon}
|
||||
{...restProps}
|
||||
|
||||
@@ -52,7 +52,7 @@ export const CheckboxHalfChecked = () => {
|
||||
>
|
||||
<path
|
||||
d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z"
|
||||
fill={theme.colors.grayscale.light1}
|
||||
fill={theme.colorFill}
|
||||
/>
|
||||
<path d="M14 10H4V8H14V10Z" fill="white" />
|
||||
</svg>
|
||||
@@ -71,7 +71,7 @@ export const CheckboxUnchecked = () => {
|
||||
>
|
||||
<path
|
||||
d="M16 0H2C0.9 0 0 0.9 0 2V16C0 17.1 0.9 18 2 18H16C17.1 18 18 17.1 18 16V2C18 0.9 17.1 0 16 0Z"
|
||||
fill={theme.colors.grayscale.light2}
|
||||
fill={theme.colorFillSecondary}
|
||||
/>
|
||||
<path d="M16 2V16H2V2H16V2Z" fill="white" />
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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 {
|
||||
ColorPicker as AntdColorPicker,
|
||||
type ColorPickerProps as AntdColorPickerProps,
|
||||
} from 'antd';
|
||||
|
||||
// Re-export the AntD ColorPicker as-is for themeable usage
|
||||
export type ColorPickerProps = AntdColorPickerProps;
|
||||
export const ColorPicker = AntdColorPicker;
|
||||
|
||||
// Export RGB color type for backward compatibility
|
||||
export type RGBColor = {
|
||||
r: number;
|
||||
g: number;
|
||||
b: number;
|
||||
a?: number;
|
||||
};
|
||||
|
||||
// Export type for AntD Color object interface
|
||||
export interface ColorValue {
|
||||
toRgb(): RGBColor;
|
||||
toHexString(): string;
|
||||
}
|
||||
|
||||
export default ColorPicker;
|
||||
@@ -27,7 +27,7 @@ const StyledDiv = styled.div`
|
||||
padding-top: 8px;
|
||||
width: 50%;
|
||||
label {
|
||||
color: ${({ theme }) => theme.colors.grayscale.base};
|
||||
color: ${({ theme }) => theme.colorTextLabel};
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 type { DrawerProps } from './types';
|
||||
|
||||
export { Drawer } from 'antd';
|
||||
export type { DrawerProps };
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* 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 type { DrawerProps } from 'antd/es/drawer';
|
||||
|
||||
export { DrawerProps };
|
||||
@@ -31,7 +31,7 @@ const MenuDots = styled.div`
|
||||
width: ${({ theme }) => theme.sizeUnit * 0.75}px;
|
||||
height: ${({ theme }) => theme.sizeUnit * 0.75}px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
background-color: ${({ theme }) => theme.colorFill};
|
||||
|
||||
font-weight: ${({ theme }) => theme.fontWeightNormal};
|
||||
display: inline-flex;
|
||||
@@ -53,7 +53,7 @@ const MenuDots = styled.div`
|
||||
width: ${({ theme }) => theme.sizeUnit * 0.75}px;
|
||||
height: ${({ theme }) => theme.sizeUnit * 0.75}px;
|
||||
border-radius: 50%;
|
||||
background-color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
background-color: ${({ theme }) => theme.colorFill};
|
||||
}
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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 {
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { Global } from '@emotion/react';
|
||||
import { css, t, useTheme, usePrevious } from '@superset-ui/core';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { Badge, Icons, Button, Tooltip, Popover } from '..';
|
||||
import { DropdownContainerProps, DropdownItem, DropdownRef } from './types';
|
||||
|
||||
const MAX_HEIGHT = 500;
|
||||
|
||||
export const DropdownContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
items,
|
||||
onOverflowingStateChange,
|
||||
dropdownContent,
|
||||
dropdownRef,
|
||||
dropdownStyle = {},
|
||||
dropdownTriggerCount,
|
||||
dropdownTriggerIcon,
|
||||
dropdownTriggerText = t('More'),
|
||||
dropdownTriggerTooltip = null,
|
||||
forceRender,
|
||||
style,
|
||||
}: DropdownContainerProps,
|
||||
outerRef: RefObject<DropdownRef>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const { ref, width = 0 } = useResizeDetector<HTMLDivElement>();
|
||||
const previousWidth = usePrevious(width) || 0;
|
||||
const { current } = ref;
|
||||
const [itemsWidth, setItemsWidth] = useState<number[]>([]);
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
// We use React.useState to be able to mock the state in Jest
|
||||
const [overflowingIndex, setOverflowingIndex] = useState<number>(-1);
|
||||
|
||||
let targetRef = useRef<HTMLDivElement>(null);
|
||||
if (dropdownRef) {
|
||||
targetRef = dropdownRef;
|
||||
}
|
||||
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = useCallback(() => {
|
||||
const mainItemsContainerNode = current?.children.item(0);
|
||||
if (mainItemsContainerNode) {
|
||||
const visibleChildrenElements = Array.from(
|
||||
mainItemsContainerNode.children,
|
||||
);
|
||||
setItemsWidth(prevGlobalWidths => {
|
||||
if (prevGlobalWidths.length !== items.length) {
|
||||
return prevGlobalWidths;
|
||||
}
|
||||
|
||||
const newGlobalWidths = [...prevGlobalWidths];
|
||||
let changed = false;
|
||||
visibleChildrenElements.forEach((child, indexInVisible) => {
|
||||
const originalItemIndex = indexInVisible;
|
||||
if (originalItemIndex < newGlobalWidths.length) {
|
||||
const newWidth = child.getBoundingClientRect().width;
|
||||
if (newGlobalWidths[originalItemIndex] !== newWidth) {
|
||||
newGlobalWidths[originalItemIndex] = newWidth;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? newGlobalWidths : prevGlobalWidths;
|
||||
});
|
||||
}
|
||||
}, [current?.children, items.length]);
|
||||
|
||||
const reduceItems = (items: DropdownItem[]): [DropdownItem[], string[]] =>
|
||||
items.reduce(
|
||||
([items, ids], item) => {
|
||||
items.push({
|
||||
id: item.id,
|
||||
element: cloneElement(item.element, { key: item.id }),
|
||||
});
|
||||
ids.push(item.id);
|
||||
return [items, ids];
|
||||
},
|
||||
[[], []] as [DropdownItem[], string[]],
|
||||
);
|
||||
|
||||
const [notOverflowedItems, notOverflowedIds] = useMemo(
|
||||
() =>
|
||||
reduceItems(
|
||||
items.slice(
|
||||
0,
|
||||
overflowingIndex !== -1 ? overflowingIndex : items.length,
|
||||
),
|
||||
),
|
||||
[items, overflowingIndex],
|
||||
);
|
||||
|
||||
const [overflowedItems, overflowedIds] = useMemo(
|
||||
() =>
|
||||
overflowingIndex !== -1
|
||||
? reduceItems(items.slice(overflowingIndex))
|
||||
: [[], []],
|
||||
[items, overflowingIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = current?.children.item(0);
|
||||
if (!container) return;
|
||||
|
||||
const childrenArray = Array.from(container.children);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
recalculateItemWidths();
|
||||
});
|
||||
|
||||
childrenArray.map(child => resizeObserver.observe(child));
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
childrenArray.map(child => resizeObserver.unobserve(child));
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [items.length, current, recalculateItemWidths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
return;
|
||||
}
|
||||
const container = current?.children.item(0);
|
||||
if (container) {
|
||||
const { children } = container;
|
||||
const childrenArray = Array.from(children);
|
||||
// If items length change, add all items to the container
|
||||
// and recalculate the widths
|
||||
if (itemsWidth.length !== items.length) {
|
||||
if (childrenArray.length === items.length) {
|
||||
setItemsWidth(
|
||||
childrenArray.map(child => child.getBoundingClientRect().width),
|
||||
);
|
||||
} else {
|
||||
setOverflowingIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the index of the first overflowed element
|
||||
// +1 is to give at least one pixel of difference and avoid flakiness
|
||||
const index = childrenArray.findIndex(
|
||||
child =>
|
||||
child.getBoundingClientRect().right >
|
||||
container.getBoundingClientRect().right + 1,
|
||||
);
|
||||
|
||||
// If elements fit (-1) and there's overflowed items
|
||||
// then preserve the overflow index. We can't use overflowIndex
|
||||
// directly because the items may have been modified
|
||||
let newOverflowingIndex =
|
||||
index === -1 && overflowedItems.length > 0
|
||||
? items.length - overflowedItems.length
|
||||
: index;
|
||||
|
||||
if (width > previousWidth) {
|
||||
// Calculates remaining space in the container
|
||||
const button = current?.children.item(1);
|
||||
const buttonRight = button?.getBoundingClientRect().right || 0;
|
||||
const containerRight = current?.getBoundingClientRect().right || 0;
|
||||
const remainingSpace = containerRight - buttonRight;
|
||||
|
||||
// Checks if some elements in the dropdown fits in the remaining space
|
||||
let sum = 0;
|
||||
for (let i = childrenArray.length; i < items.length; i += 1) {
|
||||
sum += itemsWidth[i];
|
||||
if (sum <= remainingSpace) {
|
||||
newOverflowingIndex = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOverflowingIndex(newOverflowingIndex);
|
||||
}
|
||||
}, [
|
||||
current,
|
||||
items.length,
|
||||
itemsWidth,
|
||||
overflowedItems.length,
|
||||
previousWidth,
|
||||
width,
|
||||
popoverVisible,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onOverflowingStateChange) {
|
||||
onOverflowingStateChange({
|
||||
notOverflowed: notOverflowedIds,
|
||||
overflowed: overflowedIds,
|
||||
});
|
||||
}
|
||||
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);
|
||||
|
||||
const overflowingCount =
|
||||
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() =>
|
||||
dropdownContent || overflowingCount ? (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
data-test="dropdown-content"
|
||||
style={dropdownStyle}
|
||||
ref={targetRef}
|
||||
>
|
||||
{dropdownContent
|
||||
? dropdownContent(overflowedItems)
|
||||
: overflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
dropdownContent,
|
||||
overflowingCount,
|
||||
theme.sizeUnit,
|
||||
dropdownStyle,
|
||||
overflowedItems,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
// Measures scroll height after rendering the elements
|
||||
setTimeout(() => {
|
||||
if (targetRef.current) {
|
||||
// We only set overflow when there's enough space to display
|
||||
// Select's popovers because they are restrained by the overflow property.
|
||||
setShowOverflow(targetRef.current.scrollHeight > MAX_HEIGHT);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [popoverVisible]);
|
||||
|
||||
useImperativeHandle(
|
||||
outerRef,
|
||||
() => ({
|
||||
...(ref.current as HTMLDivElement),
|
||||
open: () => setPopoverVisible(true),
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
// Closes the popover when scrolling on the document
|
||||
useEffect(() => {
|
||||
document.onscroll = popoverVisible
|
||||
? () => setPopoverVisible(false)
|
||||
: null;
|
||||
return () => {
|
||||
document.onscroll = null;
|
||||
};
|
||||
}, [popoverVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
margin-right: ${theme.sizeUnit * 4}px;
|
||||
min-width: 0px;
|
||||
`}
|
||||
data-test="container"
|
||||
style={style}
|
||||
>
|
||||
{notOverflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
{popoverContent && (
|
||||
<>
|
||||
<Global
|
||||
styles={css`
|
||||
.ant-popover-inner {
|
||||
// Some OS versions only show the scroll when hovering.
|
||||
// These settings will make the scroll always visible.
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9px;
|
||||
background-color: ${theme.colorFillSecondary};
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: ${theme.colorFillQuaternary};
|
||||
border-left: 1px solid ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
overflow: showOverflow ? 'auto' : 'visible',
|
||||
},
|
||||
}}
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={visible => setPopoverVisible(visible)}
|
||||
placement="bottom"
|
||||
forceRender={forceRender}
|
||||
>
|
||||
<Tooltip title={dropdownTriggerTooltip}>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
data-test="dropdown-container-btn"
|
||||
icon={dropdownTriggerIcon}
|
||||
css={css`
|
||||
padding-left: ${theme.paddingXS}px;
|
||||
padding-right: ${theme.paddingXXS}px;
|
||||
gap: ${theme.sizeXXS}px;
|
||||
`}
|
||||
>
|
||||
{dropdownTriggerText}
|
||||
<Badge
|
||||
count={dropdownTriggerCount ?? overflowingCount}
|
||||
color={
|
||||
(dropdownTriggerCount ?? overflowingCount) > 0
|
||||
? theme.colorPrimary
|
||||
: theme.colorTextSecondary
|
||||
}
|
||||
showZero
|
||||
css={css`
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
<Icons.DownOutlined
|
||||
iconSize="m"
|
||||
iconColor={theme.colorIcon}
|
||||
css={css`
|
||||
.anticon {
|
||||
display: flex;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -16,448 +16,6 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
CSSProperties,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { Global } from '@emotion/react';
|
||||
import { css, t, useTheme, usePrevious } from '@superset-ui/core';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { Badge, Icons, Button, Tooltip, Popover } from '..';
|
||||
/**
|
||||
* Container item.
|
||||
*/
|
||||
export interface DropdownItem {
|
||||
/**
|
||||
* String that uniquely identifies the item.
|
||||
*/
|
||||
id: string;
|
||||
/**
|
||||
* The element to be rendered.
|
||||
*/
|
||||
element: ReactElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Horizontal container that displays overflowed items in a dropdown.
|
||||
* It shows an indicator of how many items are currently overflowing.
|
||||
*/
|
||||
export interface DropdownContainerProps {
|
||||
/**
|
||||
* Array of items. The id property is used to uniquely identify
|
||||
* the elements when rendering or dealing with event handlers.
|
||||
*/
|
||||
items: DropdownItem[];
|
||||
/**
|
||||
* Event handler called every time an element moves between
|
||||
* main container and dropdown.
|
||||
*/
|
||||
onOverflowingStateChange?: (overflowingState: {
|
||||
notOverflowed: string[];
|
||||
overflowed: string[];
|
||||
}) => void;
|
||||
/**
|
||||
* Option to customize the content of the dropdown.
|
||||
*/
|
||||
dropdownContent?: (overflowedItems: DropdownItem[]) => ReactElement;
|
||||
/**
|
||||
* Dropdown ref.
|
||||
*/
|
||||
dropdownRef?: RefObject<HTMLDivElement>;
|
||||
/**
|
||||
* Dropdown additional style properties.
|
||||
*/
|
||||
dropdownStyle?: CSSProperties;
|
||||
/**
|
||||
* Displayed count in the dropdown trigger.
|
||||
*/
|
||||
dropdownTriggerCount?: number;
|
||||
/**
|
||||
* Icon of the dropdown trigger.
|
||||
*/
|
||||
dropdownTriggerIcon?: ReactElement;
|
||||
/**
|
||||
* Text of the dropdown trigger.
|
||||
*/
|
||||
dropdownTriggerText?: string;
|
||||
/**
|
||||
* Text of the dropdown trigger tooltip
|
||||
*/
|
||||
dropdownTriggerTooltip?: ReactNode | null;
|
||||
/**
|
||||
* Main container additional style properties.
|
||||
*/
|
||||
style?: CSSProperties;
|
||||
/**
|
||||
* Force render popover content before it's first opened
|
||||
*/
|
||||
forceRender?: boolean;
|
||||
}
|
||||
|
||||
export type DropdownRef = HTMLDivElement & { open: () => void };
|
||||
|
||||
const MAX_HEIGHT = 500;
|
||||
|
||||
export const DropdownContainer = forwardRef(
|
||||
(
|
||||
{
|
||||
items,
|
||||
onOverflowingStateChange,
|
||||
dropdownContent,
|
||||
dropdownRef,
|
||||
dropdownStyle = {},
|
||||
dropdownTriggerCount,
|
||||
dropdownTriggerIcon,
|
||||
dropdownTriggerText = t('More'),
|
||||
dropdownTriggerTooltip = null,
|
||||
forceRender,
|
||||
style,
|
||||
}: DropdownContainerProps,
|
||||
outerRef: RefObject<DropdownRef>,
|
||||
) => {
|
||||
const theme = useTheme();
|
||||
const { ref, width = 0 } = useResizeDetector<HTMLDivElement>();
|
||||
const previousWidth = usePrevious(width) || 0;
|
||||
const { current } = ref;
|
||||
const [itemsWidth, setItemsWidth] = useState<number[]>([]);
|
||||
const [popoverVisible, setPopoverVisible] = useState(false);
|
||||
// We use React.useState to be able to mock the state in Jest
|
||||
const [overflowingIndex, setOverflowingIndex] = useState<number>(-1);
|
||||
|
||||
let targetRef = useRef<HTMLDivElement>(null);
|
||||
if (dropdownRef) {
|
||||
targetRef = dropdownRef;
|
||||
}
|
||||
|
||||
const [showOverflow, setShowOverflow] = useState(false);
|
||||
|
||||
// callback to update item widths so that the useLayoutEffect runs whenever
|
||||
// width of any of the child changes
|
||||
const recalculateItemWidths = useCallback(() => {
|
||||
const mainItemsContainerNode = current?.children.item(0);
|
||||
if (mainItemsContainerNode) {
|
||||
const visibleChildrenElements = Array.from(
|
||||
mainItemsContainerNode.children,
|
||||
);
|
||||
setItemsWidth(prevGlobalWidths => {
|
||||
if (prevGlobalWidths.length !== items.length) {
|
||||
return prevGlobalWidths;
|
||||
}
|
||||
|
||||
const newGlobalWidths = [...prevGlobalWidths];
|
||||
let changed = false;
|
||||
visibleChildrenElements.forEach((child, indexInVisible) => {
|
||||
const originalItemIndex = indexInVisible;
|
||||
if (originalItemIndex < newGlobalWidths.length) {
|
||||
const newWidth = child.getBoundingClientRect().width;
|
||||
if (newGlobalWidths[originalItemIndex] !== newWidth) {
|
||||
newGlobalWidths[originalItemIndex] = newWidth;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return changed ? newGlobalWidths : prevGlobalWidths;
|
||||
});
|
||||
}
|
||||
}, [current?.children, items.length]);
|
||||
|
||||
const reduceItems = (items: DropdownItem[]): [DropdownItem[], string[]] =>
|
||||
items.reduce(
|
||||
([items, ids], item) => {
|
||||
items.push({
|
||||
id: item.id,
|
||||
element: cloneElement(item.element, { key: item.id }),
|
||||
});
|
||||
ids.push(item.id);
|
||||
return [items, ids];
|
||||
},
|
||||
[[], []] as [DropdownItem[], string[]],
|
||||
);
|
||||
|
||||
const [notOverflowedItems, notOverflowedIds] = useMemo(
|
||||
() =>
|
||||
reduceItems(
|
||||
items.slice(
|
||||
0,
|
||||
overflowingIndex !== -1 ? overflowingIndex : items.length,
|
||||
),
|
||||
),
|
||||
[items, overflowingIndex],
|
||||
);
|
||||
|
||||
const [overflowedItems, overflowedIds] = useMemo(
|
||||
() =>
|
||||
overflowingIndex !== -1
|
||||
? reduceItems(items.slice(overflowingIndex))
|
||||
: [[], []],
|
||||
[items, overflowingIndex],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const container = current?.children.item(0);
|
||||
if (!container) return;
|
||||
|
||||
const childrenArray = Array.from(container.children);
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
recalculateItemWidths();
|
||||
});
|
||||
|
||||
childrenArray.map(child => resizeObserver.observe(child));
|
||||
|
||||
// eslint-disable-next-line consistent-return
|
||||
return () => {
|
||||
childrenArray.map(child => resizeObserver.unobserve(child));
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [items.length, current, recalculateItemWidths]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
return;
|
||||
}
|
||||
const container = current?.children.item(0);
|
||||
if (container) {
|
||||
const { children } = container;
|
||||
const childrenArray = Array.from(children);
|
||||
// If items length change, add all items to the container
|
||||
// and recalculate the widths
|
||||
if (itemsWidth.length !== items.length) {
|
||||
if (childrenArray.length === items.length) {
|
||||
setItemsWidth(
|
||||
childrenArray.map(child => child.getBoundingClientRect().width),
|
||||
);
|
||||
} else {
|
||||
setOverflowingIndex(-1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculates the index of the first overflowed element
|
||||
// +1 is to give at least one pixel of difference and avoid flakiness
|
||||
const index = childrenArray.findIndex(
|
||||
child =>
|
||||
child.getBoundingClientRect().right >
|
||||
container.getBoundingClientRect().right + 1,
|
||||
);
|
||||
|
||||
// If elements fit (-1) and there's overflowed items
|
||||
// then preserve the overflow index. We can't use overflowIndex
|
||||
// directly because the items may have been modified
|
||||
let newOverflowingIndex =
|
||||
index === -1 && overflowedItems.length > 0
|
||||
? items.length - overflowedItems.length
|
||||
: index;
|
||||
|
||||
if (width > previousWidth) {
|
||||
// Calculates remaining space in the container
|
||||
const button = current?.children.item(1);
|
||||
const buttonRight = button?.getBoundingClientRect().right || 0;
|
||||
const containerRight = current?.getBoundingClientRect().right || 0;
|
||||
const remainingSpace = containerRight - buttonRight;
|
||||
|
||||
// Checks if some elements in the dropdown fits in the remaining space
|
||||
let sum = 0;
|
||||
for (let i = childrenArray.length; i < items.length; i += 1) {
|
||||
sum += itemsWidth[i];
|
||||
if (sum <= remainingSpace) {
|
||||
newOverflowingIndex = i + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setOverflowingIndex(newOverflowingIndex);
|
||||
}
|
||||
}, [
|
||||
current,
|
||||
items.length,
|
||||
itemsWidth,
|
||||
overflowedItems.length,
|
||||
previousWidth,
|
||||
width,
|
||||
popoverVisible,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (onOverflowingStateChange) {
|
||||
onOverflowingStateChange({
|
||||
notOverflowed: notOverflowedIds,
|
||||
overflowed: overflowedIds,
|
||||
});
|
||||
}
|
||||
}, [notOverflowedIds, onOverflowingStateChange, overflowedIds]);
|
||||
|
||||
const overflowingCount =
|
||||
overflowingIndex !== -1 ? items.length - overflowingIndex : 0;
|
||||
|
||||
const popoverContent = useMemo(
|
||||
() =>
|
||||
dropdownContent || overflowingCount ? (
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
`}
|
||||
data-test="dropdown-content"
|
||||
style={dropdownStyle}
|
||||
ref={targetRef}
|
||||
>
|
||||
{dropdownContent
|
||||
? dropdownContent(overflowedItems)
|
||||
: overflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
) : null,
|
||||
[
|
||||
dropdownContent,
|
||||
overflowingCount,
|
||||
theme.sizeUnit,
|
||||
dropdownStyle,
|
||||
overflowedItems,
|
||||
],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (popoverVisible) {
|
||||
// Measures scroll height after rendering the elements
|
||||
setTimeout(() => {
|
||||
if (targetRef.current) {
|
||||
// We only set overflow when there's enough space to display
|
||||
// Select's popovers because they are restrained by the overflow property.
|
||||
setShowOverflow(targetRef.current.scrollHeight > MAX_HEIGHT);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
}, [popoverVisible]);
|
||||
|
||||
useImperativeHandle(
|
||||
outerRef,
|
||||
() => ({
|
||||
...(ref.current as HTMLDivElement),
|
||||
open: () => setPopoverVisible(true),
|
||||
}),
|
||||
[ref],
|
||||
);
|
||||
|
||||
// Closes the popover when scrolling on the document
|
||||
useEffect(() => {
|
||||
document.onscroll = popoverVisible
|
||||
? () => setPopoverVisible(false)
|
||||
: null;
|
||||
return () => {
|
||||
document.onscroll = null;
|
||||
};
|
||||
}, [popoverVisible]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
`}
|
||||
>
|
||||
<div
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: ${theme.sizeUnit * 4}px;
|
||||
margin-right: ${theme.sizeUnit * 4}px;
|
||||
min-width: 0px;
|
||||
`}
|
||||
data-test="container"
|
||||
style={style}
|
||||
>
|
||||
{notOverflowedItems.map(item => item.element)}
|
||||
</div>
|
||||
{popoverContent && (
|
||||
<>
|
||||
<Global
|
||||
styles={css`
|
||||
.ant-popover-inner {
|
||||
// Some OS versions only show the scroll when hovering.
|
||||
// These settings will make the scroll always visible.
|
||||
::-webkit-scrollbar {
|
||||
-webkit-appearance: none;
|
||||
width: 14px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
border-radius: 9px;
|
||||
background-color: ${theme.colors.grayscale.light1};
|
||||
border: 3px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background-color: ${theme.colors.grayscale.light4};
|
||||
border-left: 1px solid ${theme.colors.grayscale.light2};
|
||||
}
|
||||
}
|
||||
`}
|
||||
/>
|
||||
|
||||
<Popover
|
||||
styles={{
|
||||
body: {
|
||||
maxHeight: `${MAX_HEIGHT}px`,
|
||||
overflow: showOverflow ? 'auto' : 'visible',
|
||||
},
|
||||
}}
|
||||
content={popoverContent}
|
||||
trigger="click"
|
||||
open={popoverVisible}
|
||||
onOpenChange={visible => setPopoverVisible(visible)}
|
||||
placement="bottom"
|
||||
forceRender={forceRender}
|
||||
>
|
||||
<Tooltip title={dropdownTriggerTooltip}>
|
||||
<Button
|
||||
buttonStyle="secondary"
|
||||
data-test="dropdown-container-btn"
|
||||
>
|
||||
{dropdownTriggerIcon}
|
||||
{dropdownTriggerText}
|
||||
<Badge
|
||||
count={dropdownTriggerCount ?? overflowingCount}
|
||||
color={
|
||||
(dropdownTriggerCount ?? overflowingCount) > 0
|
||||
? theme.colorPrimary
|
||||
: theme.colors.grayscale.light1
|
||||
}
|
||||
showZero
|
||||
css={css`
|
||||
margin-left: ${theme.sizeUnit * 2}px;
|
||||
`}
|
||||
/>
|
||||
<Icons.DownOutlined
|
||||
iconSize="m"
|
||||
iconColor={theme.colors.grayscale.light1}
|
||||
css={css`
|
||||
.anticon {
|
||||
display: flex;
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Popover>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
export { DropdownContainer } from './DropdownContainer';
|
||||
export type * from './types';
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import type { CSSProperties, ReactElement, RefObject, ReactNode } from 'react';
|
||||
import { IconType } from '../Icons';
|
||||
|
||||
/**
|
||||
* Container item.
|
||||
@@ -69,7 +70,7 @@ export interface DropdownContainerProps {
|
||||
/**
|
||||
* Icon of the dropdown trigger.
|
||||
*/
|
||||
dropdownTriggerIcon?: ReactElement;
|
||||
dropdownTriggerIcon?: IconType;
|
||||
/**
|
||||
* Text of the dropdown trigger.
|
||||
*/
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { Form } from 'antd';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import { styled } from '../../theme';
|
||||
|
||||
export const FormItem = styled(Form.Item)`
|
||||
${({ theme }) => `
|
||||
|
||||
@@ -28,14 +28,17 @@ export default {
|
||||
component: BaseIconComponent,
|
||||
};
|
||||
|
||||
const palette: Record<string, string | null> = { Default: null };
|
||||
Object.entries(supersetTheme.colors).forEach(([familyName, family]) => {
|
||||
Object.entries(family as Record<string, string>).forEach(
|
||||
([colorName, colorValue]) => {
|
||||
palette[`${familyName} / ${colorName}`] = colorValue;
|
||||
},
|
||||
);
|
||||
});
|
||||
const palette: Record<string, string | null> = {
|
||||
Default: null,
|
||||
Primary: supersetTheme.colorPrimary,
|
||||
Success: supersetTheme.colorSuccess,
|
||||
Warning: supersetTheme.colorWarning,
|
||||
Error: supersetTheme.colorError,
|
||||
Info: supersetTheme.colorInfo,
|
||||
Text: supersetTheme.colorText,
|
||||
'Text Secondary': supersetTheme.colorTextSecondary,
|
||||
Icon: supersetTheme.colorIcon,
|
||||
};
|
||||
|
||||
const IconSet = styled.div`
|
||||
display: grid;
|
||||
|
||||
@@ -25,7 +25,7 @@ import type { LabelProps } from './types';
|
||||
|
||||
export function Label(props: LabelProps) {
|
||||
const theme = useTheme();
|
||||
const { transitionTiming } = theme;
|
||||
// Use Ant Design's motion duration instead of deprecated transitionTiming
|
||||
const {
|
||||
type = 'default',
|
||||
monospace = false,
|
||||
@@ -46,7 +46,7 @@ export function Label(props: LabelProps) {
|
||||
const borderColorHover = onClick ? baseColor.borderHover : borderColor;
|
||||
|
||||
const labelStyles = css`
|
||||
transition: background-color ${transitionTiming}s;
|
||||
transition: background-color ${theme.motionDurationMid};
|
||||
white-space: nowrap;
|
||||
cursor: ${onClick ? 'pointer' : 'default'};
|
||||
overflow: hidden;
|
||||
|
||||
@@ -45,7 +45,16 @@ export const DatasetTypeLabel: React.FC<DatasetTypeLabelProps> = ({
|
||||
const labelType = datasetType === 'physical' ? 'primary' : 'default';
|
||||
|
||||
return (
|
||||
<Label icon={icon} type={labelType}>
|
||||
<Label
|
||||
icon={icon}
|
||||
type={labelType}
|
||||
style={{
|
||||
color:
|
||||
datasetType === 'physical'
|
||||
? theme.colorPrimaryText
|
||||
: theme.colorPrimary,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,7 @@ const StyledCard = styled(Card)`
|
||||
|
||||
const Cover = styled.div`
|
||||
height: 264px;
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
|
||||
border-bottom: 1px solid ${({ theme }) => theme.colorSplit};
|
||||
overflow: hidden;
|
||||
|
||||
.cover-footer {
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { styled } from '@superset-ui/core';
|
||||
import cls from 'classnames';
|
||||
import { styled } from '../../theme';
|
||||
import { Loading as Loader } from '../assets';
|
||||
import type { LoadingProps } from './types';
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ const StyledMenuItem = styled(AntdMenu.Item)`
|
||||
justify-content: space-between;
|
||||
}
|
||||
a {
|
||||
transition: background-color ${theme.motionDurationMid}s;
|
||||
transition: background-color ${theme.motionDurationMid};
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
@@ -63,7 +63,7 @@ const StyledMenuItem = styled(AntdMenu.Item)`
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%);
|
||||
transition: translate ${theme.motionDurationMid}s;
|
||||
transition: translate ${theme.motionDurationMid};
|
||||
}
|
||||
&:focus {
|
||||
@media (max-width: 767px) {
|
||||
@@ -140,7 +140,7 @@ const StyledSubMenu = styled(AntdMenu.SubMenu)`
|
||||
height: 3px;
|
||||
opacity: 0;
|
||||
transform: translateX(-50%);
|
||||
transition: all ${theme.transitionTiming}s;
|
||||
transition: all ${theme.motionDurationMid};
|
||||
}
|
||||
}
|
||||
`}
|
||||
|
||||
@@ -30,7 +30,7 @@ const MetadataWrapper = styled.div`
|
||||
|
||||
const MetadataText = styled.span`
|
||||
font-size: ${({ theme }) => theme.fontSizeXS}px;
|
||||
color: ${({ theme }) => theme.colors.grayscale.light1};
|
||||
color: ${({ theme }) => theme.colorTextSecondary};
|
||||
font-weight: ${({ theme }) => theme.fontWeightStrong};
|
||||
`;
|
||||
|
||||
|
||||
@@ -60,12 +60,12 @@ const menuItemStyles = (theme: any) => css`
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: ${theme.colors.grayscale.light3};
|
||||
background: ${theme.colorFillQuaternary};
|
||||
}
|
||||
|
||||
&.active {
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
background: ${theme.colors.grayscale.light2};
|
||||
background: ${theme.colorFillTertiary};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,16 +66,14 @@ export default function PopoverSection({
|
||||
<Icons.InfoCircleOutlined
|
||||
role="img"
|
||||
iconSize="s"
|
||||
iconColor={theme.colors.grayscale.light1}
|
||||
iconColor={theme.colorIcon}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
<Icons.CheckOutlined
|
||||
iconSize="s"
|
||||
role="img"
|
||||
iconColor={
|
||||
isSelected ? theme.colorPrimary : theme.colors.grayscale.base
|
||||
}
|
||||
iconColor={isSelected ? theme.colorPrimary : theme.colorIcon}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user