mirror of
https://github.com/apache/superset.git
synced 2026-06-12 19:19:20 +00:00
Compare commits
83 Commits
examples
...
msyavuz/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e6d85104 | ||
|
|
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 |
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}"
|
||||
@@ -3,3 +3,14 @@
|
||||
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\""
|
||||
@@ -1,8 +1,15 @@
|
||||
{
|
||||
"name": "Apache Superset Development",
|
||||
// Keep this in sync with the base image in Dockerfile (ARG PY_VER)
|
||||
// Using the same base as Dockerfile, but non-slim for dev tools
|
||||
"image": "python:3.11.13-bookworm",
|
||||
// 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": {
|
||||
@@ -32,10 +39,17 @@
|
||||
},
|
||||
|
||||
// Run commands after container is created
|
||||
"postCreateCommand": "chmod +x .devcontainer/setup-dev.sh && .devcontainer/setup-dev.sh",
|
||||
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
|
||||
|
||||
// Auto-start Superset on Codespace resume
|
||||
"postStartCommand": ".devcontainer/start-superset.sh",
|
||||
// Auto-start Superset after ensuring Docker is ready
|
||||
// Run in foreground to see any errors, but don't block on failures
|
||||
"postStartCommand": "bash -c 'echo \"Waiting 30s for services to initialize...\"; sleep 30; .devcontainer/start-superset.sh || echo \"⚠️ Auto-start failed - run start-superset manually\"'",
|
||||
|
||||
// Set environment variables
|
||||
"remoteEnv": {
|
||||
// Removed automatic venv activation to prevent startup issues
|
||||
// The setup script will handle this
|
||||
},
|
||||
|
||||
// VS Code customizations
|
||||
"customizations": {
|
||||
|
||||
@@ -3,30 +3,76 @@
|
||||
|
||||
echo "🔧 Setting up Superset development environment..."
|
||||
|
||||
# The universal image has most tools, just need Superset-specific libs
|
||||
echo "📦 Installing Superset-specific dependencies..."
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libsasl2-dev \
|
||||
libldap2-dev \
|
||||
libpq-dev \
|
||||
tmux \
|
||||
gh
|
||||
# System dependencies and uv are now pre-installed in the Docker image
|
||||
# This speeds up Codespace creation significantly!
|
||||
|
||||
# Install uv for fast Python package management
|
||||
echo "📦 Installing uv..."
|
||||
curl -LsSf https://astral.sh/uv/install.sh | sh
|
||||
# Create virtual environment using uv
|
||||
echo "🐍 Creating Python virtual environment..."
|
||||
if ! uv venv; then
|
||||
echo "❌ Failed to create virtual environment"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add cargo/bin to PATH for uv
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.bashrc
|
||||
echo 'export PATH="$HOME/.cargo/bin:$PATH"' >> ~/.zshrc
|
||||
# Install 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..."
|
||||
npm install -g @anthropic-ai/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 "🚀 Run '.devcontainer/start-superset.sh' to start Superset"
|
||||
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 ""
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
#!/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"
|
||||
|
||||
@@ -13,10 +18,37 @@ else
|
||||
echo "📁 Using current directory: $(pwd)"
|
||||
fi
|
||||
|
||||
# Check if docker is running
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "⏳ Waiting for Docker to start..."
|
||||
sleep 5
|
||||
# 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
|
||||
@@ -24,16 +56,33 @@ echo "🧹 Cleaning up existing containers..."
|
||||
docker-compose -f docker-compose-light.yml down
|
||||
|
||||
# Start services
|
||||
echo "🏗️ Building and starting services..."
|
||||
echo "🏗️ Starting Superset in background (daemon mode)..."
|
||||
echo ""
|
||||
echo "📝 Once started, login with:"
|
||||
echo " Username: admin"
|
||||
echo " Password: admin"
|
||||
echo ""
|
||||
echo "📋 Running in foreground with live logs (Ctrl+C to stop)..."
|
||||
|
||||
# Run docker-compose and capture exit code
|
||||
docker-compose -f docker-compose-light.yml up
|
||||
# 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
|
||||
|
||||
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 }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -131,3 +131,4 @@ superset/static/stats/statistics.html
|
||||
# LLM-related
|
||||
CLAUDE.local.md
|
||||
.aider*
|
||||
.claude_rc*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -32,6 +32,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
|
||||
@@ -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
|
||||
@@ -87,8 +87,66 @@ Restart Superset to apply changes.
|
||||
3. **Apply**: Assign themes to specific dashboards or configure instance-wide
|
||||
4. **Iterate**: Modify theme JSON directly in the CRUD interface or re-import from the theme editor
|
||||
|
||||
## Custom Fonts
|
||||
|
||||
Superset supports custom fonts through runtime configuration, allowing you to use branded or custom typefaces without rebuilding the application.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
Add font URLs to your `superset_config.py`:
|
||||
|
||||
```python
|
||||
# Load fonts from Google Fonts, Adobe Fonts, or self-hosted sources
|
||||
CUSTOM_FONT_URLS = [
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
|
||||
"https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
]
|
||||
|
||||
# Update CSP to allow font sources
|
||||
TALISMAN_CONFIG = {
|
||||
"content_security_policy": {
|
||||
"font-src": ["'self'", "https://fonts.googleapis.com", "https://fonts.gstatic.com"],
|
||||
"style-src": ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com"],
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Using Custom Fonts in Themes
|
||||
|
||||
Once configured, reference the fonts in your theme configuration:
|
||||
|
||||
```python
|
||||
THEME_DEFAULT = {
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace",
|
||||
# ... other theme tokens
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Or in the CRUD interface theme JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"token": {
|
||||
"fontFamily": "Inter, -apple-system, BlinkMacSystemFont, sans-serif",
|
||||
"fontFamilyCode": "JetBrains Mono, Monaco, monospace"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Font Sources
|
||||
|
||||
- **Google Fonts**: Free, CDN-hosted fonts with wide variety
|
||||
- **Adobe Fonts**: Premium fonts (requires subscription and kit ID)
|
||||
- **Self-hosted**: Place font files in `/static/assets/fonts/` and reference via CSS
|
||||
|
||||
This feature works with the stock Docker image - no custom build required!
|
||||
|
||||
## Advanced Features
|
||||
|
||||
- **System Themes**: Superset includes built-in light and dark themes
|
||||
- **Per-Dashboard Theming**: Each dashboard can have its own visual identity
|
||||
- **JSON Editor**: Edit theme configurations directly within Superset's interface
|
||||
- **Custom Fonts**: Load external fonts via configuration without rebuilding
|
||||
|
||||
@@ -137,7 +137,7 @@ contributing to Apache Superset more accessible to developers worldwide.
|
||||
|
||||
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
|
||||
|
||||
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=codespaces&geo=UsWest&devcontainer_path=.devcontainer%2Fdevcontainer.json)
|
||||
[**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).
|
||||
@@ -421,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.
|
||||
|
||||
@@ -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": [
|
||||
|
||||
327
docs/yarn.lock
327
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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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.
|
||||
|
||||
@@ -195,6 +195,7 @@ development = [
|
||||
"grpcio>=1.55.3",
|
||||
"openapi-spec-validator",
|
||||
"parameterized",
|
||||
"pip",
|
||||
"pre-commit",
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
|
||||
@@ -541,6 +541,8 @@ pillow==10.3.0
|
||||
# via
|
||||
# apache-superset
|
||||
# matplotlib
|
||||
pip==25.1.1
|
||||
# via apache-superset
|
||||
platformdirs==4.3.8
|
||||
# via
|
||||
# -c requirements/base.txt
|
||||
|
||||
@@ -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',
|
||||
{
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
3091
superset-frontend/package-lock.json
generated
3091
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",
|
||||
@@ -129,7 +129,7 @@
|
||||
"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",
|
||||
@@ -176,7 +176,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 +208,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 +216,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 +243,7 @@
|
||||
"@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/classnames": "^2.3.4",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
@@ -331,7 +331,7 @@
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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'> = {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
],
|
||||
"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",
|
||||
@@ -37,7 +37,7 @@
|
||||
"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",
|
||||
@@ -46,10 +46,10 @@
|
||||
"lodash": "^4.17.21",
|
||||
"math-expression-evaluator": "^2.0.6",
|
||||
"pretty-ms": "^9.2.0",
|
||||
"re-resizable": "^6.10.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react-ace": "^10.1.0",
|
||||
"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 +59,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 +78,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",
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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};` : ''
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
@@ -17,10 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
CSSProperties,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
ReactElement,
|
||||
RefObject,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
@@ -28,7 +26,6 @@ import {
|
||||
useMemo,
|
||||
useState,
|
||||
useRef,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
@@ -36,77 +33,7 @@ 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 };
|
||||
import { DropdownContainerProps, DropdownItem, DropdownRef } from './types';
|
||||
|
||||
const MAX_HEIGHT = 500;
|
||||
|
||||
@@ -428,8 +355,13 @@ export const DropdownContainer = forwardRef(
|
||||
<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;
|
||||
`}
|
||||
>
|
||||
{dropdownTriggerIcon}
|
||||
{dropdownTriggerText}
|
||||
<Badge
|
||||
count={dropdownTriggerCount ?? overflowingCount}
|
||||
@@ -461,3 +393,5 @@ export const DropdownContainer = forwardRef(
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { DropdownItem, DropdownContainerProps, DropdownRef } 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.
|
||||
*/
|
||||
|
||||
@@ -32,8 +32,9 @@ export const StyledHeader = styled.span<{ headerPosition: string }>`
|
||||
`;
|
||||
|
||||
export const StyledContainer = styled.div<{ headerPosition: string }>`
|
||||
${({ headerPosition }) => `
|
||||
${({ headerPosition, theme }) => `
|
||||
display: flex;
|
||||
gap: ${theme.sizeUnit}px;
|
||||
flex-direction: ${headerPosition === 'top' ? 'column' : 'row'};
|
||||
align-items: ${headerPosition === 'left' ? 'center' : undefined};
|
||||
width: 100%;
|
||||
|
||||
@@ -132,7 +132,7 @@ const VirtualTable = <RecordType extends object>(
|
||||
if (gridRef.current) {
|
||||
return gridRef.current?.state?.scrollLeft;
|
||||
}
|
||||
return null;
|
||||
return 0;
|
||||
},
|
||||
set: (scrollLeft: number) => {
|
||||
if (gridRef.current) {
|
||||
|
||||
@@ -52,7 +52,9 @@ interface TableCollectionProps<T extends object> {
|
||||
const StyledTable = styled(Table)`
|
||||
${({ theme }) => `
|
||||
th.ant-column-cell {
|
||||
min-width: fit-content;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.actions {
|
||||
opacity: 0;
|
||||
@@ -83,7 +85,6 @@ const StyledTable = styled(Table)`
|
||||
font-feature-settings: 'tnum' 1;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
max-width: 320px;
|
||||
line-height: 1;
|
||||
vertical-align: middle;
|
||||
padding-left: ${theme.sizeUnit * 4}px;
|
||||
@@ -149,7 +150,7 @@ function TableCollection<T extends object>({
|
||||
size={size}
|
||||
data-test="listview-table"
|
||||
pagination={false}
|
||||
tableLayout="auto"
|
||||
tableLayout="fixed"
|
||||
rowKey="rowId"
|
||||
rowSelection={rowSelection}
|
||||
locale={{ emptyText: null }}
|
||||
|
||||
@@ -94,7 +94,7 @@ export function mapColumns<T extends object>(
|
||||
dataIndex: column.id?.includes('.') ? column.id.split('.') : column.id,
|
||||
hidden: column.hidden,
|
||||
key: column.id,
|
||||
minWidth: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
|
||||
width: column.size ? COLUMN_SIZE_MAP[column.size] : COLUMN_SIZE_MAP.md,
|
||||
ellipsis: !columnsForWrapText?.includes(column.id),
|
||||
defaultSortOrder: (isSorted
|
||||
? isSortedDesc
|
||||
|
||||
@@ -76,6 +76,7 @@ export { CronPicker, type CronError } from './CronPicker';
|
||||
export * from './DatePicker';
|
||||
export { DeleteModal, type DeleteModalProps } from './DeleteModal';
|
||||
export { Divider, type DividerProps } from './Divider';
|
||||
export { Drawer, type DrawerProps } from './Drawer';
|
||||
export {
|
||||
Dropdown,
|
||||
MenuDotsDropdown,
|
||||
|
||||
@@ -49,6 +49,21 @@ describe('isProbablyHTML', () => {
|
||||
const trickyText = 'a <= 10 and b > 10';
|
||||
expect(isProbablyHTML(trickyText)).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false for strings with angle brackets that are not HTML', () => {
|
||||
// Test case from issue #25561
|
||||
expect(isProbablyHTML('<abcdef:12345>')).toBe(false);
|
||||
|
||||
// Other similar cases
|
||||
expect(isProbablyHTML('<foo:bar>')).toBe(false);
|
||||
expect(isProbablyHTML('<123>')).toBe(false);
|
||||
expect(isProbablyHTML('<test@example.com>')).toBe(false);
|
||||
expect(isProbablyHTML('<custom-element>')).toBe(false);
|
||||
|
||||
// Mathematical expressions
|
||||
expect(isProbablyHTML('if x < 5 and y > 10')).toBe(false);
|
||||
expect(isProbablyHTML('price < $100')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sanitizeHtmlIfNeeded', () => {
|
||||
|
||||
@@ -68,9 +68,87 @@ export function isProbablyHTML(text: string) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if the string contains common HTML patterns
|
||||
if (!hasHtmlTagPattern(text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(cleanedStr, 'text/html');
|
||||
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
|
||||
|
||||
// Check if parsing created actual HTML elements (not just text nodes)
|
||||
const elements = Array.from(doc.body.childNodes).filter(
|
||||
node => node.nodeType === 1,
|
||||
) as Element[];
|
||||
|
||||
// If no elements were created, it's not HTML
|
||||
if (elements.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if the elements are known HTML tags (not custom/unknown tags)
|
||||
// This prevents strings like "<abcdef:12345>" from being treated as HTML
|
||||
return elements.some(element => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
// List of common HTML tags we want to recognize
|
||||
const knownHtmlTags = [
|
||||
'div',
|
||||
'span',
|
||||
'p',
|
||||
'a',
|
||||
'b',
|
||||
'i',
|
||||
'u',
|
||||
'em',
|
||||
'strong',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'h4',
|
||||
'h5',
|
||||
'h6',
|
||||
'table',
|
||||
'tr',
|
||||
'td',
|
||||
'th',
|
||||
'tbody',
|
||||
'thead',
|
||||
'tfoot',
|
||||
'ul',
|
||||
'ol',
|
||||
'li',
|
||||
'img',
|
||||
'br',
|
||||
'hr',
|
||||
'pre',
|
||||
'code',
|
||||
'blockquote',
|
||||
'section',
|
||||
'article',
|
||||
'nav',
|
||||
'header',
|
||||
'footer',
|
||||
'form',
|
||||
'input',
|
||||
'button',
|
||||
'select',
|
||||
'option',
|
||||
'textarea',
|
||||
'label',
|
||||
'fieldset',
|
||||
'legend',
|
||||
'video',
|
||||
'audio',
|
||||
'canvas',
|
||||
'iframe',
|
||||
'script',
|
||||
'style',
|
||||
'link',
|
||||
'meta',
|
||||
'title',
|
||||
];
|
||||
return knownHtmlTags.includes(tagName);
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeHtmlIfNeeded(htmlString: string) {
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('ChartProps', () => {
|
||||
});
|
||||
expect(props1).not.toBe(props2);
|
||||
});
|
||||
it('selector returns a new chartProps if some input fields change', () => {
|
||||
it('selector returns a new chartProps if some input fields change and returns memoized chart props', () => {
|
||||
const props1 = selector({
|
||||
width: 800,
|
||||
height: 600,
|
||||
@@ -145,7 +145,7 @@ describe('ChartProps', () => {
|
||||
theme: supersetTheme,
|
||||
});
|
||||
expect(props1).not.toBe(props2);
|
||||
expect(props1).not.toBe(props3);
|
||||
expect(props1).toBe(props3);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
"@storybook/types": "8.4.7",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"core-js": "3.40.0",
|
||||
"gh-pages": "^6.2.0",
|
||||
"gh-pages": "^6.3.0",
|
||||
"jquery": "^3.7.1",
|
||||
"memoize-one": "^5.2.1",
|
||||
"react": "^17.0.2",
|
||||
@@ -54,7 +54,7 @@
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.26.0",
|
||||
"@babel/preset-env": "^7.27.2",
|
||||
"@babel/preset-react": "^7.26.3",
|
||||
"@babel/preset-react": "^7.27.1",
|
||||
"@babel/preset-typescript": "^7.23.3",
|
||||
"@storybook/react-webpack5": "8.2.9",
|
||||
"babel-loader": "^10.0.0",
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.1.0"
|
||||
"react": "^19.1.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
|
||||
@@ -24,11 +24,11 @@
|
||||
"lib"
|
||||
],
|
||||
"dependencies": {
|
||||
"@deck.gl/aggregation-layers": "^9.1.13",
|
||||
"@deck.gl/core": "^9.1.13",
|
||||
"@deck.gl/aggregation-layers": "^9.1.14",
|
||||
"@deck.gl/core": "^9.1.14",
|
||||
"@deck.gl/geo-layers": "^9.1.13",
|
||||
"@deck.gl/layers": "^9.1.13",
|
||||
"@deck.gl/react": "^9.1.13",
|
||||
"@deck.gl/react": "^9.1.14",
|
||||
"@luma.gl/constants": "^9.1.9",
|
||||
"@luma.gl/core": "^9.1.9",
|
||||
"@luma.gl/engine": "^9.1.9",
|
||||
|
||||
@@ -89,6 +89,7 @@ export type CategoricalDeckGLContainerProps = {
|
||||
width: number;
|
||||
viewport: Viewport;
|
||||
getLayer: GetLayerType<unknown>;
|
||||
getHighlightLayer?: GetLayerType<unknown>;
|
||||
payload: JsonObject;
|
||||
onAddFilter?: HandlerFunction;
|
||||
setControlValue: (control: string, value: JsonValue) => void;
|
||||
@@ -213,6 +214,7 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
const getLayers = useCallback(() => {
|
||||
const {
|
||||
getLayer,
|
||||
getHighlightLayer,
|
||||
payload,
|
||||
formData: fd,
|
||||
onAddFilter,
|
||||
@@ -244,19 +246,27 @@ const CategoricalDeckGLContainer = (props: CategoricalDeckGLContainerProps) => {
|
||||
data: { ...payload.data, features },
|
||||
};
|
||||
|
||||
return [
|
||||
getLayer({
|
||||
formData: fd,
|
||||
payload: filteredPayload,
|
||||
onAddFilter,
|
||||
setTooltip,
|
||||
datasource: props.datasource,
|
||||
onContextMenu,
|
||||
filterState,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
}) as Layer,
|
||||
];
|
||||
const layerProps = {
|
||||
formData: fd,
|
||||
payload: filteredPayload,
|
||||
onAddFilter,
|
||||
setTooltip,
|
||||
datasource: props.datasource,
|
||||
onContextMenu,
|
||||
filterState,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
};
|
||||
|
||||
const layer = getLayer(layerProps) as Layer;
|
||||
|
||||
if (emitCrossFilters && filterState?.value && getHighlightLayer) {
|
||||
const highlightLayer = getHighlightLayer(layerProps) as Layer;
|
||||
|
||||
return [layer, highlightLayer];
|
||||
}
|
||||
|
||||
return [layer];
|
||||
}, [addColor, categories, props, setTooltip]);
|
||||
|
||||
const toggleCategory = useCallback(
|
||||
|
||||
@@ -23,8 +23,11 @@ import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { isEqual } from 'lodash';
|
||||
import {
|
||||
AdhocFilter,
|
||||
ContextMenuFilters,
|
||||
DataMask,
|
||||
Datasource,
|
||||
ensureIsArray,
|
||||
FilterState,
|
||||
HandlerFunction,
|
||||
isDefined,
|
||||
JsonObject,
|
||||
@@ -65,7 +68,15 @@ export type DeckMultiProps = {
|
||||
height: number;
|
||||
width: number;
|
||||
datasource: Datasource;
|
||||
setDataMask?: (dataMask: DataMask) => void;
|
||||
onContextMenu?: (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
filters?: ContextMenuFilters,
|
||||
) => void;
|
||||
onSelect: () => void;
|
||||
filterState?: FilterState;
|
||||
emitCrossFilters?: boolean;
|
||||
};
|
||||
|
||||
const DeckMulti = (props: DeckMultiProps) => {
|
||||
@@ -175,16 +186,14 @@ const DeckMulti = (props: DeckMultiProps) => {
|
||||
const createLayerFromData = useCallback(
|
||||
(subslice: JsonObject, json: JsonObject): Layer =>
|
||||
// @ts-ignore TODO(hainenber): define proper type for `form_data.viz_type` and call signature for functions in layerGenerators.
|
||||
layerGenerators[subslice.form_data.viz_type](
|
||||
subslice.form_data,
|
||||
json,
|
||||
props.onAddFilter,
|
||||
layerGenerators[subslice.form_data.viz_type]({
|
||||
formData: subslice.form_data,
|
||||
payload: json,
|
||||
setTooltip,
|
||||
props.datasource,
|
||||
[],
|
||||
props.onSelect,
|
||||
),
|
||||
[props.onAddFilter, props.onSelect, props.datasource, setTooltip],
|
||||
datasource: props.datasource,
|
||||
onSelect: props.onSelect,
|
||||
}),
|
||||
[props.onSelect, props.datasource, setTooltip],
|
||||
);
|
||||
|
||||
const loadSingleLayer = useCallback(
|
||||
|
||||
@@ -87,6 +87,7 @@ interface GetPointsType {
|
||||
export function createDeckGLComponent(
|
||||
getLayer: GetLayerType<unknown>,
|
||||
getPoints: GetPointsType,
|
||||
getHighlightLayer?: GetLayerType<unknown>,
|
||||
) {
|
||||
// Higher order component
|
||||
return memo((props: DeckGLComponentProps) => {
|
||||
@@ -118,7 +119,7 @@ export function createDeckGLComponent(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const computeLayer = useCallback(
|
||||
const computeLayers = useCallback(
|
||||
(props: DeckGLComponentProps) => {
|
||||
const {
|
||||
formData,
|
||||
@@ -130,7 +131,7 @@ export function createDeckGLComponent(
|
||||
emitCrossFilters,
|
||||
} = props;
|
||||
|
||||
return getLayer({
|
||||
const layerProps = {
|
||||
formData,
|
||||
payload,
|
||||
onAddFilter,
|
||||
@@ -139,7 +140,17 @@ export function createDeckGLComponent(
|
||||
onContextMenu,
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
}) as Layer;
|
||||
};
|
||||
|
||||
const layer = getLayer(layerProps) as Layer;
|
||||
|
||||
if (emitCrossFilters && filterState?.value && getHighlightLayer) {
|
||||
const highlightLayer = getHighlightLayer(layerProps) as Layer;
|
||||
|
||||
return [layer, highlightLayer];
|
||||
}
|
||||
|
||||
return [layer];
|
||||
},
|
||||
[setTooltip],
|
||||
);
|
||||
@@ -152,7 +163,7 @@ export function createDeckGLComponent(
|
||||
setCategories(categories);
|
||||
}, [props]);
|
||||
|
||||
const [layer, setLayer] = useState(computeLayer(props));
|
||||
const [layers, setLayers] = useState(computeLayers(props));
|
||||
|
||||
useEffect(() => {
|
||||
// Only recompute the layer if anything BUT the viewport has changed
|
||||
@@ -167,9 +178,9 @@ export function createDeckGLComponent(
|
||||
viewport: null,
|
||||
};
|
||||
if (!isEqual(prevFdNoVP, currFdNoVP) || prevPayload !== props.payload) {
|
||||
setLayer(computeLayer(props));
|
||||
setLayers(computeLayers(props));
|
||||
}
|
||||
}, [computeLayer, prevFormData, prevFilterState, prevPayload, props]);
|
||||
}, [computeLayers, prevFormData, prevFilterState, prevPayload, props]);
|
||||
|
||||
const { formData, payload, setControlValue, height, width } = props;
|
||||
|
||||
@@ -179,7 +190,7 @@ export function createDeckGLComponent(
|
||||
ref={containerRef}
|
||||
mapboxApiAccessToken={payload.data.mapboxApiKey}
|
||||
viewport={viewport}
|
||||
layers={[layer]}
|
||||
layers={layers}
|
||||
mapStyle={formData.mapbox_style}
|
||||
setControlValue={setControlValue}
|
||||
width={width}
|
||||
@@ -200,6 +211,7 @@ export function createDeckGLComponent(
|
||||
export function createCategoricalDeckGLComponent(
|
||||
getLayer: GetLayerType<Layer>,
|
||||
getPoints: GetPointsType,
|
||||
getHighlightLayer?: GetLayerType<Layer>,
|
||||
) {
|
||||
return function Component(props: DeckGLComponentProps) {
|
||||
const {
|
||||
@@ -224,6 +236,7 @@ export function createCategoricalDeckGLComponent(
|
||||
setControlValue={setControlValue}
|
||||
viewport={viewport}
|
||||
getLayer={getLayer}
|
||||
getHighlightLayer={getHighlightLayer}
|
||||
payload={payload}
|
||||
getPoints={getPoints}
|
||||
width={width}
|
||||
|
||||
@@ -23,6 +23,7 @@ import { commonLayerProps } from '../common';
|
||||
import { GetLayerType, createCategoricalDeckGLComponent } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { Point } from '../../types';
|
||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
const points: Point[] = [];
|
||||
@@ -73,7 +74,7 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
|
||||
|
||||
return new ArcLayer({
|
||||
data,
|
||||
getSourceColor: (d: any) => {
|
||||
getSourceColor: (d: JsonObject) => {
|
||||
if (colorSchemeType === COLOR_SCHEME_TYPES.fixed_color) {
|
||||
return [sc.r, sc.g, sc.b, 255 * sc.a];
|
||||
}
|
||||
@@ -98,7 +99,50 @@ export const getLayer: GetLayerType<ArcLayer> = function ({
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
opacity: filterState?.value ? 0.1 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createCategoricalDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<ArcLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const data = payload.data.features;
|
||||
|
||||
const getColor = (d: {
|
||||
sourcePosition: [number, number];
|
||||
targetPosition: [number, number];
|
||||
}) => {
|
||||
const sourcePosition = filterState?.value[0];
|
||||
const targetPosition = filterState?.value[1];
|
||||
|
||||
if (
|
||||
sourcePosition &&
|
||||
targetPosition &&
|
||||
d.sourcePosition[0] === sourcePosition[0] &&
|
||||
d.sourcePosition[1] === sourcePosition[1] &&
|
||||
d.targetPosition[0] === targetPosition[0] &&
|
||||
d.targetPosition[1] === targetPosition[1]
|
||||
) {
|
||||
return HIGHLIGHT_COLOR_ARRAY;
|
||||
}
|
||||
|
||||
return TRANSPARENT_COLOR_ARRAY;
|
||||
};
|
||||
|
||||
return new ArcLayer({
|
||||
data,
|
||||
getSourceColor: getColor,
|
||||
getTargetColor: getColor,
|
||||
id: `path-hihglight-layer-${fd.slice_id}` as const,
|
||||
getWidth: fd.stroke_width ? fd.stroke_width : 3,
|
||||
});
|
||||
};
|
||||
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ContourLayer } from '@deck.gl/aggregation-layers';
|
||||
import { PolygonLayer } from '@deck.gl/layers';
|
||||
import { Position } from '@deck.gl/core';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { commonLayerProps } from '../common';
|
||||
@@ -24,6 +25,7 @@ import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { ColorType } from '../../types';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function setTooltipContent(o: any) {
|
||||
return (
|
||||
@@ -112,4 +114,56 @@ export function getPoints(data: any[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<PolygonLayer> = function ({
|
||||
formData,
|
||||
filterState,
|
||||
setDataMask,
|
||||
onContextMenu,
|
||||
setTooltip,
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData;
|
||||
|
||||
const fromLonLat = filterState?.value[0];
|
||||
const toLonLat = filterState?.value[1];
|
||||
|
||||
const minLon = fromLonLat[0];
|
||||
const maxLon = toLonLat[0];
|
||||
const minLat = fromLonLat[1];
|
||||
const maxLat = toLonLat[1];
|
||||
|
||||
const boxPolygon = [
|
||||
[minLon, minLat],
|
||||
[maxLon, minLat],
|
||||
[maxLon, maxLat],
|
||||
[minLon, maxLat],
|
||||
[minLon, minLat],
|
||||
];
|
||||
|
||||
return new PolygonLayer({
|
||||
id: `contour-highlight-layer-${fd.slice_id}`,
|
||||
data: [{ polygon: boxPolygon }],
|
||||
getPolygon: (d: any) => d.polygon,
|
||||
getFillColor: [
|
||||
HIGHLIGHT_COLOR_ARRAY[0],
|
||||
HIGHLIGHT_COLOR_ARRAY[1],
|
||||
HIGHLIGHT_COLOR_ARRAY[2],
|
||||
100,
|
||||
],
|
||||
getLineColor: HIGHLIGHT_COLOR_ARRAY,
|
||||
getLineWidth: 4,
|
||||
filled: true,
|
||||
stroked: true,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
setTooltipContent,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
filterState,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -43,6 +43,7 @@ import fitViewport, { Viewport } from '../../utils/fitViewport';
|
||||
import { TooltipProps } from '../../components/Tooltip';
|
||||
import { Point } from '../../types';
|
||||
import { GetLayerType } from '../../factory';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
type ProcessedFeature = Feature<Geometry, GeoJsonProperties> & {
|
||||
properties: JsonObject;
|
||||
@@ -119,7 +120,21 @@ function setTooltipContent(o: JsonObject) {
|
||||
);
|
||||
}
|
||||
|
||||
const getFillColor = (feature: JsonObject) => feature?.properties?.fillColor;
|
||||
const getFillColor = (feature: JsonObject, filterStateValue: unknown[]) => {
|
||||
if (filterStateValue) {
|
||||
if (
|
||||
JSON.stringify(feature.geometry.coordinates) ===
|
||||
JSON.stringify(filterStateValue?.[0])
|
||||
) {
|
||||
return HIGHLIGHT_COLOR_ARRAY;
|
||||
}
|
||||
|
||||
const fillColor = feature?.properties?.fillColor;
|
||||
fillColor[3] = 125;
|
||||
return fillColor;
|
||||
}
|
||||
return feature?.properties?.fillColor;
|
||||
};
|
||||
const getLineColor = (feature: JsonObject) => feature?.properties?.strokeColor;
|
||||
|
||||
export const getLayer: GetLayerType<GeoJsonLayer> = function ({
|
||||
@@ -160,7 +175,8 @@ export const getLayer: GetLayerType<GeoJsonLayer> = function ({
|
||||
extruded: fd.extruded,
|
||||
filled: fd.filled,
|
||||
stroked: fd.stroked,
|
||||
getFillColor,
|
||||
getFillColor: (feature: JsonObject) =>
|
||||
getFillColor(feature, filterState?.value),
|
||||
getLineColor,
|
||||
getLineWidth: fd.line_width || 1,
|
||||
pointRadiusScale: fd.point_radius_scale,
|
||||
@@ -188,6 +204,7 @@ export type DeckGLGeoJsonProps = {
|
||||
filterState: FilterState;
|
||||
onContextMenu: HandlerFunction;
|
||||
setDataMask: SetDataMaskHook;
|
||||
emitCrossFilters?: boolean;
|
||||
};
|
||||
|
||||
export function getPoints(data: Point[]) {
|
||||
@@ -242,6 +259,7 @@ const DeckGLGeoJson = (props: DeckGLGeoJsonProps) => {
|
||||
onAddFilter,
|
||||
payload,
|
||||
formData,
|
||||
emitCrossFilters: props.emitCrossFilters,
|
||||
});
|
||||
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,7 @@ import sandboxedEval from '../../utils/sandbox';
|
||||
import { createDeckGLComponent, GetLayerType } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function setTooltipContent(o: JsonObject) {
|
||||
return (
|
||||
@@ -86,7 +87,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
|
||||
: aggFunc;
|
||||
|
||||
return new GridLayer({
|
||||
id: `grid-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}` as const,
|
||||
id: `grid-layer-${fd.slice_id}-${JSON.stringify(colorBreakpoints)}`,
|
||||
data,
|
||||
cellSize: fd.grid_size,
|
||||
extruded: fd.extruded,
|
||||
@@ -109,6 +110,7 @@ export const getLayer: GetLayerType<GridLayer> = function ({
|
||||
onContextMenu,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
opacity: filterState?.value ? 0.1 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -116,4 +118,43 @@ export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<GridLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
let data = payload.data.features;
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
// Applying user defined data mutator if defined
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
data = jsFnMutator(data);
|
||||
}
|
||||
|
||||
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
|
||||
|
||||
const selectedPointsSet = new Set(
|
||||
filterState?.value?.map((sp: [number, number]) => `${sp[0]},${sp[1]}`),
|
||||
);
|
||||
|
||||
const colorAggFunc = (p: JsonObject) =>
|
||||
selectedPointsSet.has(`${p.position[0]},${p.position[1]}`) ? 1 : 0;
|
||||
|
||||
return new GridLayer({
|
||||
id: `grid-highlight-layer-${fd.slice_id}-${JSON.stringify(filterState?.value)}`,
|
||||
data,
|
||||
cellSize: fd.grid_size,
|
||||
extruded: fd.extruded,
|
||||
colorDomain: [0, 1],
|
||||
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
|
||||
colorAggregation: 'MAX',
|
||||
outline: false,
|
||||
// @ts-ignore
|
||||
getElevationValue: aggFunc,
|
||||
getColorWeight: colorAggFunc,
|
||||
opacity: 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -19,10 +19,12 @@
|
||||
import { HeatmapLayer } from '@deck.gl/aggregation-layers';
|
||||
import { Position } from '@deck.gl/core';
|
||||
import { t, getSequentialSchemeRegistry, JsonObject } from '@superset-ui/core';
|
||||
import { isPointInBonds } from '../../utilities/utils';
|
||||
import { commonLayerProps, getColorRange } from '../common';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function setTooltipContent(o: JsonObject) {
|
||||
return (
|
||||
@@ -98,4 +100,49 @@ export function getPoints(data: any[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<HeatmapLayer> = ({
|
||||
formData,
|
||||
filterState,
|
||||
payload,
|
||||
}) => {
|
||||
const fd = formData;
|
||||
const {
|
||||
intensity = 1,
|
||||
radius_pixels: radiusPixels = 30,
|
||||
aggregation = 'SUM',
|
||||
js_data_mutator: jsFnMutator,
|
||||
} = fd;
|
||||
let data = payload.data.features;
|
||||
|
||||
if (jsFnMutator) {
|
||||
// Applying user defined data mutator if defined
|
||||
const jsFnMutatorFunction = sandboxedEval(fd.js_data_mutator);
|
||||
data = jsFnMutatorFunction(data);
|
||||
}
|
||||
|
||||
const dataInside = data.filter((d: JsonObject) =>
|
||||
isPointInBonds(d.position, filterState?.value),
|
||||
);
|
||||
|
||||
return new HeatmapLayer({
|
||||
id: `heatmap-layer-${fd.slice_id}` as const,
|
||||
data: dataInside,
|
||||
intensity,
|
||||
radiusPixels,
|
||||
colorRange: [
|
||||
[
|
||||
HIGHLIGHT_COLOR_ARRAY[0],
|
||||
HIGHLIGHT_COLOR_ARRAY[1],
|
||||
HIGHLIGHT_COLOR_ARRAY[2],
|
||||
55,
|
||||
],
|
||||
HIGHLIGHT_COLOR_ARRAY,
|
||||
],
|
||||
aggregation: aggregation.toUpperCase(),
|
||||
getPosition: (d: { position: Position; weight: number }) => d.position,
|
||||
getWeight: (d: { position: number[]; weight: number }) =>
|
||||
d.weight ? d.weight : 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function setTooltipContent(o: JsonObject) {
|
||||
return (
|
||||
@@ -108,6 +109,7 @@ export const getLayer: GetLayerType<HexagonLayer> = function ({
|
||||
onContextMenu,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
opacity: filterState?.value ? 0.3 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -115,4 +117,43 @@ export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<HexagonLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
let data = payload.data.features;
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
// Applying user defined data mutator if defined
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
data = jsFnMutator(data);
|
||||
}
|
||||
|
||||
const aggFunc = getAggFunc(fd.js_agg_function, p => p.weight);
|
||||
|
||||
const selectedPointsSet = new Set(
|
||||
filterState?.value?.map((sp: [number, number]) => `${sp[0]},${sp[1]}`),
|
||||
);
|
||||
|
||||
const colorAggFunc = (p: JsonObject) =>
|
||||
selectedPointsSet.has(`${p.position[0]},${p.position[1]}`) ? 1 : 0;
|
||||
|
||||
return new HexagonLayer({
|
||||
id: `hex-highlight-layer-${fd.slice_id}-${JSON.stringify(filterState?.value)}`,
|
||||
data,
|
||||
radius: fd.grid_size,
|
||||
extruded: fd.extruded,
|
||||
colorDomain: [0, 1],
|
||||
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
|
||||
colorAggregation: 'MAX',
|
||||
outline: false,
|
||||
// @ts-ignore
|
||||
getElevationValue: aggFunc,
|
||||
getColorWeight: colorAggFunc,
|
||||
opacity: 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -24,6 +24,7 @@ import sandboxedEval from '../../utils/sandbox';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { Point } from '../../types';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
function setTooltipContent(o: JsonObject) {
|
||||
return (
|
||||
@@ -83,6 +84,7 @@ export const getLayer: GetLayerType<PathLayer> = function ({
|
||||
onContextMenu,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
opacity: filterState?.value ? 0.3 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -95,4 +97,40 @@ export function getPoints(data: JsonObject[]) {
|
||||
return points;
|
||||
}
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<PathLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const fixedColor = HIGHLIGHT_COLOR_ARRAY;
|
||||
let data = payload.data.features.map((feature: JsonObject) => ({
|
||||
...feature,
|
||||
path: feature.path,
|
||||
width: fd.line_width,
|
||||
color: fixedColor,
|
||||
}));
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
data = jsFnMutator(data);
|
||||
}
|
||||
|
||||
const filteredData = data.filter(
|
||||
(d: JsonObject) =>
|
||||
JSON.stringify(d.path).replaceAll(' ', '') === filterState?.value[0],
|
||||
);
|
||||
|
||||
return new PathLayer({
|
||||
id: `path-highlight-layer-${fd.slice_id}` as const,
|
||||
getColor: () => HIGHLIGHT_COLOR_ARRAY,
|
||||
getPath: (d: any) => d.path,
|
||||
getWidth: (d: any) => d.width,
|
||||
data: filteredData,
|
||||
rounded: true,
|
||||
widthScale: 1,
|
||||
widthUnits: fd.line_width_unit,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -24,7 +24,6 @@
|
||||
import { memo, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
ContextMenuFilters,
|
||||
ensureIsArray,
|
||||
FilterState,
|
||||
HandlerFunction,
|
||||
JsonObject,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
getBuckets,
|
||||
getBreakPointColorScaler,
|
||||
getColorBreakpointsBuckets,
|
||||
TRANSPARENT_COLOR_ARRAY,
|
||||
} from '../../utils';
|
||||
|
||||
import { commonLayerProps, getColorForBreakpoints } from '../common';
|
||||
@@ -57,8 +57,7 @@ import { TooltipProps } from '../../components/Tooltip';
|
||||
import { GetLayerType } from '../../factory';
|
||||
import { COLOR_SCHEME_TYPES } from '../../utilities/utils';
|
||||
import { DEFAULT_DECKGL_COLOR } from '../../utilities/Shared_DeckGL';
|
||||
|
||||
const DOUBLE_CLICK_THRESHOLD = 250; // milliseconds
|
||||
import { Point } from '../../types';
|
||||
|
||||
function getElevation(
|
||||
d: JsonObject,
|
||||
@@ -110,7 +109,6 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
|
||||
setDataMask,
|
||||
onContextMenu,
|
||||
onSelect,
|
||||
selected,
|
||||
emitCrossFilters,
|
||||
}) {
|
||||
const fd = formData as PolygonFormData;
|
||||
@@ -181,15 +179,20 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
|
||||
}
|
||||
|
||||
// when polygons are selected, reduce the opacity of non-selected polygons
|
||||
const colorScaler = (d: JsonObject): [number, number, number, number] => {
|
||||
const baseColor = (baseColorScaler(d) as [
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
number,
|
||||
]) || [0, 0, 0, 0];
|
||||
if (!ensureIsArray(selected).includes(d[fd.line_column])) {
|
||||
baseColor[3] /= 2;
|
||||
const colorScaler = (d: {
|
||||
polygon: Point[];
|
||||
}): [number, number, number, number] => {
|
||||
const baseColor =
|
||||
(baseColorScaler(d) as [number, number, number, number]) ||
|
||||
TRANSPARENT_COLOR_ARRAY;
|
||||
const polygonPoints = getPointsFromPolygon(d);
|
||||
|
||||
const isPolygonFilterSelected =
|
||||
JSON.stringify(polygonPoints).replaceAll(' ', '') ===
|
||||
filterState?.value?.[0];
|
||||
|
||||
if (filterState?.value && !isPolygonFilterSelected) {
|
||||
baseColor[3] /= 3;
|
||||
}
|
||||
|
||||
return baseColor;
|
||||
@@ -216,6 +219,7 @@ export const getLayer: GetLayerType<PolygonLayer> = function ({
|
||||
getElevation: (d: JsonObject) => getElevation(d, colorScaler),
|
||||
elevationScale: fd.multiplier,
|
||||
fp64: true,
|
||||
opacity: fd.opacity ? fd.opacity / 100 : 1,
|
||||
...commonLayerProps({
|
||||
formData: fd,
|
||||
setTooltip,
|
||||
@@ -276,18 +280,14 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
return viewport;
|
||||
}, [props]);
|
||||
|
||||
const [lastClick, setLastClick] = useState(0);
|
||||
const [viewport, setViewport] = useState(getAdjustedViewport());
|
||||
const [stateFormData, setStateFormData] = useState(props.payload.form_data);
|
||||
const [selected, setSelected] = useState<JsonObject[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
const { payload } = props;
|
||||
|
||||
if (payload.form_data !== stateFormData) {
|
||||
setViewport(getAdjustedViewport());
|
||||
setSelected([]);
|
||||
setLastClick(0);
|
||||
setStateFormData(payload.form_data);
|
||||
}
|
||||
}, [getAdjustedViewport, props, stateFormData, viewport]);
|
||||
@@ -299,37 +299,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onSelect = useCallback(
|
||||
(polygon: JsonObject) => {
|
||||
const { formData, onAddFilter } = props;
|
||||
|
||||
const now = new Date().getDate();
|
||||
const doubleClick = now - lastClick <= DOUBLE_CLICK_THRESHOLD;
|
||||
|
||||
// toggle selected polygons
|
||||
const selectedCopy = [...selected];
|
||||
if (doubleClick) {
|
||||
selectedCopy.splice(0, selectedCopy.length, polygon);
|
||||
} else if (formData.toggle_polygons) {
|
||||
const i = selectedCopy.indexOf(polygon);
|
||||
if (i === -1) {
|
||||
selectedCopy.push(polygon);
|
||||
} else {
|
||||
selectedCopy.splice(i, 1);
|
||||
}
|
||||
} else {
|
||||
selectedCopy.splice(0, 1, polygon);
|
||||
}
|
||||
|
||||
setSelected(selectedCopy);
|
||||
setLastClick(now);
|
||||
if (formData.table_filter) {
|
||||
onAddFilter(formData.line_column, selected, false, true);
|
||||
}
|
||||
},
|
||||
[lastClick, props, selected],
|
||||
);
|
||||
|
||||
const getLayers = useCallback(() => {
|
||||
const {
|
||||
formData,
|
||||
@@ -350,8 +319,6 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
payload,
|
||||
onAddFilter,
|
||||
setTooltip,
|
||||
selected,
|
||||
onSelect,
|
||||
onContextMenu,
|
||||
setDataMask,
|
||||
filterState,
|
||||
@@ -359,7 +326,7 @@ const DeckGLPolygon = (props: DeckGLPolygonProps) => {
|
||||
});
|
||||
|
||||
return [layer];
|
||||
}, [onSelect, selected, setTooltip, props]);
|
||||
}, [setTooltip, props]);
|
||||
|
||||
const { payload, formData, setControlValue } = props;
|
||||
|
||||
|
||||
@@ -184,32 +184,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'table_filter',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Emit Filter Events'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t('Whether to apply filter when items are clicked'),
|
||||
},
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
name: 'toggle_polygons',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Multiple filtering'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
'Allow sending multiple polygons as a filter event',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
[legendPosition],
|
||||
[legendFormat],
|
||||
],
|
||||
|
||||
@@ -23,10 +23,12 @@ import {
|
||||
QueryFormData,
|
||||
t,
|
||||
} from '@superset-ui/core';
|
||||
import { isPointInBonds } from '../../utilities/utils';
|
||||
import { commonLayerProps } from '../common';
|
||||
import { createCategoricalDeckGLComponent, GetLayerType } from '../../factory';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { unitToRadius } from '../../utils/geo';
|
||||
import { HIGHLIGHT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
@@ -105,7 +107,43 @@ export const getLayer: GetLayerType<ScatterplotLayer> = function ({
|
||||
onContextMenu,
|
||||
emitCrossFilters,
|
||||
}),
|
||||
opacity: filterState?.value ? 0.3 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createCategoricalDeckGLComponent(getLayer, getPoints);
|
||||
export const getHighlightLayer: GetLayerType<ScatterplotLayer> = function ({
|
||||
formData,
|
||||
payload,
|
||||
filterState,
|
||||
}) {
|
||||
const fd = formData;
|
||||
const dataWithRadius = payload.data.features.map((d: JsonObject) => {
|
||||
let radius = unitToRadius(fd.point_unit, d.radius) || 10;
|
||||
if (fd.multiplier) {
|
||||
radius *= fd.multiplier;
|
||||
}
|
||||
|
||||
return { ...d, radius };
|
||||
});
|
||||
|
||||
const dataInside = dataWithRadius.filter((d: JsonObject) =>
|
||||
isPointInBonds(d.position, filterState?.value),
|
||||
);
|
||||
|
||||
return new ScatterplotLayer({
|
||||
id: `scatter-highlight-layer-${fd.slice_id}` as const,
|
||||
data: dataInside,
|
||||
fp64: true,
|
||||
getFillColor: () => HIGHLIGHT_COLOR_ARRAY,
|
||||
getRadius: (d: any) => d.radius,
|
||||
radiusMinPixels: Number(fd.min_radius) || undefined,
|
||||
radiusMaxPixels: Number(fd.max_radius) || undefined,
|
||||
stroked: false,
|
||||
});
|
||||
};
|
||||
|
||||
export default createCategoricalDeckGLComponent(
|
||||
getLayer,
|
||||
getPoints,
|
||||
getHighlightLayer,
|
||||
);
|
||||
|
||||
@@ -22,11 +22,16 @@
|
||||
import { ScreenGridLayer } from '@deck.gl/aggregation-layers';
|
||||
import { CategoricalColorNamespace, JsonObject, t } from '@superset-ui/core';
|
||||
import { Color } from '@deck.gl/core';
|
||||
import { COLOR_SCHEME_TYPES, ColorSchemeType } from '../../utilities/utils';
|
||||
import {
|
||||
COLOR_SCHEME_TYPES,
|
||||
ColorSchemeType,
|
||||
isPointInBonds,
|
||||
} from '../../utilities/utils';
|
||||
import sandboxedEval from '../../utils/sandbox';
|
||||
import { commonLayerProps, getColorRange } from '../common';
|
||||
import TooltipRow from '../../TooltipRow';
|
||||
import { GetLayerType, createDeckGLComponent } from '../../factory';
|
||||
import { HIGHLIGHT_COLOR_ARRAY, TRANSPARENT_COLOR_ARRAY } from '../../utils';
|
||||
|
||||
export function getPoints(data: JsonObject[]) {
|
||||
return data.map(d => d.position);
|
||||
@@ -113,7 +118,39 @@ export const getLayer: GetLayerType<ScreenGridLayer> = function ({
|
||||
}),
|
||||
getWeight: aggFunc,
|
||||
colorScaleType: colorSchemeType === 'default' ? 'linear' : 'quantize',
|
||||
opacity: filterState?.value ? 0.3 : 1,
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints);
|
||||
const getHighlightLayer: GetLayerType<ScreenGridLayer> = function ({
|
||||
formData,
|
||||
filterState,
|
||||
payload,
|
||||
}) {
|
||||
const fd = formData;
|
||||
let data = payload.data.features;
|
||||
|
||||
if (fd.js_data_mutator) {
|
||||
// Applying user defined data mutator if defined
|
||||
const jsFnMutator = sandboxedEval(fd.js_data_mutator);
|
||||
data = jsFnMutator(data);
|
||||
}
|
||||
const dataInside = data.filter((d: JsonObject) =>
|
||||
isPointInBonds(d.position, filterState?.value),
|
||||
);
|
||||
|
||||
const aggFunc = (d: JsonObject) => d.weight || 0;
|
||||
|
||||
return new ScreenGridLayer({
|
||||
id: `screengrid-highlight-layer-${formData.slice_id}` as const,
|
||||
data: dataInside,
|
||||
cellSizePixels: formData.grid_size,
|
||||
colorDomain: [0, 1],
|
||||
colorRange: [TRANSPARENT_COLOR_ARRAY, HIGHLIGHT_COLOR_ARRAY],
|
||||
outline: false,
|
||||
getWeight: aggFunc,
|
||||
colorScaleType: 'quantize',
|
||||
});
|
||||
};
|
||||
|
||||
export default createDeckGLComponent(getLayer, getPoints, getHighlightLayer);
|
||||
|
||||
@@ -116,6 +116,8 @@ export function commonLayerProps({
|
||||
drillBy: {},
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -38,3 +38,19 @@ export const isColorSchemeTypeVisible = (
|
||||
controls: ControlStateMapping,
|
||||
colorSchemeType: ColorSchemeType,
|
||||
) => controls.color_scheme_type?.value === colorSchemeType;
|
||||
|
||||
export const isPointInBonds = (
|
||||
position: [number, number],
|
||||
area: [[number, number], [number, number]],
|
||||
) => {
|
||||
const [lon, lat] = position;
|
||||
const fromLonLat = area[0];
|
||||
const toLatLon = area[1];
|
||||
|
||||
return (
|
||||
lon >= fromLonLat[0] &&
|
||||
lon <= toLatLon[0] &&
|
||||
lat >= fromLonLat[1] &&
|
||||
lat <= toLatLon[1]
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,9 @@ import { BitmapLayer, PathLayer } from '@deck.gl/layers';
|
||||
import { hexToRGB } from './utils/colors';
|
||||
import { ColorBreakpointType } from './types';
|
||||
|
||||
export const TRANSPARENT_COLOR_ARRAY = [0, 0, 0, 0] as Color;
|
||||
export const HIGHLIGHT_COLOR_ARRAY = [255, 0, 0, 255] as Color;
|
||||
|
||||
const DEFAULT_NUM_BUCKETS = 10;
|
||||
|
||||
export const MAPBOX_LAYER_PREFIX = 'mapbox://';
|
||||
@@ -121,7 +124,7 @@ export function getBreakPointColorScaler(
|
||||
: getSequentialSchemeRegistry().get(linearColorScheme);
|
||||
|
||||
if (!colorScheme) {
|
||||
return () => [0, 0, 0, 0];
|
||||
return () => TRANSPARENT_COLOR_ARRAY;
|
||||
}
|
||||
let scaler: ScaleLinear<string, string> | ScaleThreshold<number, string>;
|
||||
let maskPoint: (v: number | undefined) => boolean;
|
||||
@@ -160,7 +163,7 @@ export function getBreakPointColorScaler(
|
||||
return (d: JsonObject): Color => {
|
||||
const v = accessor(d);
|
||||
if (!v) {
|
||||
return [0, 0, 0, 0];
|
||||
return TRANSPARENT_COLOR_ARRAY;
|
||||
}
|
||||
const c = hexToRGB(scaler(v));
|
||||
if (maskPoint(v)) {
|
||||
|
||||
@@ -101,18 +101,21 @@ describe('getCrossFilterDataMask', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'LON',
|
||||
op: '==',
|
||||
val: -122.4205965,
|
||||
op: 'IN',
|
||||
val: [-122.4205965, -122.4215375],
|
||||
},
|
||||
{
|
||||
col: 'LAT',
|
||||
op: '==',
|
||||
val: 37.8054735,
|
||||
op: 'IN',
|
||||
val: [37.8054735, 37.8058583],
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
value: [-122.4205965, 37.8054735],
|
||||
value: [
|
||||
[-122.4205965, 37.8054735],
|
||||
[-122.4215375, 37.8058583],
|
||||
],
|
||||
customColumnLabel: 'LON, LAT',
|
||||
},
|
||||
},
|
||||
@@ -157,7 +160,12 @@ describe('getCrossFilterDataMask', () => {
|
||||
const dataMask = getCrossFilterDataMask({
|
||||
formData: latlongFormData,
|
||||
data: latlongPickingData,
|
||||
filterState: { value: [-122.4205965, 37.8054735] },
|
||||
filterState: {
|
||||
value: [
|
||||
[-122.4205965, 37.8054735],
|
||||
[-122.4215375, 37.8058583],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const expected = {
|
||||
@@ -213,13 +221,13 @@ describe('getCrossFilterDataMask', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'LONLAT',
|
||||
op: '==',
|
||||
val: `-122.4205965,37.8054735`,
|
||||
op: 'IN',
|
||||
val: [`-122.4205965,37.8054735`, `-122.4215375,37.8058583`],
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
value: [`-122.4205965,37.8054735`],
|
||||
value: [`-122.4205965,37.8054735`, `-122.4215375,37.8058583`],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: false,
|
||||
@@ -267,13 +275,13 @@ describe('getCrossFilterDataMask', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'LONLAT',
|
||||
op: '==',
|
||||
val: `37.8054735,-122.4205965`,
|
||||
op: 'IN',
|
||||
val: [`37.8054735,-122.4205965`, `37.8058583,-122.4215375`],
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
value: [`37.8054735,-122.4205965`],
|
||||
value: [`37.8054735,-122.4205965`, `37.8058583,-122.4215375`],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: false,
|
||||
@@ -316,8 +324,8 @@ describe('getCrossFilterDataMask', () => {
|
||||
filters: [
|
||||
{
|
||||
col: 'geohash',
|
||||
op: '==',
|
||||
val: `9q8zn620c751`,
|
||||
op: 'IN',
|
||||
val: [`9q8zn620c751`],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -78,10 +78,12 @@ export interface ValidatedPickingData {
|
||||
|
||||
const getFiltersBySpatialType = ({
|
||||
position,
|
||||
positions,
|
||||
positionBounds,
|
||||
spatialData,
|
||||
}: {
|
||||
position: [number, number];
|
||||
position?: [number, number];
|
||||
positions?: [number, number][];
|
||||
spatialData: SpatialData;
|
||||
positionBounds?: PositionBounds;
|
||||
}) => {
|
||||
@@ -98,7 +100,7 @@ const getFiltersBySpatialType = ({
|
||||
let filters: QueryObjectFilterClause[] = [];
|
||||
let customColumnLabel;
|
||||
|
||||
if (!position && !positionBounds)
|
||||
if (!position && !positions && !positionBounds)
|
||||
throw new Error('Position of picked data is required');
|
||||
|
||||
switch (type) {
|
||||
@@ -106,7 +108,23 @@ const getFiltersBySpatialType = ({
|
||||
if (lonCol != null && latCol != null) {
|
||||
const cols = [lonCol, latCol];
|
||||
|
||||
if (position) {
|
||||
if (positions && positions.length > 0) {
|
||||
values = positions;
|
||||
customColumnLabel = cols.join(', ');
|
||||
|
||||
filters = [
|
||||
{
|
||||
col: lonCol,
|
||||
op: 'IN',
|
||||
val: positions.map(pos => pos[0]),
|
||||
},
|
||||
{
|
||||
col: latCol,
|
||||
op: 'IN',
|
||||
val: positions.map(pos => pos[1]),
|
||||
},
|
||||
];
|
||||
} else if (position) {
|
||||
values = position;
|
||||
customColumnLabel = cols.join(', ');
|
||||
|
||||
@@ -152,19 +170,35 @@ const getFiltersBySpatialType = ({
|
||||
|
||||
if (!col) throw new Error('Column is required');
|
||||
|
||||
const val = (reverseCheckbox ? position.reverse() : position).join(
|
||||
delimiter,
|
||||
);
|
||||
if (positions && positions.length > 0) {
|
||||
const vals = positions.map(pos =>
|
||||
(reverseCheckbox ? [...pos].reverse() : pos).join(delimiter),
|
||||
);
|
||||
|
||||
values = [val];
|
||||
values = vals;
|
||||
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: '==',
|
||||
val,
|
||||
},
|
||||
];
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: 'IN',
|
||||
val: vals,
|
||||
},
|
||||
];
|
||||
} else if (position) {
|
||||
const val = (reverseCheckbox ? position.reverse() : position).join(
|
||||
delimiter,
|
||||
);
|
||||
|
||||
values = [val];
|
||||
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: '==',
|
||||
val,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -173,18 +207,35 @@ const getFiltersBySpatialType = ({
|
||||
|
||||
if (!col) throw new Error('Column is required');
|
||||
|
||||
const [lon, lat] = position;
|
||||
const val = ngeohash.encode(lat, lon, GEOHASH_PRECISION);
|
||||
if (positions && positions.length > 0) {
|
||||
const vals = positions.map(pos => {
|
||||
const [lon, lat] = pos;
|
||||
return ngeohash.encode(lat, lon, GEOHASH_PRECISION);
|
||||
});
|
||||
|
||||
values = [val];
|
||||
values = vals;
|
||||
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: '==',
|
||||
val,
|
||||
},
|
||||
];
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: 'IN',
|
||||
val: vals,
|
||||
},
|
||||
];
|
||||
} else if (position) {
|
||||
const [lon, lat] = position;
|
||||
const val = ngeohash.encode(lat, lon, GEOHASH_PRECISION);
|
||||
|
||||
values = [val];
|
||||
|
||||
filters = [
|
||||
{
|
||||
col,
|
||||
op: '==',
|
||||
val,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
@@ -280,31 +331,69 @@ const getStartEndSpatialFilters = ({
|
||||
};
|
||||
};
|
||||
|
||||
const isPointInBounds = (
|
||||
point: [number, number],
|
||||
bounds: PositionBounds,
|
||||
): boolean =>
|
||||
point[0] >= bounds.from[0] &&
|
||||
point[0] <= bounds.to[0] &&
|
||||
point[1] >= bounds.from[1] &&
|
||||
point[1] <= bounds.to[1];
|
||||
|
||||
const getSpatialFilters = ({
|
||||
formData,
|
||||
data,
|
||||
filterState,
|
||||
}: {
|
||||
formData: LayerFormData;
|
||||
data: PickingInfo;
|
||||
filterState?: FilterState;
|
||||
}): FilterResult => {
|
||||
const position = (data.object?.points?.[0]?.position ||
|
||||
data.object?.position) as [number, number];
|
||||
const positions = data.object?.points?.map(
|
||||
(point: { position: [number, number]; weight: number }) => point.position,
|
||||
) as [number, number][];
|
||||
|
||||
let positionBounds: PositionBounds | undefined;
|
||||
|
||||
if (!position && data.coordinate && data.viewport) {
|
||||
if (!positions && data.coordinate && data.viewport) {
|
||||
const pickedPositionBounds = calculatePickedPositionBounds({
|
||||
pickedCoordinates: data.coordinate,
|
||||
viewport: data.viewport,
|
||||
});
|
||||
|
||||
positionBounds = pickedPositionBounds;
|
||||
|
||||
if (filterState?.value && data.coordinate) {
|
||||
const currentFilterValues = filterState.value;
|
||||
if (
|
||||
Array.isArray(currentFilterValues) &&
|
||||
currentFilterValues.length === 2
|
||||
) {
|
||||
const currentBounds: PositionBounds = {
|
||||
from: currentFilterValues[0] as [number, number],
|
||||
to: currentFilterValues[1] as [number, number],
|
||||
};
|
||||
|
||||
const pickedPoint: [number, number] = [
|
||||
data.coordinate[0],
|
||||
data.coordinate[1],
|
||||
];
|
||||
|
||||
if (isPointInBounds(pickedPoint, currentBounds)) {
|
||||
return {
|
||||
filters: [],
|
||||
values: currentFilterValues,
|
||||
customColumnLabel: filterState.customColumnLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.spatial) throw new Error('Spatial data is required');
|
||||
|
||||
return getFiltersBySpatialType({
|
||||
position,
|
||||
positions,
|
||||
positionBounds,
|
||||
spatialData: formData.spatial,
|
||||
});
|
||||
@@ -353,7 +442,7 @@ const getGeojsonFilters = ({
|
||||
const val = `%${JSON.stringify(geometry)}%`;
|
||||
|
||||
return {
|
||||
values: [val],
|
||||
values: [geometry],
|
||||
filters: [
|
||||
{
|
||||
col: {
|
||||
@@ -385,7 +474,7 @@ export const getCrossFilterDataMask = ({
|
||||
const result = getStartEndSpatialFilters({ formData, data });
|
||||
({ values, filters, customColumnLabel } = result);
|
||||
} else if (formData.spatial?.type) {
|
||||
const result = getSpatialFilters({ formData, data });
|
||||
const result = getSpatialFilters({ formData, data, filterState });
|
||||
({ values, filters, customColumnLabel } = result);
|
||||
} else if (formData.line_column) {
|
||||
const result = getLineColumnFilters({ formData, data });
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
import { useState } from 'react';
|
||||
import { Dropdown, Menu } from 'antd';
|
||||
import { Dropdown } from 'antd';
|
||||
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import { t } from '@superset-ui/core';
|
||||
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
|
||||
@@ -69,34 +69,42 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
|
||||
return (
|
||||
<Dropdown
|
||||
placement="bottomRight"
|
||||
visible={showComparisonDropdown}
|
||||
onVisibleChange={(flag: boolean) => {
|
||||
open={showComparisonDropdown}
|
||||
onOpenChange={(flag: boolean) => {
|
||||
setShowComparisonDropdown(flag);
|
||||
}}
|
||||
overlay={
|
||||
<Menu
|
||||
multiple
|
||||
onClick={handleOnClick}
|
||||
onBlur={handleOnBlur}
|
||||
selectedKeys={selectedComparisonColumns}
|
||||
>
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</InfoText>
|
||||
{comparisonColumns.map((column: ComparisonColumn) => (
|
||||
<Menu.Item key={column.key}>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
menu={{
|
||||
multiple: true,
|
||||
onClick: handleOnClick,
|
||||
onBlur: handleOnBlur,
|
||||
selectedKeys: selectedComparisonColumns,
|
||||
items: [
|
||||
{
|
||||
key: 'all',
|
||||
label: (
|
||||
<InfoText>
|
||||
{t(
|
||||
'Select columns that will be displayed in the table. You can multiselect columns.',
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu>
|
||||
}
|
||||
</InfoText>
|
||||
),
|
||||
type: 'group',
|
||||
children: comparisonColumns.map((column: ComparisonColumn) => ({
|
||||
key: column.key,
|
||||
label: (
|
||||
<>
|
||||
<ColumnLabel>{column.label}</ColumnLabel>
|
||||
<CheckIconWrapper>
|
||||
{selectedComparisonColumns.includes(column.key) && (
|
||||
<CheckOutlined />
|
||||
)}
|
||||
</CheckIconWrapper>
|
||||
</>
|
||||
),
|
||||
})),
|
||||
},
|
||||
],
|
||||
}}
|
||||
trigger={['click']}
|
||||
>
|
||||
<span>
|
||||
|
||||
@@ -36,18 +36,19 @@ import {
|
||||
QueryModeLabel,
|
||||
sections,
|
||||
sharedControls,
|
||||
shouldSkipMetricColumn,
|
||||
isRegularMetric,
|
||||
isPercentMetric,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
GenericDataType,
|
||||
getMetricLabel,
|
||||
isAdhocColumn,
|
||||
isFeatureEnabled,
|
||||
isPhysicalColumn,
|
||||
legacyValidateInteger,
|
||||
QueryFormColumn,
|
||||
QueryFormMetric,
|
||||
QueryMode,
|
||||
SMART_DATE_ID,
|
||||
t,
|
||||
@@ -533,14 +534,25 @@ const config: ControlPanelConfig = {
|
||||
)
|
||||
.forEach((colname, index) => {
|
||||
if (
|
||||
explore.form_data.metrics?.some(
|
||||
metric => getMetricLabel(metric) === colname,
|
||||
) ||
|
||||
explore.form_data.percent_metrics?.some(
|
||||
(metric: QueryFormMetric) =>
|
||||
getMetricLabel(metric) === colname,
|
||||
)
|
||||
shouldSkipMetricColumn({
|
||||
colname,
|
||||
colnames,
|
||||
formData: explore.form_data,
|
||||
})
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isMetric = isRegularMetric(
|
||||
colname,
|
||||
explore.form_data,
|
||||
);
|
||||
const isPercentMetricValue = isPercentMetric(
|
||||
colname,
|
||||
explore.form_data,
|
||||
);
|
||||
|
||||
if (isMetric || isPercentMetricValue) {
|
||||
const comparisonColumns =
|
||||
generateComparisonColumns(colname);
|
||||
comparisonColumns.forEach((name, idx) => {
|
||||
@@ -589,7 +601,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show Cell bars'),
|
||||
label: t('Show cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -617,7 +629,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('add colors to cell bars for +/-'),
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -631,7 +643,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'comparison_color_enabled',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('basic conditional formatting'),
|
||||
label: t('Basic conditional formatting'),
|
||||
renderTrigger: true,
|
||||
visibility: ({ controls }) =>
|
||||
!isEmpty(controls?.time_compare?.value),
|
||||
@@ -672,7 +684,7 @@ const config: ControlPanelConfig = {
|
||||
config: {
|
||||
type: 'ConditionalFormattingControl',
|
||||
renderTrigger: true,
|
||||
label: t('Custom Conditional Formatting'),
|
||||
label: t('Custom conditional formatting'),
|
||||
extraColorChoices: [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
formatSelectOptionsForRange,
|
||||
getStandardizedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { xAxisLabelRotation } from '../controls';
|
||||
|
||||
const sortAxisChoices = [
|
||||
['alpha_asc', t('Axis ascending')],
|
||||
@@ -153,7 +154,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'xscale_interval',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('XScale Interval'),
|
||||
label: t('X-scale interval'),
|
||||
renderTrigger: true,
|
||||
choices: [[-1, t('Auto')]].concat(
|
||||
formatSelectOptionsForRange(1, 50),
|
||||
@@ -171,7 +172,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'yscale_interval',
|
||||
config: {
|
||||
type: 'SelectControl',
|
||||
label: t('YScale Interval'),
|
||||
label: t('Y-scale interval'),
|
||||
choices: [[-1, t('Auto')]].concat(
|
||||
formatSelectOptionsForRange(1, 50),
|
||||
),
|
||||
@@ -248,6 +249,7 @@ const config: ControlPanelConfig = {
|
||||
],
|
||||
['y_axis_format'],
|
||||
['x_axis_time_format'],
|
||||
[xAxisLabelRotation],
|
||||
['currency_format'],
|
||||
[
|
||||
{
|
||||
|
||||
@@ -99,6 +99,7 @@ export default function transformProps(
|
||||
valueBounds,
|
||||
yAxisFormat,
|
||||
xAxisTimeFormat,
|
||||
xAxisLabelRotation,
|
||||
currencyFormat,
|
||||
} = formData;
|
||||
const metricLabel = getMetricLabel(metric);
|
||||
@@ -251,6 +252,7 @@ export default function transformProps(
|
||||
axisLabel: {
|
||||
formatter: xAxisFormatter,
|
||||
interval: xscaleInterval === -1 ? 'auto' : xscaleInterval - 1,
|
||||
rotate: xAxisLabelRotation,
|
||||
},
|
||||
},
|
||||
yAxis: {
|
||||
|
||||
@@ -43,6 +43,7 @@ export interface HeatmapFormData extends QueryFormData {
|
||||
sortYAxis?: string;
|
||||
timeFormat?: string;
|
||||
xAxis: QueryFormColumn;
|
||||
xAxisLabelRotation: number;
|
||||
xscaleInterval: number;
|
||||
valueBounds: [number | undefined | null, number | undefined | null];
|
||||
yAxisFormat?: string;
|
||||
|
||||
@@ -358,7 +358,22 @@ const config: ControlPanelConfig = {
|
||||
['x_axis_time_format'],
|
||||
[xAxisLabelRotation],
|
||||
[xAxisLabelInterval],
|
||||
...richTooltipSection,
|
||||
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
|
||||
[
|
||||
{
|
||||
name: 'show_query_identifiers',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show query identifiers'),
|
||||
description: t(
|
||||
'Add Query A and Query B identifiers to tooltips to help differentiate series',
|
||||
),
|
||||
default: false,
|
||||
renderTrigger: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -212,6 +212,7 @@ export default function transformProps(
|
||||
sortSeriesAscendingB,
|
||||
timeGrainSqla,
|
||||
percentageThreshold,
|
||||
showQueryIdentifiers = false,
|
||||
metrics = [],
|
||||
metricsB = [],
|
||||
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
@@ -395,10 +396,17 @@ export default function transformProps(
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
const colorScaleKey = getOriginalSeries(seriesName, array);
|
||||
|
||||
let displayName = `${entryName} (Query A)`;
|
||||
let displayName: string;
|
||||
|
||||
if (groupby.length > 0) {
|
||||
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameA} (Query A)`
|
||||
: MetricDisplayNameA;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -453,10 +461,17 @@ export default function transformProps(
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName = `${entryName} (Query B)`;
|
||||
let displayName: string;
|
||||
|
||||
if (groupbyB.length > 0) {
|
||||
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
|
||||
// When we have groupby, format as "metric, dimension"
|
||||
const metricPart = showQueryIdentifiers
|
||||
? `${MetricDisplayNameB} (Query B)`
|
||||
: MetricDisplayNameB;
|
||||
displayName = `${metricPart}, ${entryName}`;
|
||||
} else {
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
}
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
@@ -696,14 +711,13 @@ export default function transformProps(
|
||||
zoomable,
|
||||
),
|
||||
// @ts-ignore
|
||||
data: rawSeriesA
|
||||
.concat(rawSeriesB)
|
||||
data: series
|
||||
.filter(
|
||||
entry =>
|
||||
extractForecastSeriesContext((entry.name || '') as string).type ===
|
||||
ForecastSeriesEnum.Observation,
|
||||
)
|
||||
.map(entry => entry.name || '')
|
||||
.map(entry => entry.id || entry.name || '')
|
||||
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
|
||||
@@ -60,6 +60,7 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
|
||||
tooltipTimeFormat?: string;
|
||||
zoomable: boolean;
|
||||
richTooltip: boolean;
|
||||
showQueryIdentifiers?: boolean;
|
||||
xAxisLabelRotation: number;
|
||||
xAxisLabelInterval?: number | string;
|
||||
colorScheme?: string;
|
||||
@@ -133,6 +134,7 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
|
||||
groupbyB: [],
|
||||
zoomable: TIMESERIES_DEFAULTS.zoomable,
|
||||
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
|
||||
showQueryIdentifiers: false,
|
||||
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
|
||||
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
|
||||
...DEFAULT_TITLE_FORM_DATA,
|
||||
|
||||
@@ -95,27 +95,27 @@ function getTotalValuePadding({
|
||||
top: donut ? 'middle' : '0',
|
||||
left: 'center',
|
||||
};
|
||||
const LEGEND_HEIGHT = 15;
|
||||
const LEGEND_WIDTH = 215;
|
||||
if (chartPadding.top) {
|
||||
padding.top = donut
|
||||
? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
: `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`;
|
||||
? `${50 + (chartPadding.top / height / 2) * 100}%`
|
||||
: `${(chartPadding.top / height) * 100}%`;
|
||||
}
|
||||
if (chartPadding.bottom) {
|
||||
padding.top = donut
|
||||
? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%`
|
||||
? `${50 - (chartPadding.bottom / height / 2) * 100}%`
|
||||
: '0';
|
||||
}
|
||||
if (chartPadding.left) {
|
||||
padding.left = `${
|
||||
50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
// When legend is on the left, shift text right to center it in the available space
|
||||
const leftPaddingPercent = (chartPadding.left / width) * 100;
|
||||
const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
}
|
||||
if (chartPadding.right) {
|
||||
padding.left = `${
|
||||
50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100
|
||||
}%`;
|
||||
// When legend is on the right, shift text left to center it in the available space
|
||||
const rightPaddingPercent = (chartPadding.right / width) * 100;
|
||||
const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75;
|
||||
padding.left = `${adjustedLeftPercent}%`;
|
||||
}
|
||||
return padding;
|
||||
}
|
||||
@@ -220,7 +220,7 @@ export default function transformProps(
|
||||
name: otherName,
|
||||
value: otherSum,
|
||||
itemStyle: {
|
||||
color: theme.colors.grayscale.dark1,
|
||||
color: theme.colorText,
|
||||
opacity:
|
||||
filterState.selectedValues &&
|
||||
!filterState.selectedValues.includes(otherName)
|
||||
@@ -368,7 +368,7 @@ export default function transformProps(
|
||||
const defaultLabel = {
|
||||
formatter,
|
||||
show: showLabels,
|
||||
color: theme.colors.grayscale.dark2,
|
||||
color: theme.colorText,
|
||||
};
|
||||
|
||||
const chartPadding = getChartPadding(
|
||||
@@ -403,7 +403,7 @@ export default function transformProps(
|
||||
label: {
|
||||
show: true,
|
||||
fontWeight: 'bold',
|
||||
backgroundColor: theme.colors.grayscale.light5,
|
||||
backgroundColor: theme.colorBgContainer,
|
||||
},
|
||||
},
|
||||
data: transformedData,
|
||||
@@ -445,6 +445,7 @@ export default function transformProps(
|
||||
text: t('Total: %s', numberFormatter(totalValue)),
|
||||
fontSize: 16,
|
||||
fontWeight: 'bold',
|
||||
fill: theme.colorText,
|
||||
},
|
||||
z: 10,
|
||||
}
|
||||
|
||||
@@ -39,7 +39,6 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
width,
|
||||
echartOptions,
|
||||
setDataMask,
|
||||
labelMap,
|
||||
selectedValues,
|
||||
formData,
|
||||
onContextMenu,
|
||||
@@ -52,45 +51,47 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
const getCrossFilterDataMask = useCallback(
|
||||
(treePathInfo: TreePathInfo[]) => {
|
||||
const treePath = extractTreePathInfo(treePathInfo);
|
||||
const name = treePath.join(',');
|
||||
const selected = Object.values(selectedValues);
|
||||
let values: string[];
|
||||
if (selected.includes(name)) {
|
||||
values = selected.filter(v => v !== name);
|
||||
} else {
|
||||
values = [name];
|
||||
const joinedTreePath = treePath.join(',');
|
||||
const value = treePath[treePath.length - 1];
|
||||
|
||||
const isCurrentValueSelected =
|
||||
Object.values(selectedValues).includes(joinedTreePath);
|
||||
|
||||
if (!columns?.length || isCurrentValueSelected) {
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters: [],
|
||||
},
|
||||
filterState: {
|
||||
value: null,
|
||||
selectedValues: [],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
}
|
||||
const labels = values.map(value => labelMap[value]);
|
||||
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
filters:
|
||||
values.length === 0 || !columns
|
||||
? []
|
||||
: columns.slice(0, treePath.length).map((col, idx) => {
|
||||
const val = labels.map(v => v[idx]);
|
||||
if (val === null || val === undefined)
|
||||
return {
|
||||
col,
|
||||
op: 'IS NULL' as const,
|
||||
};
|
||||
return {
|
||||
col,
|
||||
op: 'IN' as const,
|
||||
val: val as (string | number | boolean)[],
|
||||
};
|
||||
}),
|
||||
filters: [
|
||||
{
|
||||
col: columns[treePath.length - 1],
|
||||
op: '==' as const,
|
||||
val: value,
|
||||
},
|
||||
],
|
||||
},
|
||||
filterState: {
|
||||
value: labels.length ? labels : null,
|
||||
selectedValues: values.length ? values : null,
|
||||
value,
|
||||
selectedValues: [joinedTreePath],
|
||||
},
|
||||
},
|
||||
isCurrentValueSelected: selected.includes(name),
|
||||
isCurrentValueSelected,
|
||||
};
|
||||
},
|
||||
[columns, labelMap, selectedValues],
|
||||
[columns, selectedValues],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
@@ -101,7 +102,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
|
||||
|
||||
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
|
||||
},
|
||||
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
|
||||
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
|
||||
);
|
||||
|
||||
const eventHandlers: EventHandlers = {
|
||||
|
||||
@@ -71,6 +71,7 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
|
||||
seriesType: EchartsTimeseriesSeriesType.Line,
|
||||
stack: false,
|
||||
tooltipTimeFormat: 'smart_date',
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
truncateXAxis: true,
|
||||
truncateYAxis: false,
|
||||
yAxisBounds: [null, null],
|
||||
|
||||
@@ -174,6 +174,8 @@ function Echart(
|
||||
if (!chartRef.current) {
|
||||
chartRef.current = init(divRef.current, null, { locale });
|
||||
}
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
setDidMount(true);
|
||||
});
|
||||
}, [locale]);
|
||||
@@ -235,9 +237,6 @@ function Echart(
|
||||
echartOptions,
|
||||
);
|
||||
chartRef.current?.setOption(themedEchartOptions, true);
|
||||
|
||||
// did mount
|
||||
handleSizeChange({ width, height });
|
||||
}
|
||||
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme]);
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ export class EchartsChartPlugin<
|
||||
super({
|
||||
...restProps,
|
||||
metadata: new ChartMetadata({
|
||||
parseMethod: 'json-bigint',
|
||||
parseMethod: 'json',
|
||||
...metadata,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -116,49 +116,48 @@ const chartPropsConfig = {
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: false,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect(transformed).toEqual(
|
||||
expect.objectContaining({
|
||||
echartOptions: expect.objectContaining({
|
||||
series: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query A), boy',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query A), girl',
|
||||
stack: 'obs\na',
|
||||
}),
|
||||
// Query B — Bar series
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 1],
|
||||
[599916000000, 3],
|
||||
],
|
||||
id: 'sum__num (Query B), boy',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
data: [
|
||||
[599616000000, 2],
|
||||
[599916000000, 4],
|
||||
],
|
||||
id: 'sum__num (Query B), girl',
|
||||
stack: 'obs\nb',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
// Check that series IDs don't include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num, girl');
|
||||
expect(seriesIds).toContain('sum__num, boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), boy');
|
||||
});
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||
const chartPropsConfigWithIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
// Check that series IDs include query identifiers
|
||||
const seriesIds = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.id,
|
||||
);
|
||||
expect(seriesIds).toContain('sum__num (Query A), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).toContain('sum__num (Query B), boy');
|
||||
expect(seriesIds).not.toContain('sum__num, girl');
|
||||
expect(seriesIds).not.toContain('sum__num, boy');
|
||||
});
|
||||
|
||||
@@ -221,6 +221,157 @@ describe('Pie label string template', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Total value positioning with legends', () => {
|
||||
const getChartPropsWithLegend = (
|
||||
showTotal = true,
|
||||
showLegend = true,
|
||||
legendOrientation = 'right',
|
||||
donut = true,
|
||||
): EchartsPieChartProps => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['category'],
|
||||
viz_type: 'pie',
|
||||
show_total: showTotal,
|
||||
show_legend: showLegend,
|
||||
legend_orientation: legendOrientation,
|
||||
donut,
|
||||
};
|
||||
|
||||
return new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
|
||||
{ category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
}) as EchartsPieChartProps;
|
||||
};
|
||||
|
||||
it('should center total text when legend is on the right', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
style: expect.objectContaining({
|
||||
text: expect.stringContaining('Total:'),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be less than 50% (shifted left)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeLessThan(50);
|
||||
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on the left', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'left', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
|
||||
// The left position should be greater than 50% (shifted right)
|
||||
const leftValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).left.replace('%', ''),
|
||||
);
|
||||
expect(leftValue).toBeGreaterThan(50);
|
||||
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
|
||||
});
|
||||
|
||||
it('should center total text when legend is on top', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'top', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for top legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
|
||||
});
|
||||
|
||||
it('should center total text when legend is on bottom', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'bottom', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
|
||||
}),
|
||||
);
|
||||
|
||||
// The top position should be adjusted for bottom legend
|
||||
const topValue = parseFloat(
|
||||
(transformed.echartOptions.graphic as any).top.replace('%', ''),
|
||||
);
|
||||
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
|
||||
});
|
||||
|
||||
it('should use default positioning when no legend is shown', () => {
|
||||
const props = getChartPropsWithLegend(true, false, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
left: 'center',
|
||||
top: 'middle',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle regular pie chart (non-donut) positioning', () => {
|
||||
const props = getChartPropsWithLegend(true, true, 'right', false);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toEqual(
|
||||
expect.objectContaining({
|
||||
type: 'text',
|
||||
top: '0', // Non-donut charts use '0' as default top position
|
||||
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show total graphic when showTotal is false', () => {
|
||||
const props = getChartPropsWithLegend(false, true, 'right', true);
|
||||
const transformed = transformProps(props);
|
||||
|
||||
expect(transformed.echartOptions.graphic).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Other category', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel';
|
||||
|
||||
describe('Bar Chart Control Panel', () => {
|
||||
describe('x_axis_time_format control', () => {
|
||||
it('should include x_axis_time_format control in the panel', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Look for x_axis_time_format control in all sections and rows
|
||||
let foundTimeFormatControl = false;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
foundTimeFormatControl = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
if (foundTimeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(foundTimeFormatControl).toBe(true);
|
||||
});
|
||||
|
||||
it('should have correct default value for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toBeDefined();
|
||||
expect(timeFormatControl.config.default).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have visibility function for x_axis_time_format', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config.visibility).toBeDefined();
|
||||
expect(typeof timeFormatControl.config.visibility).toBe('function');
|
||||
|
||||
// The visibility function exists - the exact logic is tested implicitly through UI behavior
|
||||
// The important part is that the control has proper visibility configuration
|
||||
});
|
||||
|
||||
it('should have proper control configuration', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
// Find the x_axis_time_format control
|
||||
let timeFormatControl: any = null;
|
||||
|
||||
for (const section of config.controlPanelSections) {
|
||||
if (section && section.controlSetRows) {
|
||||
for (const row of section.controlSetRows) {
|
||||
for (const control of row) {
|
||||
if (
|
||||
typeof control === 'object' &&
|
||||
control !== null &&
|
||||
'name' in control &&
|
||||
control.name === 'x_axis_time_format'
|
||||
) {
|
||||
timeFormatControl = control;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
if (timeFormatControl) break;
|
||||
}
|
||||
}
|
||||
|
||||
expect(timeFormatControl).toBeDefined();
|
||||
expect(timeFormatControl.config).toMatchObject({
|
||||
default: 'smart_date',
|
||||
disableStash: true,
|
||||
resetOnHide: false,
|
||||
});
|
||||
|
||||
// Should have a description that includes D3 time format docs
|
||||
expect(timeFormatControl.config.description).toContain('D3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Control panel structure for bar charts', () => {
|
||||
it('should have Chart Orientation section', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const orientationSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Orientation',
|
||||
);
|
||||
|
||||
expect(orientationSection).toBeDefined();
|
||||
expect(orientationSection!.expanded).toBe(true);
|
||||
});
|
||||
|
||||
it('should have Chart Options section with X Axis controls', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
const chartOptionsSection = config.controlPanelSections.find(
|
||||
section => section && section.label === 'Chart Options',
|
||||
);
|
||||
|
||||
expect(chartOptionsSection).toBeDefined();
|
||||
expect(chartOptionsSection!.expanded).toBe(true);
|
||||
|
||||
// Should contain X Axis subsection header - this is sufficient proof
|
||||
expect(chartOptionsSection!.controlSetRows).toBeDefined();
|
||||
expect(chartOptionsSection!.controlSetRows!.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have proper form data overrides', () => {
|
||||
const config = controlPanel;
|
||||
|
||||
expect(config.formDataOverrides).toBeDefined();
|
||||
expect(typeof config.formDataOverrides).toBe('function');
|
||||
|
||||
// Test the form data override function
|
||||
const mockFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
metrics: ['test_metric'],
|
||||
groupby: ['test_column'],
|
||||
other_field: 'test',
|
||||
};
|
||||
|
||||
const result = config.formDataOverrides!(mockFormData);
|
||||
|
||||
expect(result).toHaveProperty('metrics');
|
||||
expect(result).toHaveProperty('groupby');
|
||||
expect(result).toHaveProperty('other_field', 'test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,353 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ChartProps, SqlaFormData, supersetTheme } from '@superset-ui/core';
|
||||
import { EchartsTimeseriesChartProps } from '../../../src/types';
|
||||
import transformProps from '../../../src/Timeseries/transformProps';
|
||||
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
|
||||
import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
|
||||
|
||||
describe('Bar Chart X-axis Time Formatting', () => {
|
||||
const baseFormData: SqlaFormData = {
|
||||
...DEFAULT_FORM_DATA,
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: '__timestamp',
|
||||
metric: ['Sales', 'Marketing', 'Operations'],
|
||||
groupby: [],
|
||||
viz_type: 'echarts_timeseries_bar',
|
||||
seriesType: EchartsTimeseriesSeriesType.Bar,
|
||||
orientation: 'vertical',
|
||||
};
|
||||
|
||||
const timeseriesData = [
|
||||
{
|
||||
data: [
|
||||
{ Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01
|
||||
{ Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01
|
||||
{ Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01
|
||||
],
|
||||
colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'],
|
||||
coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'],
|
||||
},
|
||||
];
|
||||
|
||||
const baseChartPropsConfig = {
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: timeseriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('Default xAxisTimeFormat', () => {
|
||||
it('should use smart_date as default xAxisTimeFormat', () => {
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Check that the x-axis has a formatter applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => {
|
||||
const formDataWithoutTimeFormat = {
|
||||
...baseFormData,
|
||||
};
|
||||
delete formDataWithoutTimeFormat.xAxisTimeFormat;
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithoutTimeFormat,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Custom xAxisTimeFormat', () => {
|
||||
it('should respect custom xAxisTimeFormat when explicitly set', () => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify the formatter function exists and is applied
|
||||
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
|
||||
// The key test is that a formatter exists - the actual formatting is handled by d3-time-format
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should handle different time format options', () => {
|
||||
const timeFormats = [
|
||||
'%Y-%m-%d',
|
||||
'%Y/%m/%d',
|
||||
'%m/%d/%Y',
|
||||
'%b %d, %Y',
|
||||
'smart_date',
|
||||
];
|
||||
|
||||
timeFormats.forEach(timeFormat => {
|
||||
const customFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: timeFormat,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Orientation-specific behavior', () => {
|
||||
it('should apply time formatting to x-axis in vertical bar charts', () => {
|
||||
const verticalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'vertical',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: verticalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In vertical orientation, time should be on x-axis
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof xAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should apply time formatting to y-axis in horizontal bar charts', () => {
|
||||
const horizontalFormData = {
|
||||
...baseFormData,
|
||||
orientation: 'horizontal',
|
||||
xAxisTimeFormat: '%Y-%m',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: horizontalFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// In horizontal orientation, axes are swapped, so time should be on y-axis
|
||||
const yAxis = transformedProps.echartOptions.yAxis as any;
|
||||
expect(yAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(typeof yAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Integration with existing features', () => {
|
||||
it('should work with axis bounds', () => {
|
||||
const formDataWithBounds = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
truncateXAxis: true,
|
||||
xAxisBounds: [null, null] as [number | null, number | null],
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithBounds,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
// The xAxis should be configured with the time formatting
|
||||
expect(transformedProps.echartOptions.xAxis).toBeDefined();
|
||||
});
|
||||
|
||||
it('should work with label rotation', () => {
|
||||
const formDataWithRotation = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
xAxisLabelRotation: 45,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithRotation,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(xAxis.axisLabel).toHaveProperty('rotate', 45);
|
||||
});
|
||||
|
||||
it('should maintain time formatting consistency with tooltip', () => {
|
||||
const formDataWithTooltip = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d',
|
||||
tooltipTimeFormat: '%Y-%m-%d',
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: formDataWithTooltip,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both axis and tooltip should have formatters
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
expect(xAxis.axisLabel).toHaveProperty('formatter');
|
||||
expect(transformedProps.xValueFormatter).toBeDefined();
|
||||
expect(typeof transformedProps.xValueFormatter).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Regression test for Issue #30373', () => {
|
||||
it('should not be stuck on adaptive formatting', () => {
|
||||
// Test the exact scenario described in the issue
|
||||
const issueFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: issueFormData,
|
||||
});
|
||||
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used
|
||||
const xAxis = transformedProps.echartOptions.xAxis as any;
|
||||
const { formatter } = xAxis.axisLabel;
|
||||
|
||||
expect(formatter).toBeDefined();
|
||||
expect(typeof formatter).toBe('function');
|
||||
|
||||
// The important part is that the xAxisTimeFormat is being used from formData
|
||||
// The actual formatting is handled by the underlying time formatter
|
||||
});
|
||||
|
||||
it('should allow changing from smart_date to other formats', () => {
|
||||
// First create with smart_date (default)
|
||||
const smartDateFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: 'smart_date',
|
||||
};
|
||||
|
||||
const smartDateChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: smartDateFormData,
|
||||
});
|
||||
|
||||
const smartDateProps = transformProps(
|
||||
smartDateChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Then change to a different format
|
||||
const customFormatFormData = {
|
||||
...baseFormData,
|
||||
xAxisTimeFormat: '%b %Y',
|
||||
};
|
||||
|
||||
const customFormatChartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: customFormatFormData,
|
||||
});
|
||||
|
||||
const customFormatProps = transformProps(
|
||||
customFormatChartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
// Both should have formatters - the key is that they're not undefined
|
||||
const smartDateXAxis = smartDateProps.echartOptions.xAxis as any;
|
||||
const customFormatXAxis = customFormatProps.echartOptions.xAxis as any;
|
||||
|
||||
expect(smartDateXAxis.axisLabel.formatter).toBeDefined();
|
||||
expect(customFormatXAxis.axisLabel.formatter).toBeDefined();
|
||||
|
||||
// Both should be functions that can format time
|
||||
expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function');
|
||||
expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function');
|
||||
});
|
||||
|
||||
it('should have xAxisTimeFormat in formData by default', () => {
|
||||
// This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat
|
||||
const chartProps = new ChartProps({
|
||||
...baseChartPropsConfig,
|
||||
formData: baseFormData,
|
||||
});
|
||||
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBeDefined();
|
||||
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
|
||||
|
||||
describe('Timeseries constants', () => {
|
||||
describe('DEFAULT_FORM_DATA', () => {
|
||||
it('should include xAxisTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('xAxisTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should include tooltipTimeFormat in default form data', () => {
|
||||
expect(DEFAULT_FORM_DATA).toHaveProperty('tooltipTimeFormat');
|
||||
expect(DEFAULT_FORM_DATA.tooltipTimeFormat).toBe('smart_date');
|
||||
});
|
||||
|
||||
it('should have consistent time format defaults', () => {
|
||||
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe(
|
||||
DEFAULT_FORM_DATA.tooltipTimeFormat,
|
||||
);
|
||||
});
|
||||
|
||||
it('should have vertical orientation as default', () => {
|
||||
expect(DEFAULT_FORM_DATA.orientation).toBe('vertical');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,6 +125,6 @@ test('@superset-ui/plugin-chart-echarts-parsemethod-validation', () => {
|
||||
];
|
||||
|
||||
plugins.forEach(plugin => {
|
||||
expect(plugin.metadata.parseMethod).toEqual('json-bigint');
|
||||
expect(plugin.metadata.parseMethod).toEqual('json');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -44,6 +44,6 @@
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/lodash": "^4.17.20",
|
||||
"jest": "^30.0.4"
|
||||
"jest": "^30.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user