Compare commits

..

1 Commits

Author SHA1 Message Date
GitHub Action
f3fb775a17 chore(🦾): bump python python-dotenv 1.1.0 -> 1.1.1 2025-07-28 19:12:51 +00:00
345 changed files with 9042 additions and 16376 deletions

View File

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

View File

@@ -1,16 +0,0 @@
# Superset Development with GitHub Codespaces
For complete documentation on using GitHub Codespaces with Apache Superset, please see:
**[Setting up a Development Environment - GitHub Codespaces](https://superset.apache.org/docs/contributing/development#github-codespaces-cloud-development)**
## Pre-installed Development Environment
When you create a new Codespace from this repository, it automatically:
1. **Creates a Python virtual environment** using `uv venv`
2. **Installs all development dependencies** via `uv pip install -r requirements/development.txt`
3. **Sets up pre-commit hooks** with `pre-commit install`
4. **Activates the virtual environment** automatically in all terminals
The virtual environment is located at `/workspaces/{repository-name}/.venv` and is automatically activated through environment variables set in the devcontainer configuration.

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
{
"name": "Apache Superset Development",
// Option 1: Use pre-built image directly
// "image": "ghcr.io/apache/superset:devcontainer-base",
// Option 2: Build from Dockerfile with cache (current approach)
"build": {
"dockerfile": "Dockerfile",
"context": ".",
// Cache from the Apache registry image
"cacheFrom": ["ghcr.io/apache/superset:devcontainer-base"]
},
"features": {
"ghcr.io/devcontainers/features/docker-in-docker:2": {
"moby": true,
"dockerDashComposeVersion": "v2"
},
"ghcr.io/devcontainers/features/node:1": {
"version": "20"
},
"ghcr.io/devcontainers/features/git:1": {},
"ghcr.io/devcontainers/features/common-utils:2": {
"configureZshAsDefaultShell": true
},
"ghcr.io/devcontainers/features/sshd:1": {
"version": "latest"
}
},
// Forward ports for development
"forwardPorts": [9001],
"portsAttributes": {
"9001": {
"label": "Superset (via Webpack Dev Server)",
"onAutoForward": "notify",
"visibility": "public"
}
},
// Run commands after container is created
"postCreateCommand": "bash .devcontainer/setup-dev.sh || echo '⚠️ Setup had issues - run .devcontainer/setup-dev.sh manually'",
// Auto-start Superset after ensuring Docker is ready
// Run in foreground to see any errors, but don't block on failures
"postStartCommand": "bash -c 'echo \"Waiting 30s for services to initialize...\"; sleep 30; .devcontainer/start-superset.sh || echo \"⚠️ Auto-start failed - run start-superset manually\"'",
// Set environment variables
"remoteEnv": {
// Removed automatic venv activation to prevent startup issues
// The setup script will handle this
},
// VS Code customizations
"customizations": {
"vscode": {
"extensions": [
"ms-python.python",
"ms-python.vscode-pylance",
"charliermarsh.ruff",
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode"
]
}
}
}

View File

@@ -1,78 +0,0 @@
#!/bin/bash
# Setup script for Superset Codespaces development environment
echo "🔧 Setting up Superset development environment..."
# System dependencies and uv are now pre-installed in the Docker image
# This speeds up Codespace creation significantly!
# Create virtual environment using uv
echo "🐍 Creating Python virtual environment..."
if ! uv venv; then
echo "❌ Failed to create virtual environment"
exit 1
fi
# Install Python dependencies
echo "📦 Installing Python dependencies..."
if ! uv pip install -r requirements/development.txt; then
echo "❌ Failed to install Python dependencies"
echo "💡 You may need to run this manually after the Codespace starts"
exit 1
fi
# Install pre-commit hooks
echo "🪝 Installing pre-commit hooks..."
if source .venv/bin/activate && pre-commit install; then
echo "✅ Pre-commit hooks installed"
else
echo "⚠️ Pre-commit hooks installation failed (non-critical)"
fi
# Install Claude Code CLI via npm
echo "🤖 Installing Claude Code..."
if npm install -g @anthropic-ai/claude-code; then
echo "✅ Claude Code installed"
else
echo "⚠️ Claude Code installation failed (non-critical)"
fi
# Make the start script executable
chmod +x .devcontainer/start-superset.sh
# Add bashrc additions for automatic venv activation
echo "🔧 Setting up automatic environment activation..."
if [ -f ~/.bashrc ]; then
# Check if we've already added our additions
if ! grep -q "Superset Codespaces environment setup" ~/.bashrc; then
echo "" >> ~/.bashrc
cat .devcontainer/bashrc-additions >> ~/.bashrc
echo "✅ Added automatic venv activation to ~/.bashrc"
else
echo "✅ Bashrc additions already present"
fi
else
# Create bashrc if it doesn't exist
cat .devcontainer/bashrc-additions > ~/.bashrc
echo "✅ Created ~/.bashrc with automatic venv activation"
fi
# Also add to zshrc since that's the default shell
if [ -f ~/.zshrc ] || [ -n "$ZSH_VERSION" ]; then
if ! grep -q "Superset Codespaces environment setup" ~/.zshrc; then
echo "" >> ~/.zshrc
cat .devcontainer/bashrc-additions >> ~/.zshrc
echo "✅ Added automatic venv activation to ~/.zshrc"
fi
fi
echo "✅ Development environment setup complete!"
echo ""
echo "📝 The virtual environment will be automatically activated in new terminals"
echo ""
echo "🔄 To activate in this terminal, run:"
echo " source ~/.bashrc"
echo ""
echo "🚀 To start Superset:"
echo " start-superset"
echo ""

View File

@@ -1,108 +0,0 @@
#!/bin/bash
# Startup script for Superset in Codespaces
# Log to a file for debugging
LOG_FILE="/tmp/superset-startup.log"
echo "[$(date)] Starting Superset startup script" >> "$LOG_FILE"
echo "[$(date)] User: $(whoami), PWD: $(pwd)" >> "$LOG_FILE"
echo "🚀 Starting Superset in Codespaces..."
echo "🌐 Frontend will be available at port 9001"
# Find the workspace directory (Codespaces clones as 'superset', not 'superset-2')
WORKSPACE_DIR=$(find /workspaces -maxdepth 1 -name "superset*" -type d | head -1)
if [ -n "$WORKSPACE_DIR" ]; then
cd "$WORKSPACE_DIR"
echo "📁 Working in: $WORKSPACE_DIR"
else
echo "📁 Using current directory: $(pwd)"
fi
# Wait for Docker to be available
echo "⏳ Waiting for Docker to start..."
echo "[$(date)] Waiting for Docker..." >> "$LOG_FILE"
max_attempts=30
attempt=0
while ! docker info > /dev/null 2>&1; do
if [ $attempt -eq $max_attempts ]; then
echo "❌ Docker failed to start after $max_attempts attempts"
echo "[$(date)] Docker failed to start after $max_attempts attempts" >> "$LOG_FILE"
echo "🔄 Please restart the Codespace or run this script manually later"
exit 1
fi
echo " Attempt $((attempt + 1))/$max_attempts..."
echo "[$(date)] Docker check attempt $((attempt + 1))/$max_attempts" >> "$LOG_FILE"
sleep 2
attempt=$((attempt + 1))
done
echo "✅ Docker is ready!"
echo "[$(date)] Docker is ready" >> "$LOG_FILE"
# Check if Superset containers are already running
if docker ps | grep -q "superset"; then
echo "✅ Superset containers are already running!"
echo ""
echo "🌐 To access Superset:"
echo " 1. Click the 'Ports' tab at the bottom of VS Code"
echo " 2. Find port 9001 and click the globe icon to open"
echo " 3. Wait 10-20 minutes for initial startup"
echo ""
echo "📝 Login credentials: admin/admin"
exit 0
fi
# Clean up any existing containers
echo "🧹 Cleaning up existing containers..."
docker-compose -f docker-compose-light.yml down
# Start services
echo "🏗️ Starting Superset in background (daemon mode)..."
echo ""
# Start in detached mode
docker-compose -f docker-compose-light.yml up -d
echo ""
echo "✅ Docker Compose started successfully!"
echo ""
echo "📋 Important information:"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "⏱️ Initial startup takes 10-20 minutes"
echo "🌐 Check the 'Ports' tab for your Superset URL (port 9001)"
echo "👤 Login: admin / admin"
echo ""
echo "📊 Useful commands:"
echo " docker-compose -f docker-compose-light.yml logs -f # Follow logs"
echo " docker-compose -f docker-compose-light.yml ps # Check status"
echo " docker-compose -f docker-compose-light.yml down # Stop services"
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo ""
echo "💤 Keeping terminal open for 60 seconds to test persistence..."
sleep 60
echo "✅ Test complete - check if this terminal is still visible!"
# Show final status
docker-compose -f docker-compose-light.yml ps
EXIT_CODE=$?
# If it failed, provide helpful instructions
if [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 130 ]; then # 130 is Ctrl+C
echo ""
echo "❌ Superset startup failed (exit code: $EXIT_CODE)"
echo ""
echo "🔄 To restart Superset, run:"
echo " .devcontainer/start-superset.sh"
echo ""
echo "🔧 For troubleshooting:"
echo " # View logs:"
echo " docker-compose -f docker-compose-light.yml logs"
echo ""
echo " # Clean restart (removes volumes):"
echo " docker-compose -f docker-compose-light.yml down -v"
echo " .devcontainer/start-superset.sh"
echo ""
echo " # Common issues:"
echo " - Network timeouts: Just retry, often transient"
echo " - Port conflicts: Check 'docker ps'"
echo " - Database issues: Try clean restart with -v"
fi

View File

@@ -12,7 +12,7 @@ jobs:
steps:
- name: Welcome Message
uses: actions/first-interaction@v2
uses: actions/first-interaction@v1
continue-on-error: true
with:
repo-token: ${{ github.token }}

View File

@@ -59,7 +59,7 @@ RUN mkdir -p /app/superset/static/assets \
# NOTE: we mount packages and plugins as they are referenced in package.json as workspaces
# ideally we'd COPY only their package.json. Here npm ci will be cached as long
# as the full content of these folders don't change, yielding a decent cache reuse rate.
# Note that it's not possible to selectively COPY or mount using blobs.
# Note that's it's not possible selectively COPY of mount using blobs.
RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.json \
--mount=type=bind,source=./superset-frontend/package-lock.json,target=./package-lock.json \
--mount=type=cache,target=/root/.cache \
@@ -74,7 +74,7 @@ RUN --mount=type=bind,source=./superset-frontend/package.json,target=./package.j
COPY superset-frontend /app/superset-frontend
######################################################################
# superset-node is used for compiling frontend assets
# superset-node used for compile frontend assets
######################################################################
FROM superset-node-ci AS superset-node
@@ -90,7 +90,7 @@ RUN --mount=type=cache,target=/root/.npm \
# Copy translation files
COPY superset/translations /app/superset/translations
# Build translations if enabled, then cleanup localization files
# Build the frontend if not in dev mode
RUN if [ "$BUILD_TRANSLATIONS" = "true" ]; then \
npm run build-translation; \
fi; \

View File

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

View File

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

View File

@@ -120,78 +120,6 @@ docker volume rm superset_db_home
docker-compose up
```
## GitHub Codespaces (Cloud Development)
GitHub Codespaces provides a complete, pre-configured development environment in the cloud. This is ideal for:
- Quick contributions without local setup
- Consistent development environments across team members
- Working from devices that can't run Docker locally
- Safe experimentation in isolated environments
:::info
We're grateful to GitHub for providing this excellent cloud development service that makes
contributing to Apache Superset more accessible to developers worldwide.
:::
### Getting Started with Codespaces
1. **Create a Codespace**: Use this pre-configured link that sets up everything you need:
[**Launch Superset Codespace →**](https://github.com/codespaces/new?skip_quickstart=true&machine=standardLinux32gb&repo=39464018&ref=master&devcontainer_path=.devcontainer%2Fdevcontainer.json&geo=UsWest)
:::caution
**Important**: You must select at least the **4 CPU / 16GB RAM** machine type (pre-selected in the link above).
Smaller instances will not have sufficient resources to run Superset effectively.
:::
2. **Wait for Setup**: The initial setup takes several minutes. The Codespace will:
- Build the development container
- Install all dependencies
- Start all required services (PostgreSQL, Redis, etc.)
- Initialize the database with example data
3. **Access Superset**: Once ready, check the **PORTS** tab in VS Code for port `9001`.
Click the globe icon to open Superset in your browser.
- Default credentials: `admin` / `admin`
### Key Features
- **Auto-reload**: Both Python and TypeScript files auto-refresh on save
- **Pre-installed Extensions**: VS Code extensions for Python, TypeScript, and database tools
- **Multiple Instances**: Run multiple Codespaces for different branches/features
- **SSH Access**: Connect via terminal using `gh cs ssh` or through the GitHub web UI
- **VS Code Integration**: Works seamlessly with VS Code desktop app
### Managing Codespaces
- **List active Codespaces**: `gh cs list`
- **SSH into a Codespace**: `gh cs ssh`
- **Stop a Codespace**: Via GitHub UI or `gh cs stop`
- **Delete a Codespace**: Via GitHub UI or `gh cs delete`
### Debugging and Logs
Since Codespaces uses `docker-compose-light.yml`, you can monitor all services:
```bash
# Stream logs from all services
docker compose -f docker-compose-light.yml logs -f
# Stream logs from a specific service
docker compose -f docker-compose-light.yml logs -f superset
# View last 100 lines and follow
docker compose -f docker-compose-light.yml logs --tail=100 -f
# List all running services
docker compose -f docker-compose-light.yml ps
```
:::tip
Codespaces automatically stop after 30 minutes of inactivity to save resources.
Your work is preserved and you can restart anytime.
:::
## Installing Development Tools
:::note
@@ -421,6 +349,14 @@ Then make sure you run your WSGI server using the right worker type:
gunicorn "superset.app:create_app()" -k "geventwebsocket.gunicorn.workers.GeventWebSocketWorker" -b 127.0.0.1:8088 --reload
```
You can log anything to the browser console, including objects:
```python
from superset import app
app.logger.error('An exception occurred!')
app.logger.info(form_data)
```
### Frontend
Frontend assets (TypeScript, JavaScript, CSS, and images) must be compiled in order to properly display the web UI. The `superset-frontend` directory contains all NPM-managed frontend assets. Note that for some legacy pages there are additional frontend assets bundled with Flask-Appbuilder (e.g. jQuery and bootstrap). These are not managed by NPM and may be phased out in the future.

View File

@@ -28,9 +28,6 @@ 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,
@@ -39,7 +36,7 @@ module.exports = defineConfig([
files: ['eslint.config.js'],
rules: {
'@typescript-eslint/no-require-imports': 'off',
},
}
},
{
languageOptions: {
@@ -71,5 +68,5 @@ module.exports = defineConfig([
version: 'detect',
},
},
},
]);
}
])

View File

@@ -15,7 +15,7 @@
"write-translations": "docusaurus write-translations",
"write-heading-ids": "docusaurus write-heading-ids",
"typecheck": "tsc",
"eslint": "eslint ."
"eslint": "eslint . --ext .js,.jsx,.ts,.tsx"
},
"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.7",
"antd": "^5.26.3",
"docusaurus-plugin-less": "^2.0.2",
"less": "^4.4.0",
"less": "^4.3.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.27.1"
"swagger-ui-react": "^5.26.0"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
"@docusaurus/tsconfig": "^3.8.1",
"@eslint/js": "^9.32.0",
"@eslint/js": "^9.31.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.8",
"eslint-config-prettier": "^10.1.5",
"eslint-plugin-prettier": "^5.5.1",
"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.101.0"
"webpack": "^5.99.9"
},
"browserslist": {
"production": [

View File

@@ -2205,16 +2205,11 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.31.0":
"@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":
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"
@@ -2517,10 +2512,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.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==
"@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==
dependencies:
"@babel/runtime" "^7.23.2"
"@rc-component/portal" "^1.1.0"
@@ -3427,10 +3422,10 @@
dependencies:
"@types/estree" "*"
"@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/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/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0":
version "5.0.6"
@@ -3971,11 +3966,6 @@ 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"
@@ -3988,7 +3978,12 @@ 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.15.0, acorn@^8.8.2:
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:
version "8.15.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.15.0.tgz#a360898bc415edaac46c8241f6383975b930b816"
integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==
@@ -4112,10 +4107,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.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==
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==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -4128,7 +4123,7 @@ antd@^5.26.7:
"@rc-component/mutate-observer" "^1.1.0"
"@rc-component/qrcode" "~1.0.0"
"@rc-component/tour" "~1.15.1"
"@rc-component/trigger" "^2.3.0"
"@rc-component/trigger" "^2.2.7"
classnames "^2.5.1"
copy-to-clipboard "^3.3.3"
dayjs "^1.11.11"
@@ -4158,7 +4153,7 @@ antd@^5.26.7:
rc-switch "~4.1.0"
rc-table "~7.51.1"
rc-tabs "~15.6.1"
rc-textarea "~1.10.1"
rc-textarea "~1.10.0"
rc-tooltip "~6.4.0"
rc-tree "~5.13.1"
rc-tree-select "~5.27.0"
@@ -4513,7 +4508,17 @@ 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, browserslist@^4.25.0:
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:
version "4.25.0"
resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.25.0.tgz#986aa9c6d87916885da2b50d8eb577ac8d133b2c"
integrity sha512-PJ8gYKeS5e/whHBh8xrwYK+dAvEj7JXtz6uTucnMRB8OiGTsKccFekoRrjajPBHV8oOY+2tI4uxeceSimKwMFA==
@@ -4615,7 +4620,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.30001702:
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001688, 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==
@@ -5898,6 +5903,11 @@ 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"
@@ -5942,10 +5952,10 @@ encodeurl@~2.0.0:
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58"
integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==
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==
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==
dependencies:
graceful-fs "^4.2.4"
tapable "^2.2.0"
@@ -6151,10 +6161,10 @@ 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.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-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-plugin-prettier@^5.5.1:
version "5.5.1"
@@ -8053,10 +8063,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.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==
less@^4.3.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/less/-/less-4.3.0.tgz#ef0cfc260a9ca8079ed8d0e3512bda8a12c82f2a"
integrity sha512-X9RyH9fvemArzfdP8Pi3irr7lor2Ok4rOttDXBhlwDg+wKQsXOXgHWduAJE1EsF7JJx0w0bcO6BC6tCKKYnXKA==
dependencies:
copy-anything "^2.0.1"
parse-node-version "^1.0.1"
@@ -10704,10 +10714,10 @@ rc-tabs@~15.6.1:
rc-resize-observer "^1.0.0"
rc-util "^5.34.1"
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==
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==
dependencies:
"@babel/runtime" "^7.10.1"
classnames "^2.2.1"
@@ -12090,10 +12100,10 @@ swagger-client@^3.35.5:
ramda "^0.30.1"
ramda-adjunct "^5.1.0"
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==
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==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -12515,7 +12525,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.3:
update-browserslist-db@^1.1.1, 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==
@@ -12784,27 +12794,26 @@ webpack-merge@^6.0.1:
flat "^5.0.2"
wildcard "^2.0.1"
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-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@^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==
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==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
"@types/estree" "^1.0.6"
"@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.15.0"
acorn-import-phases "^1.0.3"
acorn "^8.14.0"
browserslist "^4.24.0"
chrome-trace-event "^1.0.2"
enhanced-resolve "^5.17.2"
enhanced-resolve "^5.17.1"
es-module-lexer "^1.2.1"
eslint-scope "5.1.1"
events "^3.2.0"
@@ -12818,7 +12827,7 @@ webpack@^5.101.0, webpack@^5.88.1, webpack@^5.95.0:
tapable "^2.1.1"
terser-webpack-plugin "^5.3.11"
watchpack "^2.4.1"
webpack-sources "^3.3.3"
webpack-sources "^3.2.3"
webpackbar@^6.0.1:
version "6.0.1"

View File

@@ -111,7 +111,7 @@ athena = ["pyathena[pandas]>=2, <3"]
aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.15.0",
"sqlalchemy-bigquery>=1.6.1",
"google-cloud-bigquery>=3.10.0",
]
clickhouse = ["clickhouse-connect>=0.5.14, <1.0"]

View File

@@ -11,7 +11,9 @@ apispec==6.6.1
apsw==3.50.1.0
# via shillelagh
async-timeout==4.0.3
# via -r requirements/base.in
# via
# -r requirements/base.in
# redis
attrs==25.3.0
# via
# cattrs
@@ -97,6 +99,11 @@ email-validator==2.2.0
# via flask-appbuilder
et-xmlfile==2.0.0
# via openpyxl
exceptiongroup==1.3.0
# via
# cattrs
# trio
# trio-websocket
flask==2.3.3
# via
# apache-superset (pyproject.toml)
@@ -154,6 +161,7 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -310,7 +318,7 @@ python-dateutil==2.9.0.post0
# holidays
# pandas
# shillelagh
python-dotenv==1.1.0
python-dotenv==1.1.1
# via apache-superset (pyproject.toml)
python-geohash==0.8.5
# via apache-superset (pyproject.toml)
@@ -395,9 +403,11 @@ typing-extensions==4.14.0
# apache-superset (pyproject.toml)
# alembic
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2

View File

@@ -20,6 +20,10 @@ apsw==3.50.1.0
# shillelagh
astroid==3.3.10
# via pylint
async-timeout==4.0.3
# via
# -c requirements/base.txt
# redis
attrs==25.3.0
# via
# -c requirements/base.txt
@@ -176,6 +180,13 @@ et-xmlfile==2.0.0
# via
# -c requirements/base.txt
# openpyxl
exceptiongroup==1.3.0
# via
# -c requirements/base.txt
# cattrs
# pytest
# trio
# trio-websocket
filelock==3.12.2
# via virtualenv
flask==2.3.3
@@ -313,6 +324,7 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -670,7 +682,7 @@ python-dateutil==2.9.0.post0
# pyhive
# shillelagh
# trino
python-dotenv==1.1.0
python-dotenv==1.1.1
# via
# -c requirements/base.txt
# apache-superset
@@ -795,7 +807,7 @@ sqlalchemy==1.4.54
# shillelagh
# sqlalchemy-bigquery
# sqlalchemy-utils
sqlalchemy-bigquery==1.15.0
sqlalchemy-bigquery==1.12.0
# via apache-superset
sqlalchemy-utils==0.38.3
# via
@@ -818,6 +830,11 @@ tabulate==0.9.0
# via
# -c requirements/base.txt
# apache-superset
tomli==2.2.1
# via
# coverage
# pylint
# pytest
tomlkit==0.13.3
# via pylint
tqdm==4.67.1
@@ -840,10 +857,13 @@ typing-extensions==4.14.0
# -c requirements/base.txt
# alembic
# apache-superset
# astroid
# cattrs
# exceptiongroup
# limits
# pyopenssl
# referencing
# rich
# selenium
# shillelagh
tzdata==2025.2

View File

@@ -33,4 +33,4 @@ superset load-test-users
echo "Running tests"
pytest --durations-min=2 --cov-report= --cov=superset ./tests/integration_tests "$@"
pytest --durations-min=2 --maxfail=1 --cov-report= --cov=superset ./tests/integration_tests "$@"

View File

@@ -94,12 +94,67 @@ describe('Charts list', () => {
});
});
describe('list mode', () => {
before(() => {
visitChartList();
setGridMode('list');
});
it('should load rows in list mode', () => {
cy.getBySel('listview-table').should('be.visible');
cy.getBySel('sort-header').eq(1).contains('Name');
cy.getBySel('sort-header').eq(2).contains('Type');
cy.getBySel('sort-header').eq(3).contains('Dataset');
cy.getBySel('sort-header').eq(4).contains('On dashboards');
cy.getBySel('sort-header').eq(5).contains('Owners');
cy.getBySel('sort-header').eq(6).contains('Last modified');
cy.getBySel('sort-header').eq(7).contains('Actions');
});
it('should bulk select in list mode', () => {
toggleBulkSelect();
cy.get('[aria-label="Select all"]').click();
cy.get('input[type="checkbox"]:checked').should('have.length', 26);
cy.getBySel('bulk-select-copy').contains('25 Selected');
cy.getBySel('bulk-select-action')
.should('have.length', 2)
.then($btns => {
expect($btns).to.contain('Delete');
expect($btns).to.contain('Export');
});
cy.getBySel('bulk-select-deselect-all').click();
cy.get('input[type="checkbox"]:checked').should('have.length', 0);
cy.getBySel('bulk-select-copy').contains('0 Selected');
cy.getBySel('bulk-select-action').should('not.exist');
});
});
describe('card mode', () => {
before(() => {
visitChartList();
setGridMode('card');
});
it('should load rows in card mode', () => {
cy.getBySel('listview-table').should('not.exist');
cy.getBySel('styled-card').should('have.length', 25);
});
it('should bulk select in card mode', () => {
toggleBulkSelect();
cy.getBySel('styled-card').click({ multiple: true });
cy.getBySel('bulk-select-copy').contains('25 Selected');
cy.getBySel('bulk-select-action')
.should('have.length', 2)
.then($btns => {
expect($btns).to.contain('Delete');
expect($btns).to.contain('Export');
});
cy.getBySel('bulk-select-deselect-all').click();
cy.getBySel('bulk-select-copy').contains('0 Selected');
cy.getBySel('bulk-select-action').should('not.exist');
});
it('should preserve other filters when sorting', () => {
cy.getBySel('styled-card').should('have.length', 25);
setFilter('Type', 'Big Number');

View File

@@ -65,16 +65,11 @@ const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
.then($el => {
cy.wrap($el)
.contains(new RegExp(`^${targetDrillByColumn}$`))
.trigger('keydown', { keyCode: 13, which: 13, force: true });
});
if (isLegacy) {
return cy.wait('@legacyData');
@@ -245,7 +240,7 @@ describe('Drill by modal', () => {
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
it('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {

View File

@@ -22,7 +22,6 @@ import {
dataTestChartName,
} from 'cypress/support/directories';
import { waitForChartLoad } from 'cypress/utils';
import {
addParentFilterWithValue,
applyNativeFilterValueWithIndex,
@@ -161,74 +160,6 @@ describe('Native filters', () => {
);
});
it('Dependent filter selects first item based on parent filter selection', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },
{ name: 'country_name', column: 'country_name', datasetId: 2 },
]);
enterNativeFilterEditModal();
selectFilter(0);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
selectFilter(1);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Values are dependent on other filters')
.should('be.visible')
.click();
},
);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Can select multiple values ')
.should('be.visible')
.click();
},
);
addParentFilterWithValue(0, testItems.topTenChart.filterColumnRegion);
cy.get(nativeFilters.filterConfigurationSections.displayedSection).within(
() => {
cy.contains('Select first filter value by default')
.should('be.visible')
.click();
},
);
// cannot use saveNativeFilterSettings because there is a bug which
// sometimes does not allow charts to load when enabling the 'Select first filter value by default'
// to be saved when using dependent filters so,
// you reload the window.
cy.get(nativeFilters.modal.footer)
.contains('Save')
.should('be.visible')
.click({ force: true });
cy.get(nativeFilters.modal.container).should('not.exist');
cy.reload();
applyNativeFilterValueWithIndex(0, 'North America');
// Check that dependent filter auto-selects the first item
cy.get(nativeFilters.filterFromDashboardView.filterContent)
.eq(1)
.should('contain.text', 'Bermuda');
});
it('User can create filter depend on 2 other filters', () => {
prepareDashboardFilters([
{ name: 'region', column: 'region', datasetId: 2 },

View File

@@ -68,13 +68,11 @@ function verifyDashboardSearch() {
function verifyDashboardLink() {
interceptDashboardGet();
openDashboardsAddedTo();
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover', {
force: true,
});
cy.get('.ant-dropdown-menu-submenu-popup').trigger('mouseover');
cy.get('.ant-dropdown-menu-submenu-popup a')
.first()
.invoke('removeAttr', 'target')
.click({ force: true });
.click();
cy.wait('@get');
}

File diff suppressed because it is too large Load Diff

View File

@@ -121,15 +121,15 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"ag-grid-community": "33.1.1",
"ag-grid-react": "33.1.1",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.6.0",
"dom-to-image-more": "^3.2.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
@@ -208,7 +208,7 @@
"devDependencies": {
"@applitools/eyes-storybook": "^3.55.6",
"@babel/cli": "^7.27.2",
"@babel/compat-data": "^7.28.0",
"@babel/compat-data": "^7.26.8",
"@babel/core": "^7.26.0",
"@babel/eslint-parser": "^7.25.9",
"@babel/node": "^7.22.6",
@@ -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.3.4",
"@types/classnames": "^2.2.10",
"@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.20.3",
"tsx": "^4.19.2",
"typescript": "5.4.5",
"vm-browserify": "^1.1.2",
"webpack": "^5.99.9",

View File

@@ -36,7 +36,7 @@
"devDependencies": {
"cross-env": "^7.0.3",
"fs-extra": "^11.3.0",
"jest": "^30.0.4",
"jest": "^30.0.2",
"yeoman-test": "^10.1.1"
},
"engines": {

View File

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

View File

@@ -177,7 +177,6 @@ const granularity: SharedControlConfig<'SelectControl'> = {
'can type and use simple natural language as in `10 seconds`, ' +
'`1 day` or `56 weeks`',
),
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
};
const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
@@ -205,7 +204,6 @@ const time_grain_sqla: SharedControlConfig<'SelectControl'> = {
choices: (datasource as Dataset)?.time_grain_sqla || [],
}),
visibility: displayTimeRelatedControls,
sortComparator: () => 0, // Disable frontend sorting to preserve backend order
};
const time_range: SharedControlConfig<'DateFilterControl'> = {

View File

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

View File

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

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { render } from '@superset-ui/core/spec';
import { TelemetryPixel } from '.';
import TelemetryPixel from '.';
const OLD_ENV = process.env;

View File

@@ -39,7 +39,7 @@ interface TelemetryPixelProps {
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
export const TelemetryPixel = ({
const TelemetryPixel = ({
version = 'unknownVersion',
sha = 'unknownSHA',
build = 'unknownBuild',
@@ -56,3 +56,4 @@ export const TelemetryPixel = ({
/>
);
};
export default TelemetryPixel;

View File

@@ -0,0 +1,116 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Dropdown, Icons } from '@superset-ui/core/components';
import type { MenuItem } from '@superset-ui/core/components/Menu';
import { t, useTheme } from '@superset-ui/core';
import { ThemeAlgorithm, ThemeMode } from '../../theme/types';
export interface ThemeSelectProps {
setThemeMode: (newMode: ThemeMode) => void;
tooltipTitle?: string;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
const ThemeSelect: React.FC<ThemeSelectProps> = ({
setThemeMode,
tooltipTitle = 'Select theme',
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> = {
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
};
// Use different icon when local theme is active
const triggerIcon = hasLocalOverride ? (
<Icons.FormatPainterOutlined style={{ color: theme.colorErrorText }} />
) : (
themeIconMap[themeMode] || <Icons.FormatPainterOutlined />
);
const menuItems: MenuItem[] = [
{
type: 'group',
label: t('Theme'),
},
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
if (onClearLocalSettings && hasLocalOverride) {
menuItems.push(
{ type: 'divider' } as MenuItem,
{
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
} as MenuItem,
);
}
return (
<Dropdown
menu={{
items: menuItems,
selectedKeys: [themeMode],
}}
trigger={['hover']}
>
{triggerIcon}
</Dropdown>
);
};
export default ThemeSelect;

View File

@@ -1,273 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
render,
screen,
userEvent,
waitFor,
within,
} from '@superset-ui/core/spec';
import { ThemeMode } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components';
import { ThemeSubMenu } from '.';
// Mock the translation function
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
t: (key: string) => key,
}));
describe('ThemeSubMenu', () => {
const defaultProps = {
allowOSPreference: true,
setThemeMode: jest.fn(),
themeMode: ThemeMode.DEFAULT,
hasLocalOverride: false,
onClearLocalSettings: jest.fn(),
};
const renderThemeSubMenu = (props = defaultProps) =>
render(
<Menu>
<ThemeSubMenu {...props} />
</Menu>,
);
const findMenuWithText = async (text: string) => {
await waitFor(() => {
const found = screen
.getAllByRole('menu')
.some(m => within(m).queryByText(text));
if (!found) throw new Error(`Menu with text "${text}" not yet rendered`);
});
return screen.getAllByRole('menu').find(m => within(m).queryByText(text))!;
};
beforeEach(() => {
jest.clearAllMocks();
});
it('renders Light and Dark theme options by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
expect(within(menu!).getByText('Light')).toBeInTheDocument();
expect(within(menu!).getByText('Dark')).toBeInTheDocument();
});
it('does not render Match system option when allowOSPreference is false', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: false });
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Match system')).not.toBeInTheDocument();
});
});
it('renders with allowOSPreference as true by default', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
expect(within(menu).getByText('Match system')).toBeInTheDocument();
});
it('renders clear option when both hasLocalOverride and onClearLocalSettings are provided', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
expect(within(menu).getByText('Clear local theme')).toBeInTheDocument();
});
it('does not render clear option when hasLocalOverride is false', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: false,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
await waitFor(() => {
expect(screen.queryByText('Clear local theme')).not.toBeInTheDocument();
});
});
it('calls setThemeMode with DEFAULT when Light is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
userEvent.click(within(menu).getByText('Light'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DEFAULT);
});
it('calls setThemeMode with DARK when Dark is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
userEvent.click(within(menu).getByText('Dark'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.DARK);
});
it('calls setThemeMode with SYSTEM when Match system is clicked', async () => {
const mockSet = jest.fn();
renderThemeSubMenu({ ...defaultProps, setThemeMode: mockSet });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
userEvent.click(within(menu).getByText('Match system'));
expect(mockSet).toHaveBeenCalledWith(ThemeMode.SYSTEM);
});
it('calls onClearLocalSettings when Clear local theme is clicked', async () => {
const mockClear = jest.fn();
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: mockClear,
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
userEvent.click(within(menu).getByText('Clear local theme'));
expect(mockClear).toHaveBeenCalledTimes(1);
});
it('displays sun icon for DEFAULT theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DEFAULT });
expect(screen.getByTestId('sun')).toBeInTheDocument();
});
it('displays moon icon for DARK theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.DARK });
expect(screen.getByTestId('moon')).toBeInTheDocument();
});
it('displays format-painter icon for SYSTEM theme', () => {
renderThemeSubMenu({ ...defaultProps, themeMode: ThemeMode.SYSTEM });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('displays override icon when hasLocalOverride is true', () => {
renderThemeSubMenu({ ...defaultProps, hasLocalOverride: true });
expect(screen.getByTestId('format-painter')).toBeInTheDocument();
});
it('renders Theme group header', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Theme');
expect(within(menu).getByText('Theme')).toBeInTheDocument();
});
it('renders sun icon for Light theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Light');
const lightOption = within(menu).getByText('Light').closest('li');
expect(within(lightOption!).getByTestId('sun')).toBeInTheDocument();
});
it('renders moon icon for Dark theme option', async () => {
renderThemeSubMenu();
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Dark');
const darkOption = within(menu).getByText('Dark').closest('li');
expect(within(darkOption!).getByTestId('moon')).toBeInTheDocument();
});
it('renders format-painter icon for Match system option', async () => {
renderThemeSubMenu({ ...defaultProps, allowOSPreference: true });
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Match system');
const matchOption = within(menu).getByText('Match system').closest('li');
expect(
within(matchOption!).getByTestId('format-painter'),
).toBeInTheDocument();
});
it('renders clear icon for Clear local theme option', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const clearOption = within(menu)
.getByText('Clear local theme')
.closest('li');
expect(within(clearOption!).getByTestId('clear')).toBeInTheDocument();
});
it('renders divider before clear option when clear option is present', async () => {
renderThemeSubMenu({
...defaultProps,
hasLocalOverride: true,
onClearLocalSettings: jest.fn(),
});
userEvent.hover(await screen.findByRole('menuitem'));
const menu = await findMenuWithText('Clear local theme');
const divider = within(menu).queryByRole('separator');
expect(divider).toBeInTheDocument();
});
it('does not render divider when clear option is not present', async () => {
renderThemeSubMenu({ ...defaultProps });
userEvent.hover(await screen.findByRole('menuitem'));
const divider = document.querySelector('.ant-menu-item-divider');
expect(divider).toBeNull();
});
});

View File

@@ -1,170 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { Icons, Menu } from '@superset-ui/core/components';
import {
css,
styled,
t,
ThemeMode,
useTheme,
ThemeAlgorithm,
} from '@superset-ui/core';
const StyledThemeSubMenu = styled(Menu.SubMenu)`
${({ theme }) => css`
[data-icon='caret-down'] {
color: ${theme.colorIcon};
font-size: ${theme.fontSizeXS}px;
margin-left: ${theme.sizeUnit}px;
}
&.ant-menu-submenu-active {
.ant-menu-title-content {
color: ${theme.colorPrimary};
}
}
`}
`;
const StyledThemeSubMenuItem = styled(Menu.Item)<{ selected: boolean }>`
${({ theme, selected }) => css`
&:hover {
color: ${theme.colorPrimary} !important;
cursor: pointer !important;
}
${selected &&
css`
background-color: ${theme.colors.primary.light4} !important;
color: ${theme.colors.primary.dark1} !important;
`}
`}
`;
export interface ThemeSubMenuOption {
key: ThemeMode;
label: string;
icon: React.ReactNode;
onClick: () => void;
}
export interface ThemeSubMenuProps {
setThemeMode: (newMode: ThemeMode) => void;
themeMode: ThemeMode;
hasLocalOverride?: boolean;
onClearLocalSettings?: () => void;
allowOSPreference?: boolean;
}
export const ThemeSubMenu: React.FC<ThemeSubMenuProps> = ({
setThemeMode,
themeMode,
hasLocalOverride = false,
onClearLocalSettings,
allowOSPreference = true,
}: ThemeSubMenuProps) => {
const theme = useTheme();
const handleSelect = (mode: ThemeMode) => {
setThemeMode(mode);
};
const themeIconMap: Record<ThemeAlgorithm | ThemeMode, React.ReactNode> =
useMemo(
() => ({
[ThemeAlgorithm.DEFAULT]: <Icons.SunOutlined />,
[ThemeAlgorithm.DARK]: <Icons.MoonOutlined />,
[ThemeMode.SYSTEM]: <Icons.FormatPainterOutlined />,
[ThemeAlgorithm.COMPACT]: <Icons.CompressOutlined />,
}),
[],
);
const selectedThemeModeIcon = useMemo(
() =>
hasLocalOverride ? (
<Icons.FormatPainterOutlined
style={{ color: theme.colors.error.base }}
/>
) : (
themeIconMap[themeMode]
),
[hasLocalOverride, theme.colors.error.base, themeIconMap, themeMode],
);
const themeOptions: ThemeSubMenuOption[] = [
{
key: ThemeMode.DEFAULT,
label: t('Light'),
icon: <Icons.SunOutlined />,
onClick: () => handleSelect(ThemeMode.DEFAULT),
},
{
key: ThemeMode.DARK,
label: t('Dark'),
icon: <Icons.MoonOutlined />,
onClick: () => handleSelect(ThemeMode.DARK),
},
...(allowOSPreference
? [
{
key: ThemeMode.SYSTEM,
label: t('Match system'),
icon: <Icons.FormatPainterOutlined />,
onClick: () => handleSelect(ThemeMode.SYSTEM),
},
]
: []),
];
// Add clear settings option only when there's a local theme active
const clearOption =
onClearLocalSettings && hasLocalOverride
? {
key: 'clear-local',
label: t('Clear local theme'),
icon: <Icons.ClearOutlined />,
onClick: onClearLocalSettings,
}
: null;
return (
<StyledThemeSubMenu
key="theme-sub-menu"
title={selectedThemeModeIcon}
icon={<Icons.CaretDownOutlined iconSize="xs" />}
>
<Menu.ItemGroup title={t('Theme')} />
{themeOptions.map(option => (
<StyledThemeSubMenuItem
key={option.key}
onClick={option.onClick}
selected={option.key === themeMode}
>
{option.icon} {option.label}
</StyledThemeSubMenuItem>
))}
{clearOption && [
<Menu.Divider key="theme-divider" />,
<Menu.Item key={clearOption.key} onClick={clearOption.onClick}>
{clearOption.icon} {clearOption.label}
</Menu.Item>,
]}
</StyledThemeSubMenu>
);
};

View File

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

View File

@@ -164,8 +164,6 @@ export * from './Steps';
export * from './Table';
export * from './TableView';
export * from './Tag';
export * from './TelemetryPixel';
export * from './ThemeSubMenu';
export * from './UnsavedChangesModal';
export * from './constants';
export * from './Result';

View File

@@ -57,206 +57,5 @@ const exampleThemes: Record<string, SerializableThemeConfig> = {
},
algorithm: ThemeAlgorithm.DARK,
},
claudette: {
algorithm: 'dark',
token: {
colorPrimary: '#C15F3C',
colorPrimaryHover: '#d16b48',
colorPrimaryActive: '#a84f30',
colorBgBase: '#1a1a1a',
colorBgContainer: '#2a2a2a',
colorBgElevated: '#323232',
colorBgLayout: '#0f0f0f',
colorBgSpotlight: '#323232',
colorText: '#F4F3EE',
colorTextSecondary: '#B1ADA1',
colorTextTertiary: '#8a8680',
colorTextQuaternary: '#6b6862',
colorTextPlaceholder: '#B1ADA1',
colorTextLightSolid: '#F4F3EE',
colorBorder: '#404040',
colorBorderSecondary: '#303030',
colorFill: '#404040',
colorFillSecondary: '#353535',
colorFillTertiary: '#2a2a2a',
colorFillQuaternary: '#1f1f1f',
colorFillAlter: '#323232',
colorIcon: '#B1ADA1',
colorIconHover: '#F4F3EE',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu'",
fontFamilyCode:
"'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, Courier, monospace",
fontSize: 14,
borderRadius: 6,
borderRadiusLG: 8,
wireframe: false,
},
},
figmate: {
algorithm: 'light',
token: {
colorPrimary: '#0d99ff',
colorPrimaryHover: '#3dadff',
colorPrimaryActive: '#0085e6',
colorInfo: '#4c74f4',
colorInfoHover: '#6b8af5',
colorInfoActive: '#2d5af2',
colorSuccess: '#00d2aa',
colorSuccessHover: '#1adbba',
colorSuccessActive: '#00c299',
colorWarning: '#ffad33',
colorWarningHover: '#ffbf5c',
colorWarningActive: '#ff9900',
colorError: '#ff5757',
colorErrorHover: '#ff7a7a',
colorErrorActive: '#ff3333',
colorBgBase: '#ffffff',
colorBgContainer: '#fafafa',
colorBgElevated: '#ffffff',
colorBgLayout: '#f5f5f5',
colorText: '#1a1a1a',
colorTextSecondary: '#666666',
colorTextTertiary: '#999999',
colorTextQuaternary: '#cccccc',
colorTextPlaceholder: '#999999',
colorBorder: '#e6e6e6',
colorBorderSecondary: '#f0f0f0',
colorFill: '#f0f0f0',
colorFillSecondary: '#f5f5f5',
colorFillTertiary: '#fafafa',
colorFillQuaternary: '#ffffff',
colorIcon: '#666666',
colorIconHover: '#333333',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 6,
lineHeight: 1.5,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
hubert: {
algorithm: 'dark',
token: {
colorPrimary: '#276EF1',
colorPrimaryHover: '#4285f4',
colorPrimaryActive: '#1557d6',
colorInfo: '#276EF1',
colorInfoHover: '#4285f4',
colorInfoActive: '#1557d6',
colorSuccess: '#3AA76D',
colorSuccessHover: '#4db87d',
colorSuccessActive: '#2d9657',
colorWarning: '#FFC043',
colorWarningHover: '#ffcd66',
colorWarningActive: '#e6ac26',
colorError: '#D44333',
colorErrorHover: '#dd5a4c',
colorErrorActive: '#bf3526',
colorBgBase: '#000000',
colorBgContainer: '#1a1a1a',
colorBgElevated: '#2a2a2a',
colorBgLayout: '#000000',
colorBgSpotlight: '#333333',
colorText: '#ffffff',
colorTextSecondary: '#cccccc',
colorTextTertiary: '#999999',
colorTextQuaternary: '#666666',
colorTextPlaceholder: '#999999',
colorTextLightSolid: '#ffffff',
colorBorder: '#333333',
colorBorderSecondary: '#1a1a1a',
colorFill: '#1a1a1a',
colorFillSecondary: '#2a2a2a',
colorFillTertiary: '#333333',
colorFillQuaternary: '#404040',
colorFillAlter: '#2a2a2a',
colorIcon: '#cccccc',
colorIconHover: '#ffffff',
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
fontWeightStrong: 600,
borderRadius: 4,
borderRadiusLG: 6,
borderRadiusSM: 2,
lineHeight: 1.4,
lineWidthBold: 2,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
bnb: {
algorithm: 'light',
token: {
colorPrimary: '#FF5A5F',
colorPrimaryHover: '#FF7479',
colorPrimaryActive: '#E5464B',
colorInfo: '#29696B',
colorInfoHover: '#3D7D7F',
colorInfoActive: '#1F5557',
colorSuccess: '#5BCACE',
colorSuccessHover: '#7DD4D8',
colorSuccessActive: '#49B6BA',
colorWarning: '#F4B02A',
colorWarningHover: '#F6C054',
colorWarningActive: '#E2A01E',
colorError: '#C32F0E',
colorErrorHover: '#D54428',
colorErrorActive: '#A8280C',
colorBgBase: '#ffffff',
colorBgContainer: '#fafafa',
colorBgElevated: '#ffffff',
colorBgLayout: '#f7f7f7',
colorText: '#222222',
colorTextSecondary: '#484848',
colorTextTertiary: '#767676',
colorTextQuaternary: '#b0b0b0',
colorTextPlaceholder: '#767676',
colorBorder: '#e8e8e8',
colorBorderSecondary: '#f0f0f0',
colorFill: '#f7f7f7',
colorFillSecondary: '#fafafa',
colorFillTertiary: '#fcfcfc',
colorFillQuaternary: '#ffffff',
colorIcon: '#767676',
colorIconHover: '#484848',
fontFamily:
'Circular, Circular Air Pro, Airbnb Cereal, -apple-system, BlinkMacSystemFont, Segoe UI, Roboto',
fontFamilyCode:
'"SF Mono", Monaco, Inconsolata, "Roboto Mono", monospace',
fontSize: 14,
fontSizeLG: 16,
fontSizeXL: 20,
fontWeightStrong: 600,
borderRadius: 8,
borderRadiusLG: 12,
borderRadiusSM: 4,
lineHeight: 1.5,
wireframe: false,
motion: true,
motionDurationSlow: '0.3s',
motionDurationMid: '0.2s',
motionDurationFast: '0.1s',
},
},
};
export default exampleThemes;

View File

@@ -26,9 +26,7 @@ import {
type ThemeStorage,
type ThemeControllerOptions,
type ThemeContextType,
type SupersetThemeConfig,
ThemeAlgorithm,
ThemeMode,
} from './types';
export {
@@ -68,16 +66,7 @@ const themeObject: Theme = Theme.fromConfig({
const { theme } = themeObject;
const supersetTheme = theme;
export {
Theme,
ThemeAlgorithm,
ThemeMode,
themeObject,
styled,
theme,
supersetTheme,
};
export { Theme, themeObject, styled, theme, supersetTheme };
export type {
SupersetTheme,
SerializableThemeConfig,
@@ -85,7 +74,6 @@ export type {
ThemeStorage,
ThemeControllerOptions,
ThemeContextType,
SupersetThemeConfig,
};
// Export theme utility functions

View File

@@ -429,16 +429,3 @@ export interface ThemeContextType {
canDetectOSPreference: () => boolean;
createDashboardThemeProvider: (themeId: string) => Promise<Theme | null>;
}
/**
* Configuration object for complete theme setup including default, dark themes and settings
*/
export interface SupersetThemeConfig {
theme_default: AnyThemeConfig;
theme_dark?: AnyThemeConfig;
theme_settings?: {
enforced?: boolean;
allowSwitching?: boolean;
allowOSPreference?: boolean;
};
}

View File

@@ -49,21 +49,6 @@ 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', () => {

View File

@@ -68,87 +68,9 @@ 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');
// 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);
});
return Array.from(doc.body.childNodes).some(({ nodeType }) => nodeType === 1);
}
export function sanitizeHtmlIfNeeded(htmlString: string) {

View File

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

View File

@@ -43,7 +43,7 @@
"@storybook/types": "8.4.7",
"@types/react-loadable": "^5.5.11",
"core-js": "3.40.0",
"gh-pages": "^6.3.0",
"gh-pages": "^6.2.0",
"jquery": "^3.7.1",
"memoize-one": "^5.2.1",
"react": "^17.0.2",

View File

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

View File

@@ -25,7 +25,7 @@
],
"dependencies": {
"@deck.gl/aggregation-layers": "^9.1.13",
"@deck.gl/core": "^9.1.14",
"@deck.gl/core": "^9.1.13",
"@deck.gl/geo-layers": "^9.1.13",
"@deck.gl/layers": "^9.1.13",
"@deck.gl/react": "^9.1.13",

View File

@@ -27,8 +27,8 @@
"@react-icons/all-files": "^4.1.0",
"@types/d3-array": "^2.9.0",
"@types/react-table": "^7.7.20",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "^34.0.2",
"ag-grid-community": "^33.1.1",
"ag-grid-react": "^33.1.1",
"classnames": "^2.5.1",
"d3-array": "^2.4.0",
"lodash": "^4.17.21",

View File

@@ -18,7 +18,7 @@
*/
/* eslint-disable import/no-extraneous-dependencies */
import { useState } from 'react';
import { Dropdown } from 'antd';
import { Dropdown, Menu } from 'antd';
import { TableOutlined, DownOutlined, CheckOutlined } from '@ant-design/icons';
import { t } from '@superset-ui/core';
import { InfoText, ColumnLabel, CheckIconWrapper } from '../../styles';
@@ -69,42 +69,34 @@ const TimeComparisonVisibility: React.FC<TimeComparisonVisibilityProps> = ({
return (
<Dropdown
placement="bottomRight"
open={showComparisonDropdown}
onOpenChange={(flag: boolean) => {
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
menu={{
multiple: true,
onClick: handleOnClick,
onBlur: handleOnBlur,
selectedKeys: selectedComparisonColumns,
items: [
{
key: 'all',
label: (
<InfoText>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<InfoText>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</InfoText>
{comparisonColumns.map((column: ComparisonColumn) => (
<Menu.Item key={column.key}>
<ColumnLabel>{column.label}</ColumnLabel>
<CheckIconWrapper>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</InfoText>
),
type: 'group',
children: comparisonColumns.map((column: ComparisonColumn) => ({
key: column.key,
label: (
<>
<ColumnLabel>{column.label}</ColumnLabel>
<CheckIconWrapper>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</CheckIconWrapper>
</>
),
})),
},
],
}}
</CheckIconWrapper>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>

View File

@@ -589,7 +589,7 @@ const config: ControlPanelConfig = {
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Show cell bars'),
label: t('Show Cell bars'),
renderTrigger: true,
default: true,
description: t(
@@ -617,7 +617,7 @@ const config: ControlPanelConfig = {
name: 'color_pn',
config: {
type: 'CheckboxControl',
label: t('Add colors to cell bars for +/-'),
label: t('add colors to cell bars for +/-'),
renderTrigger: true,
default: true,
description: t(
@@ -631,7 +631,7 @@ const config: ControlPanelConfig = {
name: 'comparison_color_enabled',
config: {
type: 'CheckboxControl',
label: t('Basic conditional formatting'),
label: t('basic conditional formatting'),
renderTrigger: true,
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value),
@@ -672,7 +672,7 @@ const config: ControlPanelConfig = {
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Custom conditional formatting'),
label: t('Custom Conditional Formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,

View File

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

View File

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

View File

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

View File

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

View File

@@ -358,22 +358,7 @@ const config: ControlPanelConfig = {
['x_axis_time_format'],
[xAxisLabelRotation],
[xAxisLabelInterval],
[<ControlSubSectionHeader>{t('Tooltip')}</ControlSubSectionHeader>],
[
{
name: 'show_query_identifiers',
config: {
type: 'CheckboxControl',
label: t('Show query identifiers'),
description: t(
'Add Query A and Query B identifiers to tooltips to help differentiate series',
),
default: false,
renderTrigger: true,
},
},
],
...richTooltipSection.slice(1), // Skip the tooltip header since we added our own
...richTooltipSection,
// eslint-disable-next-line react/jsx-key
[<ControlSubSectionHeader>{t('Y Axis')}</ControlSubSectionHeader>],
[

View File

@@ -212,7 +212,6 @@ export default function transformProps(
sortSeriesAscendingB,
timeGrainSqla,
percentageThreshold,
showQueryIdentifiers = false,
metrics = [],
metricsB = [],
}: EchartsMixedTimeseriesFormData = { ...DEFAULT_FORM_DATA, ...formData };
@@ -396,17 +395,10 @@ export default function transformProps(
const seriesName = inverted[entryName] || entryName;
const colorScaleKey = getOriginalSeries(seriesName, array);
let displayName: string;
let displayName = `${entryName} (Query A)`;
if (groupby.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameA} (Query A)`
: MetricDisplayNameA;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
displayName = `${MetricDisplayNameA} (Query A), ${entryName}`;
}
const seriesFormatter = getFormatter(
@@ -461,17 +453,10 @@ export default function transformProps(
const seriesName = `${seriesEntry} (1)`;
const colorScaleKey = getOriginalSeries(seriesEntry, array);
let displayName: string;
let displayName = `${entryName} (Query B)`;
if (groupbyB.length > 0) {
// When we have groupby, format as "metric, dimension"
const metricPart = showQueryIdentifiers
? `${MetricDisplayNameB} (Query B)`
: MetricDisplayNameB;
displayName = `${metricPart}, ${entryName}`;
} else {
// When no groupby, format as just the entry name with optional query identifier
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
displayName = `${MetricDisplayNameB} (Query B), ${entryName}`;
}
const seriesFormatter = getFormatter(
@@ -711,13 +696,14 @@ export default function transformProps(
zoomable,
),
// @ts-ignore
data: series
data: rawSeriesA
.concat(rawSeriesB)
.filter(
entry =>
extractForecastSeriesContext((entry.name || '') as string).type ===
ForecastSeriesEnum.Observation,
)
.map(entry => entry.id || entry.name || '')
.map(entry => entry.name || '')
.concat(extractAnnotationLabels(annotationLayers, annotationData)),
},
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),

View File

@@ -60,7 +60,6 @@ export type EchartsMixedTimeseriesFormData = QueryFormData & {
tooltipTimeFormat?: string;
zoomable: boolean;
richTooltip: boolean;
showQueryIdentifiers?: boolean;
xAxisLabelRotation: number;
xAxisLabelInterval?: number | string;
colorScheme?: string;
@@ -134,7 +133,6 @@ export const DEFAULT_FORM_DATA: EchartsMixedTimeseriesFormData = {
groupbyB: [],
zoomable: TIMESERIES_DEFAULTS.zoomable,
richTooltip: TIMESERIES_DEFAULTS.richTooltip,
showQueryIdentifiers: false,
xAxisLabelRotation: TIMESERIES_DEFAULTS.xAxisLabelRotation,
xAxisLabelInterval: TIMESERIES_DEFAULTS.xAxisLabelInterval,
...DEFAULT_TITLE_FORM_DATA,

View File

@@ -95,27 +95,27 @@ function getTotalValuePadding({
top: donut ? 'middle' : '0',
left: 'center',
};
const LEGEND_HEIGHT = 15;
const LEGEND_WIDTH = 215;
if (chartPadding.top) {
padding.top = donut
? `${50 + (chartPadding.top / height / 2) * 100}%`
: `${(chartPadding.top / height) * 100}%`;
? `${50 + ((chartPadding.top - LEGEND_HEIGHT) / height / 2) * 100}%`
: `${((chartPadding.top + LEGEND_HEIGHT) / height) * 100}%`;
}
if (chartPadding.bottom) {
padding.top = donut
? `${50 - (chartPadding.bottom / height / 2) * 100}%`
? `${50 - ((chartPadding.bottom + LEGEND_HEIGHT) / height / 2) * 100}%`
: '0';
}
if (chartPadding.left) {
// When legend is on the left, shift text right to center it in the available space
const leftPaddingPercent = (chartPadding.left / width) * 100;
const adjustedLeftPercent = 50 + leftPaddingPercent * 0.25;
padding.left = `${adjustedLeftPercent}%`;
padding.left = `${
50 + ((chartPadding.left - LEGEND_WIDTH) / width / 2) * 100
}%`;
}
if (chartPadding.right) {
// When legend is on the right, shift text left to center it in the available space
const rightPaddingPercent = (chartPadding.right / width) * 100;
const adjustedLeftPercent = 50 - rightPaddingPercent * 0.75;
padding.left = `${adjustedLeftPercent}%`;
padding.left = `${
50 - ((chartPadding.right + LEGEND_WIDTH) / width / 2) * 100
}%`;
}
return padding;
}
@@ -220,7 +220,7 @@ export default function transformProps(
name: otherName,
value: otherSum,
itemStyle: {
color: theme.colorText,
color: theme.colors.grayscale.dark1,
opacity:
filterState.selectedValues &&
!filterState.selectedValues.includes(otherName)
@@ -368,7 +368,7 @@ export default function transformProps(
const defaultLabel = {
formatter,
show: showLabels,
color: theme.colorText,
color: theme.colors.grayscale.dark2,
};
const chartPadding = getChartPadding(
@@ -403,7 +403,7 @@ export default function transformProps(
label: {
show: true,
fontWeight: 'bold',
backgroundColor: theme.colorBgContainer,
backgroundColor: theme.colors.grayscale.light5,
},
},
data: transformedData,
@@ -445,7 +445,6 @@ export default function transformProps(
text: t('Total: %s', numberFormatter(totalValue)),
fontSize: 16,
fontWeight: 'bold',
fill: theme.colorText,
},
z: 10,
}

View File

@@ -39,6 +39,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
width,
echartOptions,
setDataMask,
labelMap,
selectedValues,
formData,
onContextMenu,
@@ -51,47 +52,45 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
const getCrossFilterDataMask = useCallback(
(treePathInfo: TreePathInfo[]) => {
const treePath = extractTreePathInfo(treePathInfo);
const joinedTreePath = treePath.join(',');
const value = treePath[treePath.length - 1];
const isCurrentValueSelected =
Object.values(selectedValues).includes(joinedTreePath);
if (!columns?.length || isCurrentValueSelected) {
return {
dataMask: {
extraFormData: {
filters: [],
},
filterState: {
value: null,
selectedValues: [],
},
},
isCurrentValueSelected,
};
const name = treePath.join(',');
const selected = Object.values(selectedValues);
let values: string[];
if (selected.includes(name)) {
values = selected.filter(v => v !== name);
} else {
values = [name];
}
const labels = values.map(value => labelMap[value]);
return {
dataMask: {
extraFormData: {
filters: [
{
col: columns[treePath.length - 1],
op: '==' as const,
val: value,
},
],
filters:
values.length === 0 || !columns
? []
: columns.slice(0, treePath.length).map((col, idx) => {
const val = labels.map(v => v[idx]);
if (val === null || val === undefined)
return {
col,
op: 'IS NULL' as const,
};
return {
col,
op: 'IN' as const,
val: val as (string | number | boolean)[],
};
}),
},
filterState: {
value,
selectedValues: [joinedTreePath],
value: labels.length ? labels : null,
selectedValues: values.length ? values : null,
},
},
isCurrentValueSelected,
isCurrentValueSelected: selected.includes(name),
};
},
[columns, selectedValues],
[columns, labelMap, selectedValues],
);
const handleChange = useCallback(
@@ -102,7 +101,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) {
setDataMask(getCrossFilterDataMask(treePathInfo).dataMask);
},
[emitCrossFilters, columns?.length, setDataMask, getCrossFilterDataMask],
[emitCrossFilters, setDataMask, getCrossFilterDataMask],
);
const eventHandlers: EventHandlers = {

View File

@@ -71,7 +71,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
seriesType: EchartsTimeseriesSeriesType.Line,
stack: false,
tooltipTimeFormat: 'smart_date',
xAxisTimeFormat: 'smart_date',
truncateXAxis: true,
truncateYAxis: false,
yAxisBounds: [null, null],

View File

@@ -174,8 +174,6 @@ function Echart(
if (!chartRef.current) {
chartRef.current = init(divRef.current, null, { locale });
}
// did mount
handleSizeChange({ width, height });
setDidMount(true);
});
}, [locale]);
@@ -237,6 +235,9 @@ function Echart(
echartOptions,
);
chartRef.current?.setOption(themedEchartOptions, true);
// did mount
handleSizeChange({ width, height });
}
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme]);

View File

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

View File

@@ -116,48 +116,49 @@ const chartPropsConfig = {
theme: supersetTheme,
};
it('should transform chart props for viz with showQueryIdentifiers=false', () => {
const chartPropsConfigWithoutIdentifiers = {
...chartPropsConfig,
formData: {
...formData,
showQueryIdentifiers: false,
},
};
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
it('should transform chart props for viz', () => {
const chartProps = new ChartProps(chartPropsConfig);
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
// Check that series IDs don't include query identifiers
const seriesIds = (transformed.echartOptions.series as any[]).map(
(s: any) => s.id,
expect(transformed).toEqual(
expect.objectContaining({
echartOptions: expect.objectContaining({
series: expect.arrayContaining([
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query A), boy',
stack: 'obs\na',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query A), girl',
stack: 'obs\na',
}),
// Query B — Bar series
expect.objectContaining({
data: [
[599616000000, 1],
[599916000000, 3],
],
id: 'sum__num (Query B), boy',
stack: 'obs\nb',
}),
expect.objectContaining({
data: [
[599616000000, 2],
[599916000000, 4],
],
id: 'sum__num (Query B), girl',
stack: 'obs\nb',
}),
]),
}),
}),
);
expect(seriesIds).toContain('sum__num, girl');
expect(seriesIds).toContain('sum__num, boy');
expect(seriesIds).not.toContain('sum__num (Query A), girl');
expect(seriesIds).not.toContain('sum__num (Query A), boy');
expect(seriesIds).not.toContain('sum__num (Query B), girl');
expect(seriesIds).not.toContain('sum__num (Query B), boy');
});
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
const chartPropsConfigWithIdentifiers = {
...chartPropsConfig,
formData: {
...formData,
showQueryIdentifiers: true,
},
};
const chartProps = new ChartProps(chartPropsConfigWithIdentifiers);
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
// Check that series IDs include query identifiers
const seriesIds = (transformed.echartOptions.series as any[]).map(
(s: any) => s.id,
);
expect(seriesIds).toContain('sum__num (Query A), girl');
expect(seriesIds).toContain('sum__num (Query A), boy');
expect(seriesIds).toContain('sum__num (Query B), girl');
expect(seriesIds).toContain('sum__num (Query B), boy');
expect(seriesIds).not.toContain('sum__num, girl');
expect(seriesIds).not.toContain('sum__num, boy');
});

View File

@@ -221,157 +221,6 @@ describe('Pie label string template', () => {
});
});
describe('Total value positioning with legends', () => {
const getChartPropsWithLegend = (
showTotal = true,
showLegend = true,
legendOrientation = 'right',
donut = true,
): EchartsPieChartProps => {
const formData: SqlaFormData = {
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: 'ds',
metric: 'sum__num',
groupby: ['category'],
viz_type: 'pie',
show_total: showTotal,
show_legend: showLegend,
legend_orientation: legendOrientation,
donut,
};
return new ChartProps({
formData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ category: 'A', sum__num: 10, sum__num__contribution: 0.4 },
{ category: 'B', sum__num: 15, sum__num__contribution: 0.6 },
],
},
],
theme: supersetTheme,
}) as EchartsPieChartProps;
};
it('should center total text when legend is on the right', () => {
const props = getChartPropsWithLegend(true, true, 'right', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
top: 'middle',
style: expect.objectContaining({
text: expect.stringContaining('Total:'),
}),
}),
);
// The left position should be less than 50% (shifted left)
const leftValue = parseFloat(
(transformed.echartOptions.graphic as any).left.replace('%', ''),
);
expect(leftValue).toBeLessThan(50);
expect(leftValue).toBeGreaterThan(30); // Should be reasonable positioning
});
it('should center total text when legend is on the left', () => {
const props = getChartPropsWithLegend(true, true, 'left', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: expect.stringMatching(/^\d+(\.\d+)?%$/),
top: 'middle',
}),
);
// The left position should be greater than 50% (shifted right)
const leftValue = parseFloat(
(transformed.echartOptions.graphic as any).left.replace('%', ''),
);
expect(leftValue).toBeGreaterThan(50);
expect(leftValue).toBeLessThan(70); // Should be reasonable positioning
});
it('should center total text when legend is on top', () => {
const props = getChartPropsWithLegend(true, true, 'top', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
}),
);
// The top position should be adjusted for top legend
const topValue = parseFloat(
(transformed.echartOptions.graphic as any).top.replace('%', ''),
);
expect(topValue).toBeGreaterThan(50); // Shifted down for top legend
});
it('should center total text when legend is on bottom', () => {
const props = getChartPropsWithLegend(true, true, 'bottom', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: expect.stringMatching(/^\d+(\.\d+)?%$/),
}),
);
// The top position should be adjusted for bottom legend
const topValue = parseFloat(
(transformed.echartOptions.graphic as any).top.replace('%', ''),
);
expect(topValue).toBeLessThan(50); // Shifted up for bottom legend
});
it('should use default positioning when no legend is shown', () => {
const props = getChartPropsWithLegend(true, false, 'right', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
left: 'center',
top: 'middle',
}),
);
});
it('should handle regular pie chart (non-donut) positioning', () => {
const props = getChartPropsWithLegend(true, true, 'right', false);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toEqual(
expect.objectContaining({
type: 'text',
top: '0', // Non-donut charts use '0' as default top position
left: expect.stringMatching(/^\d+(\.\d+)?%$/), // Should still adjust left for right legend
}),
);
});
it('should not show total graphic when showTotal is false', () => {
const props = getChartPropsWithLegend(false, true, 'right', true);
const transformed = transformProps(props);
expect(transformed.echartOptions.graphic).toBeNull();
});
});
describe('Other category', () => {
const defaultFormData: SqlaFormData = {
colorScheme: 'bnbColors',

View File

@@ -1,204 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import controlPanel from '../../../src/Timeseries/Regular/Bar/controlPanel';
describe('Bar Chart Control Panel', () => {
describe('x_axis_time_format control', () => {
it('should include x_axis_time_format control in the panel', () => {
const config = controlPanel;
// Look for x_axis_time_format control in all sections and rows
let foundTimeFormatControl = false;
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === 'x_axis_time_format'
) {
foundTimeFormatControl = true;
break;
}
}
if (foundTimeFormatControl) break;
}
if (foundTimeFormatControl) break;
}
}
expect(foundTimeFormatControl).toBe(true);
});
it('should have correct default value for x_axis_time_format', () => {
const config = controlPanel;
// Find the x_axis_time_format control
let timeFormatControl: any = null;
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === 'x_axis_time_format'
) {
timeFormatControl = control;
break;
}
}
if (timeFormatControl) break;
}
if (timeFormatControl) break;
}
}
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config).toBeDefined();
expect(timeFormatControl.config.default).toBe('smart_date');
});
it('should have visibility function for x_axis_time_format', () => {
const config = controlPanel;
// Find the x_axis_time_format control
let timeFormatControl: any = null;
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === 'x_axis_time_format'
) {
timeFormatControl = control;
break;
}
}
if (timeFormatControl) break;
}
if (timeFormatControl) break;
}
}
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config.visibility).toBeDefined();
expect(typeof timeFormatControl.config.visibility).toBe('function');
// The visibility function exists - the exact logic is tested implicitly through UI behavior
// The important part is that the control has proper visibility configuration
});
it('should have proper control configuration', () => {
const config = controlPanel;
// Find the x_axis_time_format control
let timeFormatControl: any = null;
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === 'x_axis_time_format'
) {
timeFormatControl = control;
break;
}
}
if (timeFormatControl) break;
}
if (timeFormatControl) break;
}
}
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config).toMatchObject({
default: 'smart_date',
disableStash: true,
resetOnHide: false,
});
// Should have a description that includes D3 time format docs
expect(timeFormatControl.config.description).toContain('D3');
});
});
describe('Control panel structure for bar charts', () => {
it('should have Chart Orientation section', () => {
const config = controlPanel;
const orientationSection = config.controlPanelSections.find(
section => section && section.label === 'Chart Orientation',
);
expect(orientationSection).toBeDefined();
expect(orientationSection!.expanded).toBe(true);
});
it('should have Chart Options section with X Axis controls', () => {
const config = controlPanel;
const chartOptionsSection = config.controlPanelSections.find(
section => section && section.label === 'Chart Options',
);
expect(chartOptionsSection).toBeDefined();
expect(chartOptionsSection!.expanded).toBe(true);
// Should contain X Axis subsection header - this is sufficient proof
expect(chartOptionsSection!.controlSetRows).toBeDefined();
expect(chartOptionsSection!.controlSetRows!.length).toBeGreaterThan(0);
});
it('should have proper form data overrides', () => {
const config = controlPanel;
expect(config.formDataOverrides).toBeDefined();
expect(typeof config.formDataOverrides).toBe('function');
// Test the form data override function
const mockFormData = {
datasource: '1__table',
viz_type: 'echarts_timeseries_bar',
metrics: ['test_metric'],
groupby: ['test_column'],
other_field: 'test',
};
const result = config.formDataOverrides!(mockFormData);
expect(result).toHaveProperty('metrics');
expect(result).toHaveProperty('groupby');
expect(result).toHaveProperty('other_field', 'test');
});
});
});

View File

@@ -1,353 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, SqlaFormData, supersetTheme } from '@superset-ui/core';
import { EchartsTimeseriesChartProps } from '../../../src/types';
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import { EchartsTimeseriesSeriesType } from '../../../src/Timeseries/types';
describe('Bar Chart X-axis Time Formatting', () => {
const baseFormData: SqlaFormData = {
...DEFAULT_FORM_DATA,
colorScheme: 'bnbColors',
datasource: '3__table',
granularity_sqla: '__timestamp',
metric: ['Sales', 'Marketing', 'Operations'],
groupby: [],
viz_type: 'echarts_timeseries_bar',
seriesType: EchartsTimeseriesSeriesType.Bar,
orientation: 'vertical',
};
const timeseriesData = [
{
data: [
{ Sales: 100, __timestamp: 1609459200000 }, // 2021-01-01
{ Marketing: 150, __timestamp: 1612137600000 }, // 2021-02-01
{ Operations: 200, __timestamp: 1614556800000 }, // 2021-03-01
],
colnames: ['Sales', 'Marketing', 'Operations', '__timestamp'],
coltypes: ['BIGINT', 'BIGINT', 'BIGINT', 'TIMESTAMP'],
},
];
const baseChartPropsConfig = {
width: 800,
height: 600,
queriesData: timeseriesData,
theme: supersetTheme,
};
describe('Default xAxisTimeFormat', () => {
it('should use smart_date as default xAxisTimeFormat', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// Check that the x-axis has a formatter applied
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
});
it('should apply xAxisTimeFormat from DEFAULT_FORM_DATA when not explicitly set', () => {
const formDataWithoutTimeFormat = {
...baseFormData,
};
delete formDataWithoutTimeFormat.xAxisTimeFormat;
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: formDataWithoutTimeFormat,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// Should still have a formatter since DEFAULT_FORM_DATA includes xAxisTimeFormat
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
});
});
describe('Custom xAxisTimeFormat', () => {
it('should respect custom xAxisTimeFormat when explicitly set', () => {
const customFormData = {
...baseFormData,
xAxisTimeFormat: '%Y-%m-%d',
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: customFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// Verify the formatter function exists and is applied
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
// The key test is that a formatter exists - the actual formatting is handled by d3-time-format
const { formatter } = xAxis.axisLabel;
expect(formatter).toBeDefined();
expect(typeof formatter).toBe('function');
});
it('should handle different time format options', () => {
const timeFormats = [
'%Y-%m-%d',
'%Y/%m/%d',
'%m/%d/%Y',
'%b %d, %Y',
'smart_date',
];
timeFormats.forEach(timeFormat => {
const customFormData = {
...baseFormData,
xAxisTimeFormat: timeFormat,
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: customFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
});
});
});
describe('Orientation-specific behavior', () => {
it('should apply time formatting to x-axis in vertical bar charts', () => {
const verticalFormData = {
...baseFormData,
orientation: 'vertical',
xAxisTimeFormat: '%Y-%m',
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: verticalFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// In vertical orientation, time should be on x-axis
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
});
it('should apply time formatting to y-axis in horizontal bar charts', () => {
const horizontalFormData = {
...baseFormData,
orientation: 'horizontal',
xAxisTimeFormat: '%Y-%m',
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: horizontalFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// In horizontal orientation, axes are swapped, so time should be on y-axis
const yAxis = transformedProps.echartOptions.yAxis as any;
expect(yAxis.axisLabel).toHaveProperty('formatter');
expect(typeof yAxis.axisLabel.formatter).toBe('function');
});
});
describe('Integration with existing features', () => {
it('should work with axis bounds', () => {
const formDataWithBounds = {
...baseFormData,
xAxisTimeFormat: '%Y-%m-%d',
truncateXAxis: true,
xAxisBounds: [null, null] as [number | null, number | null],
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: formDataWithBounds,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
// The xAxis should be configured with the time formatting
expect(transformedProps.echartOptions.xAxis).toBeDefined();
});
it('should work with label rotation', () => {
const formDataWithRotation = {
...baseFormData,
xAxisTimeFormat: '%Y-%m-%d',
xAxisLabelRotation: 45,
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: formDataWithRotation,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(xAxis.axisLabel).toHaveProperty('rotate', 45);
});
it('should maintain time formatting consistency with tooltip', () => {
const formDataWithTooltip = {
...baseFormData,
xAxisTimeFormat: '%Y-%m-%d',
tooltipTimeFormat: '%Y-%m-%d',
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: formDataWithTooltip,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// Both axis and tooltip should have formatters
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(transformedProps.xValueFormatter).toBeDefined();
expect(typeof transformedProps.xValueFormatter).toBe('function');
});
});
describe('Regression test for Issue #30373', () => {
it('should not be stuck on adaptive formatting', () => {
// Test the exact scenario described in the issue
const issueFormData = {
...baseFormData,
xAxisTimeFormat: '%Y-%m-%d %H:%M:%S', // Non-adaptive format
};
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: issueFormData,
});
const transformedProps = transformProps(
chartProps as EchartsTimeseriesChartProps,
);
// Verify formatter exists - this is the key fix, ensuring xAxisTimeFormat is used
const xAxis = transformedProps.echartOptions.xAxis as any;
const { formatter } = xAxis.axisLabel;
expect(formatter).toBeDefined();
expect(typeof formatter).toBe('function');
// The important part is that the xAxisTimeFormat is being used from formData
// The actual formatting is handled by the underlying time formatter
});
it('should allow changing from smart_date to other formats', () => {
// First create with smart_date (default)
const smartDateFormData = {
...baseFormData,
xAxisTimeFormat: 'smart_date',
};
const smartDateChartProps = new ChartProps({
...baseChartPropsConfig,
formData: smartDateFormData,
});
const smartDateProps = transformProps(
smartDateChartProps as EchartsTimeseriesChartProps,
);
// Then change to a different format
const customFormatFormData = {
...baseFormData,
xAxisTimeFormat: '%b %Y',
};
const customFormatChartProps = new ChartProps({
...baseChartPropsConfig,
formData: customFormatFormData,
});
const customFormatProps = transformProps(
customFormatChartProps as EchartsTimeseriesChartProps,
);
// Both should have formatters - the key is that they're not undefined
const smartDateXAxis = smartDateProps.echartOptions.xAxis as any;
const customFormatXAxis = customFormatProps.echartOptions.xAxis as any;
expect(smartDateXAxis.axisLabel.formatter).toBeDefined();
expect(customFormatXAxis.axisLabel.formatter).toBeDefined();
// Both should be functions that can format time
expect(typeof smartDateXAxis.axisLabel.formatter).toBe('function');
expect(typeof customFormatXAxis.axisLabel.formatter).toBe('function');
});
it('should have xAxisTimeFormat in formData by default', () => {
// This test specifically verifies our fix - that DEFAULT_FORM_DATA includes xAxisTimeFormat
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
expect(chartProps.formData.xAxisTimeFormat).toBeDefined();
expect(chartProps.formData.xAxisTimeFormat).toBe('smart_date');
});
});
});

View File

@@ -1,43 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { DEFAULT_FORM_DATA } from '../../src/Timeseries/constants';
describe('Timeseries constants', () => {
describe('DEFAULT_FORM_DATA', () => {
it('should include xAxisTimeFormat in default form data', () => {
expect(DEFAULT_FORM_DATA).toHaveProperty('xAxisTimeFormat');
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe('smart_date');
});
it('should include tooltipTimeFormat in default form data', () => {
expect(DEFAULT_FORM_DATA).toHaveProperty('tooltipTimeFormat');
expect(DEFAULT_FORM_DATA.tooltipTimeFormat).toBe('smart_date');
});
it('should have consistent time format defaults', () => {
expect(DEFAULT_FORM_DATA.xAxisTimeFormat).toBe(
DEFAULT_FORM_DATA.tooltipTimeFormat,
);
});
it('should have vertical orientation as default', () => {
expect(DEFAULT_FORM_DATA.orientation).toBe('vertical');
});
});
});

View File

@@ -34,12 +34,6 @@ const parseLabel = value => {
return String(value);
};
function displayCell(value, allowRenderHtml) {
if (allowRenderHtml && typeof value === 'string') {
return safeHtmlSpan(value);
}
return parseLabel(value);
}
function displayHeaderCell(
needToggle,
ArrowIcon,
@@ -748,7 +742,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
style={style}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -765,7 +759,7 @@ export class TableRenderer extends Component {
onClick={rowTotalCallbacks[flatRowKey]}
onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
>
{displayCell(agg.format(aggValue), allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
}
@@ -829,7 +823,7 @@ export class TableRenderer extends Component {
onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
style={{ padding: '5px' }}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
});
@@ -846,7 +840,7 @@ export class TableRenderer extends Component {
onClick={grandTotalCallback}
onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
>
{displayCell(agg.format(aggValue), this.props.allowRenderHtml)}
{agg.format(aggValue)}
</td>
);
}

View File

@@ -59,6 +59,7 @@ import {
Space,
RawAntdSelect as Select,
Dropdown,
Menu,
Tooltip,
} from '@superset-ui/core/components';
import {
@@ -563,62 +564,52 @@ export default function TableChart<D extends DataRecord = DataRecord>(
return (
<Dropdown
placement="bottomRight"
open={showComparisonDropdown}
onOpenChange={(flag: boolean) => {
visible={showComparisonDropdown}
onVisibleChange={(flag: boolean) => {
setShowComparisonDropdown(flag);
}}
menu={{
multiple: true,
onClick: handleOnClick,
onBlur: handleOnBlur,
selectedKeys: selectedComparisonColumns,
items: [
{
key: 'all',
label: (
<div
overlay={
<Menu
multiple
onClick={handleOnClick}
onBlur={handleOnBlur}
selectedKeys={selectedComparisonColumns}
>
<div
css={css`
max-width: 242px;
padding: 0 ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
font-size: ${theme.fontSizeSM}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
)}
</div>
{comparisonColumns.map(column => (
<Menu.Item key={column.key}>
<span
css={css`
max-width: 242px;
padding: 0 ${theme.sizeUnit * 2}px;
color: ${theme.colorText};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.fontSizeSM}px;
`}
>
{t(
'Select columns that will be displayed in the table. You can multiselect columns.',
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</div>
),
type: 'group',
children: comparisonColumns.map(
(column: { key: string; label: string }) => ({
key: column.key,
label: (
<>
<span
css={css`
color: ${theme.colorText};
`}
>
{column.label}
</span>
<span
css={css`
float: right;
font-size: ${theme.fontSizeSM}px;
`}
>
{selectedComparisonColumns.includes(column.key) && (
<CheckOutlined />
)}
</span>
</>
),
}),
),
},
],
}}
</span>
</Menu.Item>
))}
</Menu>
}
trigger={['click']}
>
<span>

View File

@@ -646,7 +646,7 @@ const config: ControlPanelConfig = {
name: 'show_cell_bars',
config: {
type: 'CheckboxControl',
label: t('Show cell bars'),
label: t('Show Cell bars'),
renderTrigger: true,
default: true,
description: t(
@@ -674,7 +674,7 @@ const config: ControlPanelConfig = {
name: 'color_pn',
config: {
type: 'CheckboxControl',
label: t('Add colors to cell bars for +/-'),
label: t('add colors to cell bars for +/-'),
renderTrigger: true,
default: true,
description: t(
@@ -688,7 +688,7 @@ const config: ControlPanelConfig = {
name: 'comparison_color_enabled',
config: {
type: 'CheckboxControl',
label: t('Basic conditional formatting'),
label: t('basic conditional formatting'),
renderTrigger: true,
visibility: ({ controls }) =>
!isEmpty(controls?.time_compare?.value),
@@ -729,7 +729,7 @@ const config: ControlPanelConfig = {
config: {
type: 'ConditionalFormattingControl',
renderTrigger: true,
label: t('Custom conditional formatting'),
label: t('Custom Conditional Formatting'),
extraColorChoices: [
{
value: ColorSchemeEnum.Green,

View File

@@ -63,7 +63,7 @@ export enum ContextMenuItem {
export interface ChartContextMenuProps {
id: number;
formData: QueryFormData;
onSelection: (args?: any) => void;
onSelection: () => void;
onClose: () => void;
additionalConfig?: {
crossFilter?: Record<string, any>;
@@ -123,12 +123,6 @@ const ChartContextMenu = (
const [dataset, setDataset] = useState<Dataset>();
const verboseMap = useVerboseMap(dataset);
const closeContextMenu = useCallback(() => {
setVisible(false);
setOpenKeys([]);
onClose();
}, [onClose]);
const handleDrillBy = useCallback((column: Column, dataset: Dataset) => {
setDrillByColumn(column);
setDataset(dataset); // Save dataset when drilling
@@ -270,7 +264,6 @@ const ChartContextMenu = (
<DrillByMenuItems
drillByConfig={filters?.drillBy}
onSelection={onSelection}
onCloseMenu={closeContextMenu}
formData={formData}
contextMenuY={clientY}
submenuIndex={submenuIndex}
@@ -318,7 +311,6 @@ const ChartContextMenu = (
onOpenChange={setOpenKeys}
onClick={() => {
setVisible(false);
setOpenKeys([]);
onClose();
}}
>

View File

@@ -166,12 +166,8 @@ test('render menu item with submenu without searchbox', async () => {
renderMenu({});
await waitFor(() => fetchMock.called(DATASET_ENDPOINT));
await expectDrillByEnabled();
// Check that each column appears in the drill-by submenu
slicedColumns.forEach(column => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0]; // Use the first submenu
expect(within(submenu).getByText(column.column_name)).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
});
@@ -190,19 +186,15 @@ test('render menu item with submenu and searchbox', async () => {
// Wait for all columns to be visible
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const searchbox = await waitFor(
() => screen.getAllByPlaceholderText('Search columns')[0],
() => screen.getAllByPlaceholderText('Search columns')[1],
);
expect(searchbox).toBeInTheDocument();
@@ -212,26 +204,19 @@ test('render menu item with submenu and searchbox', async () => {
// Wait for filtered results
await waitFor(() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns
.filter(col => !expectedFilteredColumnNames.includes(col.column_name))
.forEach(col => {
expect(
within(submenu).queryByText(col.column_name),
).not.toBeInTheDocument();
expect(screen.queryByText(col.column_name)).not.toBeInTheDocument();
});
expectedFilteredColumnNames.forEach(colName => {
expect(within(submenu).getByText(colName)).toBeInTheDocument();
expect(screen.getByText(colName)).toBeInTheDocument();
});
});
@@ -253,23 +238,17 @@ test('Do not display excluded column in the menu', async () => {
// Wait for menu items to be loaded
await waitFor(
() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
defaultColumns
.filter(column => !excludedColNames.includes(column.column_name))
.forEach(column => {
expect(
within(submenu).getByText(column.column_name),
).toBeInTheDocument();
expect(screen.getByText(column.column_name)).toBeInTheDocument();
});
},
{ timeout: 10000 },
);
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
excludedColNames.forEach(colName => {
expect(within(submenu).queryByText(colName)).not.toBeInTheDocument();
expect(screen.queryByText(colName)).not.toBeInTheDocument();
});
});
@@ -290,11 +269,7 @@ test('When menu item is clicked, call onSelection with clicked column and drill
await expectDrillByEnabled();
// Wait for col1 to be visible before clicking
const col1Element = await waitFor(() => {
const submenus = screen.getAllByTestId('drill-by-submenu');
const submenu = submenus[0];
return within(submenu).getByText('col1');
});
const col1Element = await waitFor(() => screen.getByText('col1'));
userEvent.click(col1Element);
expect(onSelectionMock).toHaveBeenCalledWith(

View File

@@ -54,7 +54,7 @@ import {
import { InputRef } from 'antd';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { getSubmenuYOffset } from '../utils';
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
import { MenuItemWithTruncation } from '../MenuItemWithTruncation';
import { Dataset } from '../types';
const SUBMENU_HEIGHT = 200;
@@ -68,7 +68,6 @@ export interface DrillByMenuItemsProps {
submenuIndex?: number;
onSelection?: (...args: any) => void;
onClick?: (event: MouseEvent) => void;
onCloseMenu?: () => void;
openNewModal?: boolean;
excludedColumns?: Column[];
open: boolean;
@@ -101,7 +100,6 @@ export const DrillByMenuItems = ({
submenuIndex = 0,
onSelection = () => {},
onClick = () => {},
onCloseMenu = () => {},
excludedColumns,
openNewModal = true,
open,
@@ -126,7 +124,6 @@ export const DrillByMenuItems = ({
if (openNewModal && onDrillBy && dataset) {
onDrillBy(column, dataset);
}
onCloseMenu();
},
[drillByConfig, onClick, onSelection, openNewModal, onDrillBy, dataset],
);
@@ -267,14 +264,15 @@ export const DrillByMenuItems = ({
const { columns, ...rest } = data;
const column = columns[index];
return (
<VirtualizedMenuItem
<MenuItemWithTruncation
menuKey={`drill-by-item-${column.column_name}`}
tooltipText={column.verbose_name || column.column_name}
onClick={e => handleSelection(e, column)}
style={style}
{...rest}
>
{column.verbose_name || column.column_name}
</VirtualizedMenuItem>
</MenuItemWithTruncation>
);
};

View File

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

View File

@@ -1,269 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
Dispatch,
ReactNode,
SetStateAction,
useCallback,
useMemo,
} from 'react';
import { isEmpty } from 'lodash';
import {
Behavior,
BinaryQueryObjectFilterClause,
css,
extractQueryFields,
getChartMetadataRegistry,
QueryFormData,
removeHTMLTags,
styled,
t,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { RootState } from 'src/dashboard/types';
import { getSubmenuYOffset } from '../utils';
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
import { useMenuItemWithTruncation } from '../MenuItemWithTruncation';
const DRILL_TO_DETAIL = t('Drill to detail');
const DRILL_TO_DETAIL_BY = t('Drill to detail by');
const DISABLED_REASONS = {
DATABASE: t(
'Drill to detail is disabled for this database. Change the database settings to enable it.',
),
NO_AGGREGATIONS: t(
'Drill to detail is disabled because this chart does not group data by dimension value.',
),
NO_FILTERS: t(
'Right-click on a dimension value to drill to detail by that value.',
),
NOT_SUPPORTED: t(
'Drill to detail by value is not yet supported for this chart type.',
),
};
function getDisabledMenuItem(
children: ReactNode,
menuKey: string,
...rest: unknown[]
): MenuItem {
return {
disabled: true,
key: menuKey,
label: (
<div
css={css`
white-space: normal;
max-width: 160px;
`}
>
{children}
</div>
),
...rest,
};
}
const Filter = ({
children,
stripHTML = false,
}: {
children: ReactNode;
stripHTML: boolean;
}) => {
const content =
stripHTML && typeof children === 'string'
? removeHTMLTags(children)
: children;
return <span>{content}</span>;
};
const StyledFilter = styled(Filter)`
${({ theme }) => `
font-weight: ${theme.fontWeightStrong};
color: ${theme.colorPrimary};
`}
`;
export type DrillDetailMenuItemsArgs = {
formData: QueryFormData;
filters?: BinaryQueryObjectFilterClause[];
setFilters: Dispatch<SetStateAction<BinaryQueryObjectFilterClause[]>>;
isContextMenu?: boolean;
contextMenuY?: number;
onSelection?: () => void;
onClick?: (event: MouseEvent) => void;
submenuIndex?: number;
setShowModal: (show: boolean) => void;
key?: string;
forceSubmenuRender?: boolean;
};
export const useDrillDetailMenuItems = ({
formData,
filters = [],
isContextMenu = false,
contextMenuY = 0,
onSelection = () => null,
onClick = () => null,
submenuIndex = 0,
setFilters,
setShowModal,
key,
...props
}: DrillDetailMenuItemsArgs) => {
const drillToDetailDisabled = useSelector<RootState, boolean | undefined>(
({ datasources }) =>
datasources[formData.datasource]?.database?.disable_drill_to_detail,
);
const openModal = useCallback(
(filters, event) => {
onClick(event);
onSelection();
setFilters(filters);
setShowModal(true);
},
[onClick, onSelection],
);
// Check for Behavior.DRILL_TO_DETAIL to tell if plugin handles the `contextmenu`
// event for dimensions. If it doesn't, tell the user that drill to detail by
// dimension is not supported. If it does, and the `contextmenu` handler didn't
// pass any filters, tell the user that they didn't select a dimension.
const handlesDimensionContextMenu = useMemo(
() =>
getChartMetadataRegistry()
.get(formData.viz_type)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail),
[formData.viz_type],
);
// Check metrics to see if chart's current configuration lacks
// aggregations, in which case Drill to Detail should be disabled.
const noAggregations = useMemo(() => {
const { metrics } = extractQueryFields(formData);
return isEmpty(metrics);
}, [formData]);
// Ensure submenu doesn't appear offscreen
const submenuYOffset = useMemo(
() =>
getSubmenuYOffset(
contextMenuY,
filters.length > 1 ? filters.length + 1 : filters.length,
submenuIndex,
),
[contextMenuY, filters.length, submenuIndex],
);
let drillDisabled;
let drillByDisabled;
if (drillToDetailDisabled) {
drillDisabled = DISABLED_REASONS.DATABASE;
drillByDisabled = DISABLED_REASONS.DATABASE;
} else if (handlesDimensionContextMenu) {
if (noAggregations) {
drillDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
drillByDisabled = DISABLED_REASONS.NO_AGGREGATIONS;
} else if (!filters?.length) {
drillByDisabled = DISABLED_REASONS.NO_FILTERS;
}
} else {
drillByDisabled = DISABLED_REASONS.NOT_SUPPORTED;
}
const drillToDetailMenuItem: MenuItem = drillDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL}
<MenuItemTooltip title={drillDisabled} />
</>,
'drill-to-detail-disabled',
props,
)
: {
key: 'drill-to-detail',
label: DRILL_TO_DETAIL,
onClick: openModal.bind(null, []),
...props,
};
const getMenuItemWithTruncation = useMenuItemWithTruncation();
const drillToDetailByMenuItem: MenuItem = drillByDisabled
? getDisabledMenuItem(
<>
{DRILL_TO_DETAIL_BY}
<MenuItemTooltip title={drillByDisabled} />
</>,
'drill-to-detail-by-disabled',
props,
)
: {
key: key || 'drill-to-detail-by',
label: DRILL_TO_DETAIL_BY,
children: [
...filters.map((filter, i) => ({
key: `drill-detail-filter-${i}`,
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${filter.formattedVal}`,
onClick: openModal.bind(null, [filter]),
key: `drill-detail-filter-${i}`,
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML>{filter.formattedVal}</StyledFilter>
</>
),
}),
})),
filters.length > 1 && {
key: 'drill-detail-filter-all',
label: getMenuItemWithTruncation({
tooltipText: `${DRILL_TO_DETAIL_BY} ${t('all')}`,
onClick: openModal.bind(null, filters),
key: 'drill-detail-filter-all',
children: (
<>
{`${DRILL_TO_DETAIL_BY} `}
<StyledFilter stripHTML={false}>{t('all')}</StyledFilter>
</>
),
}),
},
].filter(Boolean) as MenuItem[],
onClick: openModal.bind(null, filters),
forceSubmenuRender: true,
popupOffset: [0, submenuYOffset],
popupClassName: 'chart-context-submenu',
...props,
};
if (isContextMenu) {
return {
drillToDetailMenuItem,
drillToDetailByMenuItem,
};
}
return {
drillToDetailMenuItem,
};
};

View File

@@ -18,14 +18,9 @@
*/
import { ReactNode, CSSProperties, useCallback } from 'react';
import {
css,
truncationCSS,
useCSSTextTruncation,
useTheme,
} from '@superset-ui/core';
import { css, truncationCSS, useCSSTextTruncation } from '@superset-ui/core';
import { Menu, type ItemType } from '@superset-ui/core/components/Menu';
import { Flex, Tooltip } from '@superset-ui/core/components';
import { Tooltip } from '@superset-ui/core/components';
import { MenuItemProps } from 'antd';
export type MenuItemWithTruncationProps = {
@@ -118,12 +113,7 @@ export const MenuItemWithTruncation = ({
onClick={onClick}
style={style}
>
<Tooltip
title={itemIsTruncated ? tooltipText : null}
css={css`
max-width: 200px;
`}
>
<Tooltip title={itemIsTruncated ? tooltipText : null}>
<div
ref={itemRef}
css={css`
@@ -137,50 +127,3 @@ export const MenuItemWithTruncation = ({
</Menu.Item>
);
};
export const VirtualizedMenuItem = ({
tooltipText,
children,
onClick,
style,
}: {
tooltipText: ReactNode;
children: ReactNode;
onClick?: (e: React.MouseEvent) => void;
style?: CSSProperties;
}) => {
const theme = useTheme();
const [itemRef, itemIsTruncated] = useCSSTextTruncation<HTMLDivElement>();
return (
<Flex
role="menuitem"
tabIndex={0}
onClick={onClick}
align="center"
style={style}
css={css`
cursor: pointer;
padding-left: ${theme.paddingXS}px;
&:hover {
background-color: ${theme.colorBgTextHover};
}
&:active {
background-color: ${theme.colorBgTextActive};
}
`}
>
<Tooltip title={itemIsTruncated ? tooltipText : null}>
<div
ref={itemRef}
css={css`
max-width: 100%;
${truncationCSS};
`}
>
{children}
</div>
</Tooltip>
</Flex>
);
};

View File

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

View File

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

View File

@@ -16,29 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
act,
fireEvent,
render,
screen,
within,
cleanup,
} from 'spec/helpers/testing-library';
import { act, fireEvent, render, screen } from 'spec/helpers/testing-library';
import { store } from 'src/views/store';
import { isFeatureEnabled } from '@superset-ui/core';
import { FacePile } from '.';
import { getRandomColor } from './utils';
// Mock the feature flag
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(),
}));
const mockIsFeatureEnabled = isFeatureEnabled as jest.MockedFunction<
typeof isFeatureEnabled
>;
const users = [...new Array(10)].map((_, i) => ({
first_name: 'user',
last_name: `${i}`,
@@ -47,99 +29,37 @@ const users = [...new Array(10)].map((_, i) => ({
beforeEach(() => {
jest.useFakeTimers();
// Default to Slack avatars disabled
mockIsFeatureEnabled.mockImplementation(() => false);
});
afterEach(() => {
jest.useRealTimers();
mockIsFeatureEnabled.mockReset();
cleanup();
});
describe('FacePile', () => {
it('renders empty state with no users', () => {
const { container } = render(<FacePile users={[]} />, { store });
let container: HTMLElement;
expect(container.querySelector('.ant-avatar-group')).toBeInTheDocument();
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(0);
beforeEach(() => {
({ container } = render(<FacePile users={users} />, { store }));
});
it('renders single user without truncation', () => {
const { container } = render(<FacePile users={users.slice(0, 1)} />, {
store,
});
it('is a valid element', () => {
const exposedFaces = screen.getAllByText(/U/);
expect(exposedFaces).toHaveLength(4);
const overflownFaces = screen.getByText('+6');
expect(overflownFaces).toBeVisible();
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(1);
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
});
it('renders multiple users no truncation', () => {
const { container } = render(<FacePile users={users.slice(0, 4)} />, {
store,
});
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(4);
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).getByText('U1')).toBeInTheDocument();
expect(within(container).getByText('U2')).toBeInTheDocument();
expect(within(container).getByText('U3')).toBeInTheDocument();
expect(within(container).queryByText(/\+/)).not.toBeInTheDocument();
});
it('renders multiple users with truncation', () => {
const { container } = render(<FacePile users={users} />, { store });
// Should show 4 avatars + 1 overflow indicator = 5 total elements
const avatars = container.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(5);
// Should show first 4 users
expect(within(container).getByText('U0')).toBeInTheDocument();
expect(within(container).getByText('U1')).toBeInTheDocument();
expect(within(container).getByText('U2')).toBeInTheDocument();
expect(within(container).getByText('U3')).toBeInTheDocument();
// Should show overflow count (+6 because 10 total - 4 shown)
expect(within(container).getByText('+6')).toBeInTheDocument();
});
it('displays user tooltip on hover', () => {
const { container } = render(<FacePile users={users.slice(0, 2)} />, {
store,
});
const firstAvatar = within(container).getByText('U0');
fireEvent.mouseEnter(firstAvatar);
// Display user info when hovering over one of exposed face in the pile.
fireEvent.mouseEnter(exposedFaces[0]);
act(() => jest.runAllTimers());
expect(screen.getByRole('tooltip')).toHaveTextContent('user 0');
});
it('displays avatar images when Slack avatars are enabled', () => {
// Enable Slack avatars feature flag
mockIsFeatureEnabled.mockImplementation(
feature => feature === 'SLACK_ENABLE_AVATARS',
);
it('renders an Avatar', () => {
expect(container.querySelector('.ant-avatar')).toBeVisible();
});
const { container: testContainer } = render(
<FacePile users={users.slice(0, 2)} />,
{
store,
},
);
const avatars = testContainer.querySelectorAll('.ant-avatar');
expect(avatars).toHaveLength(2);
// Should have img elements with correct src attributes
const imgs = testContainer.querySelectorAll('.ant-avatar img');
expect(imgs).toHaveLength(2);
expect(imgs[0]).toHaveAttribute('src', '/api/v1/user/0/avatar.png');
expect(imgs[1]).toHaveAttribute('src', '/api/v1/user/1/avatar.png');
it('hides overflow', () => {
expect(container.querySelectorAll('.ant-avatar')).toHaveLength(5);
});
});

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { tagToSelectOption, loadTags } from 'src/components/Tag/utils';
import { tagToSelectOption } from 'src/components/Tag/utils';
describe('tagToSelectOption', () => {
test('converts a Tag object with table_name to a SelectTagsValue', () => {
@@ -37,166 +35,3 @@ describe('tagToSelectOption', () => {
expect(tagToSelectOption(tag)).toEqual(expectedSelectTagsValue);
});
});
describe('loadTags', () => {
beforeEach(() => {
fetchMock.reset();
});
afterEach(() => {
fetchMock.restore();
});
test('constructs correct API query with custom tag filter', async () => {
const mockTags = [
{ id: 1, name: 'analytics', type: 1 },
{ id: 2, name: 'finance', type: 1 },
];
fetchMock.get('glob:*/api/v1/tag/*', {
result: mockTags,
count: 2,
});
await loadTags('analytics', 0, 25);
// Verify the API was called with correct parameters
const calls = fetchMock.calls();
expect(calls).toHaveLength(1);
const [url] = calls[0];
expect(url).toContain('/api/v1/tag/?q=');
// Extract and decode the query parameter
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Verify the query structure
expect(decodedQuery).toEqual({
filters: [
{ col: 'name', opr: 'ct', value: 'analytics' },
{ col: 'type', opr: 'custom_tag', value: true },
],
page: 0,
page_size: 25,
order_column: 'name',
order_direction: 'asc',
});
});
test('returns correctly transformed data', async () => {
const mockTags = [
{ id: 1, name: 'analytics', type: 1 },
{ id: 2, name: 'finance', type: 1 },
];
fetchMock.get('glob:*/api/v1/tag/*', {
result: mockTags,
count: 2,
});
const result = await loadTags('', 0, 25);
expect(result).toEqual({
data: [
{ value: 1, label: 'analytics', key: 1 },
{ value: 2, label: 'finance', key: 2 },
],
totalCount: 2,
});
});
test('handles search parameter correctly', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('financial-data', 0, 25);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Should include the search term in the name filter
expect(decodedQuery.filters[0]).toEqual({
col: 'name',
opr: 'ct',
value: 'financial-data',
});
});
test('handles pagination parameters correctly', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('', 2, 10);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
expect(decodedQuery.page).toBe(2);
expect(decodedQuery.page_size).toBe(10);
});
test('always includes custom tag filter regardless of other parameters', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
// Test with different combinations of parameters
await loadTags('', 0, 25);
await loadTags('search-term', 1, 50);
await loadTags('another-search', 5, 100);
const calls = fetchMock.calls();
// Verify all calls include the custom tag filter
calls.forEach(call => {
const [url] = call;
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Every call should have the custom tag filter
expect(decodedQuery.filters).toContainEqual({
col: 'type',
opr: 'custom_tag',
value: true,
});
});
});
test('maintains correct order specification', async () => {
fetchMock.get('glob:*/api/v1/tag/*', {
result: [],
count: 0,
});
await loadTags('test', 0, 25);
const calls = fetchMock.calls();
const [url] = calls[0];
const urlObj = new URL(url);
const queryParam = urlObj.searchParams.get('q');
expect(queryParam).not.toBeNull();
const decodedQuery = rison.decode(queryParam!) as Record<string, any>;
// Should always order by name ascending
expect(decodedQuery.order_column).toBe('name');
expect(decodedQuery.order_direction).toBe('asc');
});
});

View File

@@ -78,129 +78,3 @@ test('should render 3 elements when maxTags is set to 3', async () => {
expect(tagsListItems).toHaveLength(3);
expect(tagsListItems[2]).toHaveTextContent('+3...');
});
describe('Tag type filtering', () => {
test('should render only custom type tags (type: 1)', async () => {
const mixedTypeTags = [
{ name: 'custom-tag', type: 1, id: 1 }, // Custom - should show
{ name: 'type:chart', type: 2, id: 2 }, // Type - should be filtered out
{ name: 'owner:admin', type: 3, id: 3 }, // Owner - should be filtered out
{ name: 'another-custom', type: 1, id: 4 }, // Custom - should show
];
// Filter tags like ChartList does - only custom types
const filteredTags = mixedTypeTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should only show 2 custom tags, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('another-custom');
expect(tagsListItems[1]).toHaveTextContent('custom-tag');
});
test('should show tags when type is undefined (fallback case)', async () => {
const undefinedTypeTags = [
{ name: 'legacy-tag', id: 1 }, // No type property - should show due to fallback
{ name: 'custom-tag', type: 1, id: 2 }, // Custom - should show
{ name: 'system-tag', type: 2, id: 3 }, // System - should be filtered out
];
// Apply ChartList filtering logic - undefined type defaults to true
const filteredTags = undefinedTypeTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should show both tags, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('custom-tag');
expect(tagsListItems[1]).toHaveTextContent('legacy-tag');
});
test('should handle legacy TagTypes.custom string format', async () => {
const legacyFormatTags = [
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 1 }, // Legacy string format - should show
{ name: 'modern-custom', type: 1, id: 2 }, // Modern enum - should show
{ name: 'other-type', type: 'TagTypes.other', id: 3 }, // Other legacy type - should be filtered out
];
// Apply ChartList filtering logic - supports both numeric and legacy string
const filteredTags = legacyFormatTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
const tagsListItems = await findAllTags();
// Should show both custom formats, sorted alphabetically
expect(tagsListItems).toHaveLength(2);
expect(tagsListItems[0]).toHaveTextContent('legacy-custom');
expect(tagsListItems[1]).toHaveTextContent('modern-custom');
});
test('should show empty list when all tags are filtered out', async () => {
const nonCustomTags = [
{ name: 'type:chart', type: 2, id: 1 }, // Type tag
{ name: 'owner:admin', type: 3, id: 2 }, // Owner tag
{ name: 'favoritedBy:user', type: 4, id: 3 }, // FavoritedBy tag
];
// Apply ChartList filtering - all should be filtered out
const filteredTags = nonCustomTags.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
setup({ tags: filteredTags, maxTags: 5 });
// Should render container but with no tags
const container = document.querySelector('.tag-list');
expect(container).toBeInTheDocument();
// No tags should be rendered
const tagsListItems = document.querySelectorAll('.ant-tag');
expect(tagsListItems).toHaveLength(0);
});
test('should handle mixed scenarios with truncation', async () => {
const largeMixedTagSet = [
{ name: 'custom-1', type: 1, id: 1 }, // Custom - should show
{ name: 'system-1', type: 2, id: 2 }, // System - filtered out
{ name: 'custom-2', type: 1, id: 3 }, // Custom - should show
{ name: 'legacy-custom', type: 'TagTypes.custom', id: 4 }, // Legacy custom - should show
{ name: 'custom-3', type: 1, id: 5 }, // Custom - should show
{ name: 'owner-tag', type: 3, id: 6 }, // Owner - filtered out
{ name: 'custom-4', type: 1, id: 7 }, // Custom - should show (but truncated)
];
// Apply ChartList filtering - should get 5 custom tags
const filteredTags = largeMixedTagSet.filter(tag =>
tag.type
? tag.type === 1 || String(tag.type) === 'TagTypes.custom'
: true,
);
// Set maxTags to 3 to test truncation of filtered results
setup({ tags: filteredTags, maxTags: 3 });
const tagsListItems = await findAllTags();
// Should show 3 tags: 2 custom tags (alphabetically sorted) + 1 "+3..." truncation indicator
expect(tagsListItems).toHaveLength(3);
expect(tagsListItems[0]).toHaveTextContent('custom-1');
expect(tagsListItems[1]).toHaveTextContent('custom-2');
expect(tagsListItems[2]).toHaveTextContent('+3...');
});
});

View File

@@ -18,16 +18,16 @@
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import { t } from '@superset-ui/core';
import { isEmpty } from 'lodash';
import { URL_PARAMS } from 'src/constants';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import { useDownloadMenuItems } from 'src/dashboard/components/menu/DownloadMenuItems';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import DownloadMenuItems from 'src/dashboard/components/menu/DownloadMenuItems';
import CssEditor from 'src/dashboard/components/CssEditor';
import RefreshIntervalModal from 'src/dashboard/components/RefreshIntervalModal';
import SaveModal from 'src/dashboard/components/SaveModal';
import HeaderReportDropdown from 'src/features/reports/ReportModal/HeaderReportDropdown';
import injectCustomCss from 'src/dashboard/util/injectCustomCss';
import { SAVE_TYPE_NEWDASHBOARD } from 'src/dashboard/util/constants';
import FilterScopeModal from 'src/dashboard/components/filterscope/FilterScopeModal';
@@ -74,6 +74,9 @@ export const useHeaderActionsMenu = ({
}: HeaderDropdownProps) => {
const dispatch = useDispatch();
const [css, setCss] = useState(customCss || '');
const [showReportSubMenu, setShowReportSubMenu] = useState<boolean | null>(
null,
);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const directPathToChild = useSelector(
(state: RootState) => state.dashboardState.directPathToChild,
@@ -169,220 +172,163 @@ export const useHeaderActionsMenu = ({
[directPathToChild],
);
const shareMenuItems = useShareMenuItems({
title: t('Share'),
disabled: isLoading,
url,
dashboardId,
dashboardComponentId,
copyMenuItemTitle: t('Copy permalink to clipboard'),
emailMenuItemTitle: t('Share permalink by email'),
emailSubject,
emailBody: t('Check out this dashboard: '),
addSuccessToast,
addDangerToast,
});
const downloadMenuItem = useDownloadMenuItems({
pdfMenuItemTitle: t('Export to PDF'),
imageMenuItemTitle: t('Download as Image'),
dashboardTitle,
dashboardId,
title: t('Download'),
disabled: isLoading,
logEvent,
});
const reportMenuItem = useHeaderReportMenuItems({
dashboardId: dashboardInfo?.id,
showReportModal,
setCurrentReportDeleting,
});
// Helper function to create menu items for components with triggerNode
const createModalMenuItem = (
key: string,
modalComponent: React.ReactElement,
): MenuItem => ({
key,
label: modalComponent,
});
const menu = useMemo(() => {
const isEmbedded = !dashboardInfo?.userId;
const refreshIntervalOptions =
dashboardInfo?.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
const menuItems: MenuItem[] = [];
// Refresh dashboard
if (!editMode) {
menuItems.push({
key: MenuKeys.RefreshDashboard,
label: t('Refresh dashboard'),
disabled: isLoading,
});
}
// Toggle fullscreen
if (!editMode && !isEmbedded) {
menuItems.push({
key: MenuKeys.ToggleFullscreen,
label: getUrlParam(URL_PARAMS.standalone)
? t('Exit fullscreen')
: t('Enter fullscreen'),
});
}
// Edit properties
if (editMode) {
menuItems.push({
key: MenuKeys.EditProperties,
label: t('Edit properties'),
});
}
// Edit CSS
if (editMode) {
menuItems.push(
createModalMenuItem(
MenuKeys.EditCss,
<CssEditor
triggerNode={<div>{t('Theme & CSS')}</div>}
initialCss={css}
onChange={changeCss}
addDangerToast={addDangerToast}
currentThemeId={dashboardInfo.theme?.id || null}
onThemeChange={handleThemeChange}
/>,
),
);
}
// Divider
menuItems.push({ type: 'divider' });
// Save as
if (userCanSave) {
menuItems.push(
createModalMenuItem(
MenuKeys.SaveModal,
<SaveModal
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
saveType={SAVE_TYPE_NEWDASHBOARD}
layout={layout}
expandedSlices={expandedSlices}
refreshFrequency={refreshFrequency}
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
lastModifiedTime={lastModifiedTime}
customCss={customCss}
colorNamespace={colorNamespace}
colorScheme={colorScheme}
onSave={onSave}
triggerNode={
<div data-test="save-as-menu-item">{t('Save as')}</div>
}
canOverwrite={userCanEdit}
/>,
),
);
}
// Download submenu
menuItems.push(downloadMenuItem);
// Share submenu
if (userCanShare) {
menuItems.push(shareMenuItems);
}
// Embed dashboard
if (!editMode && userCanCurate) {
menuItems.push({
key: MenuKeys.ManageEmbedded,
label: t('Embed dashboard'),
});
}
// Divider
menuItems.push({ type: 'divider' });
// Report dropdown
if (!editMode && reportMenuItem) {
menuItems.push(reportMenuItem);
}
// Set filter mapping
if (editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes)) {
menuItems.push(
createModalMenuItem(
MenuKeys.SetFilterMapping,
<FilterScopeModal
triggerNode={<div>{t('Set filter mapping')}</div>}
/>,
),
);
}
// Auto-refresh interval
menuItems.push(
createModalMenuItem(
MenuKeys.AutorefreshModal,
<RefreshIntervalModal
addSuccessToast={addSuccessToast}
refreshFrequency={refreshFrequency}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
onChange={changeRefreshInterval}
editMode={editMode}
refreshIntervalOptions={refreshIntervalOptions}
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
/>,
),
);
dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS;
return (
<Menu
selectable={false}
data-test="header-actions-menu"
onClick={handleMenuClick}
items={menuItems}
/>
>
{!editMode && (
<Menu.Item
key={MenuKeys.RefreshDashboard}
data-test="refresh-dashboard-menu-item"
disabled={isLoading}
>
{t('Refresh dashboard')}
</Menu.Item>
)}
{!editMode && !isEmbedded && (
<Menu.Item key={MenuKeys.ToggleFullscreen}>
{getUrlParam(URL_PARAMS.standalone)
? t('Exit fullscreen')
: t('Enter fullscreen')}
</Menu.Item>
)}
{editMode && (
<Menu.Item key={MenuKeys.EditProperties}>
{t('Edit properties')}
</Menu.Item>
)}
{editMode && (
<Menu.Item key={MenuKeys.EditCss}>
<CssEditor
triggerNode={<div>{t('Theme & CSS')}</div>}
initialCss={css}
onChange={changeCss}
addDangerToast={addDangerToast}
currentThemeId={dashboardInfo.theme?.id || null}
onThemeChange={handleThemeChange}
/>
</Menu.Item>
)}
<Menu.Divider />
{userCanSave && (
<Menu.Item key={MenuKeys.SaveModal}>
<SaveModal
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardTitle={dashboardTitle}
dashboardInfo={dashboardInfo}
saveType={SAVE_TYPE_NEWDASHBOARD}
layout={layout}
expandedSlices={expandedSlices}
refreshFrequency={refreshFrequency}
shouldPersistRefreshFrequency={shouldPersistRefreshFrequency}
lastModifiedTime={lastModifiedTime}
customCss={customCss}
colorNamespace={colorNamespace}
colorScheme={colorScheme}
onSave={onSave}
triggerNode={
<div data-test="save-as-menu-item">{t('Save as')}</div>
}
canOverwrite={userCanEdit}
/>
</Menu.Item>
)}
<DownloadMenuItems
submenuKey={MenuKeys.Download}
disabled={isLoading}
title={t('Download')}
pdfMenuItemTitle={t('Export to PDF')}
imageMenuItemTitle={t('Download as Image')}
dashboardTitle={dashboardTitle}
dashboardId={dashboardId}
logEvent={logEvent}
/>
{userCanShare && (
<ShareMenuItems
disabled={isLoading}
data-test="share-dashboard-menu-item"
title={t('Share')}
url={url}
copyMenuItemTitle={t('Copy permalink to clipboard')}
emailMenuItemTitle={t('Share permalink by email')}
emailSubject={emailSubject}
emailBody={t('Check out this dashboard: ')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
dashboardId={dashboardId}
dashboardComponentId={dashboardComponentId}
/>
)}
{!editMode && userCanCurate && (
<Menu.Item key={MenuKeys.ManageEmbedded}>
{t('Embed dashboard')}
</Menu.Item>
)}
<Menu.Divider />
{!editMode ? (
showReportSubMenu ? (
<>
<HeaderReportDropdown
submenuTitle={t('Manage email report')}
dashboardId={dashboardInfo.id}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
showReportSubMenu={showReportSubMenu}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
<Menu.Divider />
</>
) : (
<HeaderReportDropdown
dashboardId={dashboardInfo.id}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
)
) : null}
{editMode && !isEmpty(dashboardInfo?.metadata?.filter_scopes) && (
<Menu.Item key={MenuKeys.SetFilterMapping}>
<FilterScopeModal
triggerNode={<div>{t('Set filter mapping')}</div>}
/>
</Menu.Item>
)}
<Menu.Item key={MenuKeys.AutorefreshModal}>
<RefreshIntervalModal
addSuccessToast={addSuccessToast}
refreshFrequency={refreshFrequency}
refreshLimit={refreshLimit}
refreshWarning={refreshWarning}
onChange={changeRefreshInterval}
editMode={editMode}
refreshIntervalOptions={refreshIntervalOptions}
triggerNode={<div>{t('Set auto-refresh interval')}</div>}
/>
</Menu.Item>
</Menu>
);
}, [
addDangerToast,
addSuccessToast,
changeRefreshInterval,
changeCss,
colorNamespace,
colorScheme,
css,
customCss,
dashboardId,
dashboardInfo,
dashboardTitle,
downloadMenuItem,
editMode,
expandedSlices,
showReportSubMenu,
isDropdownVisible,
directPathToChild,
handleMenuClick,
isLoading,
lastModifiedTime,
layout,
onSave,
refreshFrequency,
refreshLimit,
refreshWarning,
reportMenuItem,
shareMenuItems,
shouldPersistRefreshFrequency,
userCanCurate,
userCanEdit,
userCanSave,
userCanShare,
changeCss,
changeRefreshInterval,
emailSubject,
url,
dashboardComponentId,
]);
return [menu, isDropdownVisible, setIsDropdownVisible];

View File

@@ -438,9 +438,10 @@ describe('PropertiesModal', () => {
const props = createProps();
const propsWithDashboardInfo = { ...props, dashboardInfo };
const open = () => waitFor(() => userEvent.click(getSelect()));
const getSelect = () =>
screen.getByRole('combobox', { name: SupersetCore.t('Owners') });
const open = () => waitFor(() => userEvent.click(getSelect()));
const getElementsByClassName = (className: string) =>
document.querySelectorAll(className)! as NodeListOf<HTMLElement>;

View File

@@ -41,20 +41,20 @@ import {
QueryFormData,
} from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import {
NoAnimationDropdown,
Tooltip,
Button,
ModalTrigger,
} from '@superset-ui/core/components';
import { useShareMenuItems } from 'src/dashboard/components/menu/ShareMenuItems';
import ShareMenuItems from 'src/dashboard/components/menu/ShareMenuItems';
import downloadAsImage from 'src/utils/downloadAsImage';
import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip';
import { Icons } from '@superset-ui/core/components/Icons';
import ViewQueryModal from 'src/explore/components/controls/ViewQueryModal';
import { ResultsPaneOnDashboard } from 'src/explore/components/DataTablesPane';
import { useDrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { DrillDetailMenuItems } from 'src/components/Chart/DrillDetail';
import { LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE } from 'src/logger/LogUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import DrillDetailModal from 'src/components/Chart/DrillDetail/DrillDetailModal';
@@ -334,199 +334,183 @@ const SliceHeaderControls = (
animationDuration: '0s',
};
const newMenuItems: MenuItem[] = [
{
key: MenuKeys.ForceRefresh,
label: (
<>
{t('Force refresh')}
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
{refreshTooltip}
</RefreshTooltip>
</>
),
disabled: props.chartStatus === 'loading',
style: { height: 'auto', lineHeight: 'initial' },
...{ 'data-test': 'refresh-chart-menu-item' }, // Typescript hack to get around MenuItem type
},
{
key: MenuKeys.Fullscreen,
label: fullscreenLabel,
},
{
type: 'divider',
},
];
const menu = (
<Menu
onClick={handleMenuClick}
data-test={`slice_${slice.slice_id}-menu`}
id={`slice_${slice.slice_id}-menu`}
selectable={false}
>
<Menu.Item
key={MenuKeys.ForceRefresh}
disabled={props.chartStatus === 'loading'}
style={{ height: 'auto', lineHeight: 'initial' }}
data-test="refresh-chart-menu-item"
>
{t('Force refresh')}
<RefreshTooltip data-test="dashboard-slice-refresh-tooltip">
{refreshTooltip}
</RefreshTooltip>
</Menu.Item>
if (slice.description) {
newMenuItems.push({
key: MenuKeys.ToggleChartDescription,
label: props.isDescriptionExpanded
? t('Hide chart description')
: t('Show chart description'),
});
}
<Menu.Item key={MenuKeys.Fullscreen}>{fullscreenLabel}</Menu.Item>
if (canExplore) {
newMenuItems.push({
key: MenuKeys.ExploreChart,
label: (
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
{t('Edit chart')}
</Tooltip>
),
...{ 'data-test-edit-chart-name': slice.slice_name },
});
}
<Menu.Divider />
if (canEditCrossFilters) {
newMenuItems.push({
key: MenuKeys.CrossFilterScoping,
label: t('Cross-filtering scoping'),
});
}
{slice.description && (
<Menu.Item key={MenuKeys.ToggleChartDescription}>
{props.isDescriptionExpanded
? t('Hide chart description')
: t('Show chart description')}
</Menu.Item>
)}
if (canExplore || canEditCrossFilters) {
newMenuItems.push({ type: 'divider' });
}
{canExplore && (
<Menu.Item
key={MenuKeys.ExploreChart}
data-test-edit-chart-name={slice.slice_name}
>
<Tooltip title={getSliceHeaderTooltip(props.slice.slice_name)}>
{t('Edit chart')}
</Tooltip>
</Menu.Item>
)}
if (canExplore || canViewQuery) {
newMenuItems.push({
key: MenuKeys.ViewQuery,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
draggable
resizable
responsive
ref={queryMenuRef}
{canEditCrossFilters && (
<Menu.Item key={MenuKeys.CrossFilterScoping}>
{t('Cross-filtering scoping')}
</Menu.Item>
)}
{(canExplore || canEditCrossFilters) && <Menu.Divider />}
{(canExplore || canViewQuery) && (
<Menu.Item key={MenuKeys.ViewQuery}>
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={<ViewQueryModal latestQueryFormData={props.formData} />}
draggable
resizable
responsive
ref={queryMenuRef}
/>
</Menu.Item>
)}
{(canExplore || canViewTable) && (
<Menu.Item key={MenuKeys.ViewResults}>
<ViewResultsModalTrigger
canExplore={props.supersetCanExplore}
exploreUrl={props.exploreUrl}
triggerNode={
<div data-test="view-query-menu-item">{t('View as table')}</div>
}
modalRef={resultsMenuRef}
modalTitle={t('Chart Data: %s', slice.slice_name)}
modalBody={
<ResultsPaneOnDashboard
queryFormData={props.formData}
queryForce={false}
dataSize={20}
isRequest
isVisible
canDownload={!!props.supersetCanCSV}
/>
}
/>
</Menu.Item>
)}
{isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail && (
<DrillDetailMenuItems
setFilters={setFilters}
filters={modalFilters}
formData={props.formData}
key={MenuKeys.DrillToDetail}
setShowModal={setDrillModalIsOpen}
/>
),
});
}
)}
if (canExplore || canViewTable) {
newMenuItems.push({
key: MenuKeys.ViewResults,
label: (
<ViewResultsModalTrigger
canExplore={props.supersetCanExplore}
exploreUrl={props.exploreUrl}
triggerNode={
<div data-test="view-query-menu-item">{t('View as table')}</div>
}
modalRef={resultsMenuRef}
modalTitle={t('Chart Data: %s', slice.slice_name)}
modalBody={
<ResultsPaneOnDashboard
queryFormData={props.formData}
queryForce={false}
dataSize={20}
isRequest
isVisible
canDownload={!!props.supersetCanCSV}
/>
}
{(slice.description || canExplore) && <Menu.Divider />}
{supersetCanShare && (
<ShareMenuItems
dashboardId={dashboardId}
dashboardComponentId={componentId}
copyMenuItemTitle={t('Copy permalink to clipboard')}
emailMenuItemTitle={t('Share chart by email')}
emailSubject={t('Superset chart')}
emailBody={t('Check out this chart: ')}
addSuccessToast={addSuccessToast}
addDangerToast={addDangerToast}
title={t('Share')}
/>
),
});
}
)}
const { drillToDetailMenuItem, drillToDetailByMenuItem } =
useDrillDetailMenuItems({
formData: props.formData,
filters: modalFilters,
setFilters,
setShowModal: setDrillModalIsOpen,
key: MenuKeys.DrillToDetail,
});
{props.supersetCanCSV && (
<Menu.SubMenu title={t('Download')} key={MenuKeys.Download}>
<Menu.Item
key={MenuKeys.ExportCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to .CSV')}
</Menu.Item>
{isPivotTable && (
<Menu.Item
key={MenuKeys.ExportPivotCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Pivoted .CSV')}
</Menu.Item>
)}
<Menu.Item
key={MenuKeys.ExportXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Excel')}
</Menu.Item>
const shareMenuItems = useShareMenuItems({
dashboardId,
dashboardComponentId: componentId,
copyMenuItemTitle: t('Copy permalink to clipboard'),
emailMenuItemTitle: t('Share chart by email'),
emailSubject: t('Superset chart'),
emailBody: t('Check out this chart: '),
addSuccessToast,
addDangerToast,
title: t('Share'),
});
{isPivotTable && (
<Menu.Item
key={MenuKeys.ExportPivotXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
)}
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
newMenuItems.push(drillToDetailMenuItem);
if (drillToDetailByMenuItem) {
newMenuItems.push(drillToDetailByMenuItem);
}
}
if (slice.description || canExplore) {
newMenuItems.push({ type: 'divider' });
}
if (supersetCanShare) {
newMenuItems.push(shareMenuItems);
}
if (props.supersetCanCSV) {
newMenuItems.push({
type: 'submenu',
key: MenuKeys.Download,
label: t('Download'),
children: [
{
key: MenuKeys.ExportCsv,
label: t('Export to .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
...(isPivotTable
? [
{
key: MenuKeys.ExportPivotCsv,
label: t('Export to Pivoted .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
{
key: MenuKeys.ExportPivotXlsx,
label: t('Export to Pivoted Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
]
: []),
{
key: MenuKeys.ExportXlsx,
label: t('Export to Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
...(isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
props.supersetCanCSV &&
isTable
? [
{
key: MenuKeys.ExportFullCsv,
label: t('Export to full .CSV'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
{
key: MenuKeys.ExportFullXlsx,
label: t('Export to full Excel'),
icon: <Icons.FileOutlined css={dropdownIconsStyles} />,
},
]
: []),
{
key: MenuKeys.DownloadAsImage,
label: t('Download as image'),
icon: <Icons.FileImageOutlined css={dropdownIconsStyles} />,
},
],
});
}
{isFeatureEnabled(FeatureFlag.AllowFullCsvExport) &&
props.supersetCanCSV &&
isTable && (
<>
<Menu.Item
key={MenuKeys.ExportFullCsv}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to full .CSV')}
</Menu.Item>
<Menu.Item
key={MenuKeys.ExportFullXlsx}
icon={<Icons.FileOutlined css={dropdownIconsStyles} />}
>
{t('Export to full Excel')}
</Menu.Item>
</>
)}
<Menu.Item
key={MenuKeys.DownloadAsImage}
icon={<Icons.FileImageOutlined css={dropdownIconsStyles} />}
>
{t('Download as image')}
</Menu.Item>
</Menu.SubMenu>
)}
</Menu>
);
return (
<>
{isFullSize && (
@@ -538,15 +522,7 @@ const SliceHeaderControls = (
/>
)}
<NoAnimationDropdown
popupRender={() => (
<Menu
onClick={handleMenuClick}
data-test={`slice_${slice.slice_id}-menu`}
id={`slice_${slice.slice_id}-menu`}
selectable={false}
items={newMenuItems}
/>
)}
popupRender={() => menu}
overlayStyle={dropdownOverlayStyle}
trigger={['click']}
placement="bottomRight"

View File

@@ -17,8 +17,8 @@
* under the License.
*/
import { render, screen } from 'spec/helpers/testing-library';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { useDownloadMenuItems } from '.';
import { Menu } from '@superset-ui/core/components/Menu';
import DownloadMenuItems from '.';
const createProps = () => ({
pdfMenuItemTitle: 'Export to PDF',
@@ -30,17 +30,19 @@ const createProps = () => ({
submenuKey: 'download',
});
const MenuWrapper = () => {
const downloadMenuItem = useDownloadMenuItems(createProps());
const menuItems: MenuItem[] = [downloadMenuItem];
return <Menu forceSubMenuRender items={menuItems} />;
const renderComponent = () => {
render(
<Menu forceSubMenuRender>
<DownloadMenuItems {...createProps()} />
</Menu>,
{
useRedux: true,
},
);
};
test('Should render menu items', () => {
render(<MenuWrapper />, {
useRedux: true,
});
renderComponent();
expect(screen.getByText('Export to PDF')).toBeInTheDocument();
expect(screen.getByText('Download as Image')).toBeInTheDocument();
});

View File

@@ -16,21 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { SyntheticEvent } from 'react';
import { FeatureFlag, isFeatureEnabled, logging, t } from '@superset-ui/core';
import { MenuItem } from '@superset-ui/core/components/Menu';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { Menu } from '@superset-ui/core/components/Menu';
import { useDownloadScreenshot } from 'src/dashboard/hooks/useDownloadScreenshot';
import { MenuKeys } from 'src/dashboard/types';
import downloadAsPdf from 'src/utils/downloadAsPdf';
import downloadAsImage from 'src/utils/downloadAsImage';
import {
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF,
LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE,
} from 'src/logger/LogUtils';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { ComponentProps } from 'react';
import { DownloadScreenshotFormat } from './types';
import DownloadAsPdf from './DownloadAsPdf';
import DownloadAsImage from './DownloadAsImage';
export interface UseDownloadMenuItemsProps {
export interface DownloadMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
pdfMenuItemTitle: string;
imageMenuItemTitle: string;
dashboardTitle: string;
@@ -38,81 +33,56 @@ export interface UseDownloadMenuItemsProps {
dashboardId: number;
title: string;
disabled?: boolean;
submenuKey: string;
}
export const useDownloadMenuItems = (
props: UseDownloadMenuItemsProps,
): MenuItem => {
const DownloadMenuItems = (props: DownloadMenuItemProps) => {
const {
pdfMenuItemTitle,
imageMenuItemTitle,
logEvent,
dashboardId,
dashboardTitle,
submenuKey,
disabled,
title,
...rest
} = props;
const { addDangerToast } = useToasts();
const SCREENSHOT_NODE_SELECTOR = '.dashboard';
const isWebDriverScreenshotEnabled =
isFeatureEnabled(FeatureFlag.EnableDashboardScreenshotEndpoints) &&
isFeatureEnabled(FeatureFlag.EnableDashboardDownloadWebDriverScreenshot);
const downloadScreenshot = useDownloadScreenshot(dashboardId, logEvent);
const onDownloadPdf = async (e: SyntheticEvent) => {
try {
downloadAsPdf(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_PDF);
};
const onDownloadImage = async (e: SyntheticEvent) => {
try {
downloadAsImage(SCREENSHOT_NODE_SELECTOR, dashboardTitle, true)(e);
} catch (error) {
logging.error(error);
addDangerToast(t('Sorry, something went wrong. Try again later.'));
}
logEvent?.(LOG_ACTIONS_DASHBOARD_DOWNLOAD_AS_IMAGE);
};
const children: MenuItem[] = isWebDriverScreenshotEnabled
? [
{
key: DownloadScreenshotFormat.PDF,
label: pdfMenuItemTitle,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PDF),
},
{
key: DownloadScreenshotFormat.PNG,
label: imageMenuItemTitle,
onClick: () => downloadScreenshot(DownloadScreenshotFormat.PNG),
},
]
: [
{
key: 'download-pdf',
label: pdfMenuItemTitle,
onClick: (e: any) => onDownloadPdf(e.domEvent),
},
{
key: 'download-image',
label: imageMenuItemTitle,
onClick: (e: any) => onDownloadImage(e.domEvent),
},
];
return {
key: MenuKeys.Download,
type: 'submenu',
label: title,
disabled,
children,
};
return isWebDriverScreenshotEnabled ? (
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<Menu.Item
key={DownloadScreenshotFormat.PDF}
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PDF)}
>
{pdfMenuItemTitle}
</Menu.Item>
<Menu.Item
key={DownloadScreenshotFormat.PNG}
onClick={() => downloadScreenshot(DownloadScreenshotFormat.PNG)}
>
{imageMenuItemTitle}
</Menu.Item>
</Menu.SubMenu>
) : (
<Menu.SubMenu key={submenuKey} title={title} disabled={disabled} {...rest}>
<DownloadAsPdf
text={pdfMenuItemTitle}
dashboardTitle={dashboardTitle}
logEvent={logEvent}
/>
<DownloadAsImage
text={imageMenuItemTitle}
dashboardTitle={dashboardTitle}
logEvent={logEvent}
/>
</Menu.SubMenu>
);
};
export default DownloadMenuItems;

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import {
render,
screen,
@@ -26,8 +26,7 @@ import {
} from 'spec/helpers/testing-library';
import * as copyTextToClipboard from 'src/utils/copy';
import fetchMock from 'fetch-mock';
import { ComponentProps } from 'react';
import { useShareMenuItems, ShareMenuItemProps } from '.';
import ShareMenuItems from '.';
const spy = jest.spyOn(copyTextToClipboard, 'default');
@@ -70,23 +69,17 @@ afterAll((): void => {
window.location = location;
});
const MenuWrapper = (
props: ComponentProps<typeof Menu> & { shareProps: ShareMenuItemProps },
) => {
const shareMenuItems = useShareMenuItems(props.shareProps);
const menuItems: MenuItem[] = [shareMenuItems];
return <Menu {...props} items={menuItems} />;
};
test('Should render menu items', () => {
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={createProps()}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
expect(screen.getByText('Copy dashboard URL')).toBeInTheDocument();
@@ -97,13 +90,14 @@ test('Click on "Copy dashboard URL" and succeed', async () => {
spy.mockResolvedValue(undefined);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -129,13 +123,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
spy.mockRejectedValue(undefined);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -162,13 +157,14 @@ test('Click on "Copy dashboard URL" and fail', async () => {
test('Click on "Share dashboard by email" and succeed', async () => {
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);
@@ -195,13 +191,14 @@ test('Click on "Share dashboard by email" and fail', async () => {
);
const props = createProps();
render(
<MenuWrapper
<Menu
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
>
<ShareMenuItems {...props} />
</Menu>,
{ useRedux: true },
);

View File

@@ -19,13 +19,12 @@
import { ComponentProps, RefObject } from 'react';
import copyTextToClipboard from 'src/utils/copy';
import { t, logging } from '@superset-ui/core';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { Menu } from '@superset-ui/core/components/Menu';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux';
export interface ShareMenuItemProps
extends ComponentProps<typeof Menu.SubMenu> {
interface ShareMenuItemProps extends ComponentProps<typeof Menu.SubMenu> {
url?: string;
copyMenuItemTitle: string;
emailMenuItemTitle: string;
@@ -41,10 +40,9 @@ export interface ShareMenuItemProps
setOpenKeys?: Function;
title: string;
disabled?: boolean;
[key: string]: any;
}
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
const ShareMenuItems = (props: ShareMenuItemProps) => {
const {
copyMenuItemTitle,
emailMenuItemTitle,
@@ -98,23 +96,20 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
}
}
return {
...rest,
type: 'submenu',
label: title,
key: MenuKeys.Share,
disabled,
children: [
{
key: MenuKeys.CopyLink,
label: copyMenuItemTitle,
onClick: onCopyLink,
},
{
key: MenuKeys.ShareByEmail,
label: emailMenuItemTitle,
onClick: onShareByEmail,
},
],
};
return (
<Menu.SubMenu
title={title}
key={MenuKeys.Share}
disabled={disabled}
{...rest}
>
<Menu.Item key={MenuKeys.CopyLink} onClick={() => onCopyLink()}>
{copyMenuItemTitle}
</Menu.Item>
<Menu.Item key={MenuKeys.ShareByEmail} onClick={() => onShareByEmail()}>
{emailMenuItemTitle}
</Menu.Item>
</Menu.SubMenu>
);
};
export default ShareMenuItems;

View File

@@ -155,34 +155,6 @@ const FilterValue: FC<FilterControlProps> = ({
dashboardId,
});
const filterOwnState = filter.dataMask?.ownState || {};
if (filter?.cascadeParentIds?.length) {
// Prevent unnecessary backend requests by validating parent filter selections first
let selectedParentFilterValueCounts = 0;
filter?.cascadeParentIds?.forEach(pId => {
const extraFormData = dataMaskSelected?.[pId]?.extraFormData;
if (extraFormData?.filters?.length) {
selectedParentFilterValueCounts += extraFormData.filters.length;
} else if (extraFormData?.time_range) {
selectedParentFilterValueCounts += 1;
}
});
// check if all parent filters with defaults have a value selected
let depsCount = dependencies.filters?.length ?? 0;
if (dependencies?.time_range) {
depsCount += 1;
}
if (selectedParentFilterValueCounts !== depsCount) {
// child filter should not request backend until it
// has all the required information from parent filters
return;
}
}
// TODO: We should try to improve our useEffect hooks to depend more on
// granular information instead of big objects that require deep comparison.
const customizer = (
@@ -254,7 +226,6 @@ const FilterValue: FC<FilterControlProps> = ({
hasDataSource,
isRefreshing,
shouldRefresh,
dataMaskSelected,
]);
useEffect(() => {

View File

@@ -96,14 +96,14 @@ test('remove filter', async () => {
test('add filter', async () => {
defaultRender();
// First trash icon
const addFilterButton = await screen.findByText('Add filter');
const addFilterButton = await screen.findByText('Add Filter');
userEvent.click(addFilterButton);
expect(defaultProps.onAdd).toHaveBeenCalledWith('NATIVE_FILTER');
});
test('add divider', async () => {
defaultRender();
const addFilterButton = await screen.findByText('Add divider');
const addFilterButton = await screen.findByText('Add Divider');
userEvent.click(addFilterButton);
expect(defaultProps.onAdd).toHaveBeenCalledWith('DIVIDER');
});
@@ -128,7 +128,7 @@ test('filter container should scroll to bottom when adding items', async () => {
defaultRender(state, props);
const addFilterButton = await screen.findByText('Add filter');
const addFilterButton = await screen.findByText('Add Filter');
userEvent.click(addFilterButton);

View File

@@ -111,7 +111,7 @@ const FilterTitlePane: FC<Props> = ({
data-test="add-new-filter-button"
onClick={() => handleOnAdd(NativeFilterType.NativeFilter)}
>
{t('Add filter')}
{t('Add Filter')}
</Button>
<Button
buttonSize="default"
@@ -125,7 +125,7 @@ const FilterTitlePane: FC<Props> = ({
data-test="add-new-divider-button"
onClick={() => handleOnAdd(NativeFilterType.Divider)}
>
{t('Add divider')}
{t('Add Divider')}
</Button>
</div>
</TabsContainer>

View File

@@ -1,93 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Route } from 'react-router-dom';
import { getExtensionsRegistry } from '@superset-ui/core';
import { Provider as ReduxProvider } from 'react-redux';
import { QueryParamProvider } from 'use-query-params';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { FlashProvider, DynamicPluginProvider } from 'src/components';
import { EmbeddedUiConfigProvider } from 'src/components/UiConfigContext';
import { SupersetThemeProvider } from 'src/theme/ThemeProvider';
import { ThemeController } from 'src/theme/ThemeController';
import type { ThemeStorage } from '@superset-ui/core';
import { store } from 'src/views/store';
import getBootstrapData from 'src/utils/getBootstrapData';
/**
* In-memory implementation of ThemeStorage interface for embedded contexts.
* Persistent storage is not required for embedded dashboards.
*/
class ThemeMemoryStorageAdapter implements ThemeStorage {
private storage = new Map<string, string>();
getItem(key: string): string | null {
return this.storage.get(key) || null;
}
setItem(key: string, value: string): void {
this.storage.set(key, value);
}
removeItem(key: string): void {
this.storage.delete(key);
}
}
const themeController = new ThemeController({
storage: new ThemeMemoryStorageAdapter(),
});
export const getThemeController = (): ThemeController => themeController;
const { common } = getBootstrapData();
const extensionsRegistry = getExtensionsRegistry();
export const EmbeddedContextProviders: React.FC = ({ children }) => {
const RootContextProviderExtension = extensionsRegistry.get(
'root.context.provider',
);
return (
<SupersetThemeProvider themeController={themeController}>
<ReduxProvider store={store}>
<DndProvider backend={HTML5Backend}>
<FlashProvider messages={common.flash_messages}>
<EmbeddedUiConfigProvider>
<DynamicPluginProvider>
<QueryParamProvider
ReactRouterRoute={Route}
stringifyOptions={{ encode: false }}
>
{RootContextProviderExtension ? (
<RootContextProviderExtension>
{children}
</RootContextProviderExtension>
) : (
children
)}
</QueryParamProvider>
</DynamicPluginProvider>
</EmbeddedUiConfigProvider>
</FlashProvider>
</DndProvider>
</ReduxProvider>
</SupersetThemeProvider>
);
};

View File

@@ -21,27 +21,20 @@ import 'src/public-path';
import { lazy, Suspense } from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import {
type SupersetThemeConfig,
makeApi,
t,
logging,
} from '@superset-ui/core';
import { makeApi, t, logging, themeObject } from '@superset-ui/core';
import Switchboard from '@superset-ui/switchboard';
import getBootstrapData, { applicationRoot } from 'src/utils/getBootstrapData';
import setupClient from 'src/setup/setupClient';
import setupPlugins from 'src/setup/setupPlugins';
import { useUiConfig } from 'src/components/UiConfigContext';
import { RootContextProviders } from 'src/views/RootContextProviders';
import { store, USER_LOADED } from 'src/views/store';
import { Loading } from '@superset-ui/core/components';
import { ErrorBoundary } from 'src/components';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import ToastContainer from 'src/components/MessageToasts/ToastContainer';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import {
EmbeddedContextProviders,
getThemeController,
} from './EmbeddedContextProviders';
import { AnyThemeConfig } from 'packages/superset-ui-core/src/theme/types';
import { embeddedApi } from './api';
import { getDataMaskChangeTrigger } from './utils';
@@ -51,7 +44,9 @@ const debugMode = process.env.WEBPACK_MODE === 'development';
const bootstrapData = getBootstrapData();
function log(...info: unknown[]) {
if (debugMode) logging.debug(`[superset]`, ...info);
if (debugMode) {
logging.debug(`[superset]`, ...info);
}
}
const LazyDashboardPage = lazy(
@@ -90,12 +85,12 @@ const EmbededLazyDashboardPage = () => {
const EmbeddedRoute = () => (
<Suspense fallback={<Loading />}>
<EmbeddedContextProviders>
<RootContextProviders>
<ErrorBoundary>
<EmbededLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</EmbeddedContextProviders>
</RootContextProviders>
</Suspense>
);
@@ -250,13 +245,12 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
Switchboard.defineMethod('getDataMask', embeddedApi.getDataMask);
Switchboard.defineMethod(
'setThemeConfig',
(payload: { themeConfig: SupersetThemeConfig }) => {
(payload: { themeConfig: AnyThemeConfig }) => {
const { themeConfig } = payload;
log('Received setThemeConfig request:', themeConfig);
try {
const themeController = getThemeController();
themeController.setThemeConfig(themeConfig);
themeObject.setConfig(themeConfig);
return { success: true, message: 'Theme applied' };
} catch (error) {
logging.error('Failed to apply theme config:', error);
@@ -264,22 +258,8 @@ window.addEventListener('message', function embeddedPageInitializer(event) {
}
},
);
Switchboard.start();
}
});
// Clean up theme controller on page unload
window.addEventListener('beforeunload', () => {
try {
const controller = getThemeController();
if (controller) {
log('Destroying theme controller');
controller.destroy();
}
} catch (error) {
logging.warn('Failed to destroy theme controller:', error);
}
});
log('embed page is ready to receive messages');

View File

@@ -53,6 +53,7 @@ import {
sections,
} from '@superset-ui/chart-controls';
import { useSelector } from 'react-redux';
import { rgba } from 'emotion-rgba';
import { kebabCase, isEqual } from 'lodash';
import {
@@ -117,11 +118,16 @@ const iconStyles = css`
const actionButtonsContainerStyles = (theme: SupersetTheme) => css`
display: flex;
position: sticky;
bottom: 0;
flex-direction: column;
align-items: center;
padding: ${theme.sizeUnit * 4}px;
background: ${theme.colorBgContainer};
flex-shrink: 0;
z-index: 999;
background: linear-gradient(
${rgba(theme.colorBgBase, 0)},
${theme.colorBgBase} 35%
);
& > button {
min-width: 156px;
@@ -132,18 +138,15 @@ const Styles = styled.div`
position: relative;
height: 100%;
width: 100%;
display: flex;
flex-direction: column;
// Resizable add overflow-y: auto as a style to this div
// To override it, we need to use !important
overflow: visible !important;
#controlSections {
flex: 1;
overflow: auto;
height: 100%;
overflow: visible;
padding-bottom: ${({ theme }) => theme.sizeUnit * 10}px;
}
.tab-content {
overflow: auto;
flex: 1 1 100%;

View File

@@ -30,6 +30,10 @@ export type DateLabelProps = {
onClick?: (event: MouseEvent) => void;
};
// This is the color that antd components (such as Select or Input) use on hover
// TODO: use theme.colorPrimary here and in antd components
const ACTIVE_BORDER_COLOR = '#45BED6';
const LabelContainer = styled.div<{
isActive?: boolean;
isPlaceholder?: boolean;
@@ -43,9 +47,10 @@ const LabelContainer = styled.div<{
padding: 0 ${theme.sizeUnit * 3}px;
background-color: ${theme.colorBgContainer};
background-color: ${theme.colors.grayscale.light5};
border: 1px solid ${isActive ? theme.colorPrimary : theme.colorBorder};
border: 1px solid
${isActive ? ACTIVE_BORDER_COLOR : theme.colors.grayscale.light2};
border-radius: ${theme.borderRadius}px;
cursor: pointer;
@@ -53,11 +58,11 @@ const LabelContainer = styled.div<{
transition: border-color 0.3s cubic-bezier(0.65, 0.05, 0.36, 1);
:hover,
:focus {
border-color: ${theme.colorPrimary};
border-color: ${ACTIVE_BORDER_COLOR};
}
.date-label-content {
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
color: ${isPlaceholder ? theme.colors.grayscale.light1 : theme.colorText};
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
@@ -66,7 +71,6 @@ const LabelContainer = styled.div<{
}
span[role='img'] {
color: ${isPlaceholder ? theme.colorTextPlaceholder : theme.colorText};
margin-left: auto;
padding-left: ${theme.sizeUnit}px;

View File

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

View File

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

View File

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

View File

@@ -41,9 +41,6 @@ import {
import TableChartPlugin from '../../../../../plugins/plugin-chart-table/src';
import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers();
class MainPreset extends Preset {
@@ -259,22 +256,4 @@ describe('VizTypeControl', () => {
expect(defaultProps.onChange).toHaveBeenCalledWith(VizType.Line);
});
it('Search input is focused when modal opens', async () => {
// Mock the focus method to track if it was called
const focusSpy = jest.fn();
const originalFocus = HTMLInputElement.prototype.focus;
HTMLInputElement.prototype.focus = focusSpy;
await waitForRenderWrapper();
const searchInput = screen.getByTestId(getTestId('search-input'));
// Verify that focus() was called on the search input
expect(focusSpy).toHaveBeenCalled();
expect(searchInput).toBeInTheDocument();
// Restore the original focus method
HTMLInputElement.prototype.focus = originalFocus;
});
});

View File

@@ -575,13 +575,6 @@ export default function VizTypeGallery(props: VizTypeGalleryProps) {
setIsSearchFocused(true);
}, []);
// Auto-focus the search input when the modal opens
useEffect(() => {
if (searchInputRef.current) {
searchInputRef.current.focus();
}
}, []);
const changeSearch: ChangeEventHandler<HTMLInputElement> = useCallback(
event => setSearchInputValue(event.target.value),
[],

View File

@@ -34,7 +34,7 @@ import { exportChart, getChartKey } from 'src/explore/exploreUtils';
import downloadAsImage from 'src/utils/downloadAsImage';
import { getChartPermalink } from 'src/utils/urlUtils';
import copyTextToClipboard from 'src/utils/copy';
import { useHeaderReportMenuItems } from 'src/features/reports/ReportModal/HeaderReportDropdown';
import HeaderReportDropDown from 'src/features/reports/ReportModal/HeaderReportDropdown';
import { logEvent } from 'src/logger/actions';
import {
LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE,
@@ -123,18 +123,12 @@ export const useExploreAdditionalActionsMenu = (
const theme = useTheme();
const { addDangerToast, addSuccessToast } = useToasts();
const dispatch = useDispatch();
const [showReportSubMenu, setShowReportSubMenu] = useState(null);
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const chart = useSelector(
state => state.charts?.[getChartKey(state.explore)],
);
// Use the updated report menu items hook
const reportMenuItem = useHeaderReportMenuItems({
chart,
showReportModal,
setCurrentReportDeleting,
});
const { datasource } = latestQueryFormData;
const shareByEmail = useCallback(async () => {
@@ -209,106 +203,14 @@ export const useExploreAdditionalActionsMenu = (
}
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
const menu = useMemo(() => {
const menuItems = [];
// Edit chart properties
if (slice) {
menuItems.push({
key: MENU_KEYS.EDIT_PROPERTIES,
label: t('Edit chart properties'),
onClick: () => {
const handleMenuClick = useCallback(
({ key, domEvent }) => {
switch (key) {
case MENU_KEYS.EDIT_PROPERTIES:
onOpenPropertiesModal();
setIsDropdownVisible(false);
},
});
}
// On dashboards submenu
menuItems.push({
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
type: 'submenu',
label: t('On dashboards'),
children: [
{
key: 'dashboards-content',
label: (
<DashboardsSubMenu
chartId={slice?.slice_id}
dashboards={dashboards}
/>
),
},
],
});
// Divider
menuItems.push({ type: 'divider' });
// Download submenu
const downloadChildren = [];
if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) {
downloadChildren.push(
{
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to original .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportCSV();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_CSV_PIVOTED,
label: t('Export to pivoted .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportCSVPivoted();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_PIVOT_XLSX,
label: t('Export to Pivoted Excel'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
exportPivotExcel(
'.pvtTable',
slice?.slice_name ?? t('pivoted_xlsx'),
);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
);
} else {
downloadChildren.push({
key: MENU_KEYS.EXPORT_TO_CSV,
label: t('Export to .CSV'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_CSV:
exportCSV();
setIsDropdownVisible(false);
dispatch(
@@ -317,17 +219,18 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
});
}
downloadChildren.push(
{
key: MENU_KEYS.EXPORT_TO_JSON,
label: t('Export to .JSON'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_CSV_PIVOTED:
exportCSVPivoted();
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_CSV_PIVOTED, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.EXPORT_TO_JSON:
exportJson();
setIsDropdownVisible(false);
dispatch(
@@ -336,33 +239,8 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.DOWNLOAD_AS_IMAGE,
label: t('Download as image'),
icon: <Icons.FileImageOutlined />,
onClick: e => {
downloadAsImage(
'.panel-body .chart-container',
slice?.slice_name ?? t('New chart'),
true,
)(e.domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
},
},
{
key: MENU_KEYS.EXPORT_TO_XLSX,
label: t('Export to Excel'),
icon: <Icons.FileOutlined />,
disabled: !canDownloadCSV,
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_XLSX:
exportExcel();
setIsDropdownVisible(false);
dispatch(
@@ -371,128 +249,225 @@ export const useExploreAdditionalActionsMenu = (
chartName: slice?.slice_name,
}),
);
},
},
);
menuItems.push({
key: MENU_KEYS.DOWNLOAD_SUBMENU,
type: 'submenu',
label: t('Download'),
children: downloadChildren,
});
// Share submenu
const shareChildren = [
{
key: MENU_KEYS.COPY_PERMALINK,
label: t('Copy permalink to clipboard'),
onClick: () => {
break;
case MENU_KEYS.EXPORT_TO_PIVOT_XLSX:
exportPivotExcel('.pvtTable', slice?.slice_name ?? t('pivoted_xlsx'));
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.DOWNLOAD_AS_IMAGE:
downloadAsImage(
'.panel-body .chart-container',
// eslint-disable-next-line camelcase
slice?.slice_name ?? t('New chart'),
true,
)(domEvent);
setIsDropdownVisible(false);
dispatch(
logEvent(LOG_ACTIONS_CHART_DOWNLOAD_AS_IMAGE, {
chartId: slice?.slice_id,
chartName: slice?.slice_name,
}),
);
break;
case MENU_KEYS.COPY_PERMALINK:
copyLink();
setIsDropdownVisible(false);
},
},
{
key: MENU_KEYS.SHARE_BY_EMAIL,
label: t('Share chart by email'),
onClick: () => {
break;
case MENU_KEYS.EMBED_CODE:
setIsDropdownVisible(false);
break;
case MENU_KEYS.SHARE_BY_EMAIL:
shareByEmail();
setIsDropdownVisible(false);
},
},
];
break;
case MENU_KEYS.VIEW_QUERY:
setIsDropdownVisible(false);
break;
case MENU_KEYS.RUN_IN_SQL_LAB:
onOpenInEditor(latestQueryFormData, domEvent.metaKey);
setIsDropdownVisible(false);
break;
default:
break;
}
},
[
copyLink,
exportCSV,
exportCSVPivoted,
exportJson,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,
shareByEmail,
slice?.slice_name,
],
);
if (isFeatureEnabled(FeatureFlag.EmbeddableCharts)) {
shareChildren.push({
key: MENU_KEYS.EMBED_CODE,
label: (
const menu = useMemo(
() => (
<Menu onClick={handleMenuClick} selectable={false} {...rest}>
<>
{slice && (
<Menu.Item key={MENU_KEYS.EDIT_PROPERTIES}>
{t('Edit chart properties')}
</Menu.Item>
)}
<Menu.SubMenu
title={t('On dashboards')}
key={MENU_KEYS.DASHBOARDS_ADDED_TO}
>
<DashboardsSubMenu
chartId={slice?.slice_id}
dashboards={dashboards}
/>
</Menu.SubMenu>
<Menu.Divider />
</>
<Menu.SubMenu title={t('Download')} key={MENU_KEYS.DOWNLOAD_SUBMENU}>
{VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type) ? (
<>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to original .CSV')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV_PIVOTED}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to pivoted .CSV')}
</Menu.Item>
</>
) : (
<Menu.Item
key={MENU_KEYS.EXPORT_TO_CSV}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .CSV')}
</Menu.Item>
)}
<Menu.Item
key={MENU_KEYS.EXPORT_TO_JSON}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to .JSON')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.DOWNLOAD_AS_IMAGE}
icon={<Icons.FileImageOutlined />}
>
{t('Download as image')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_XLSX}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Excel')}
</Menu.Item>
<Menu.Item
key={MENU_KEYS.EXPORT_TO_PIVOT_XLSX}
icon={<Icons.FileOutlined />}
disabled={!canDownloadCSV}
>
{t('Export to Pivoted Excel')}
</Menu.Item>
</Menu.SubMenu>
<Menu.SubMenu title={t('Share')} key={MENU_KEYS.SHARE_SUBMENU}>
<Menu.Item key={MENU_KEYS.COPY_PERMALINK}>
{t('Copy permalink to clipboard')}
</Menu.Item>
<Menu.Item key={MENU_KEYS.SHARE_BY_EMAIL}>
{t('Share chart by email')}
</Menu.Item>
{isFeatureEnabled(FeatureFlag.EmbeddableCharts) ? (
<Menu.Item key={MENU_KEYS.EMBED_CODE}>
<ModalTrigger
triggerNode={
<div data-test="embed-code-button">{t('Embed code')}</div>
}
modalTitle={t('Embed code')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
}
maxWidth={`${theme.sizeUnit * 100}px`}
destroyOnHidden
responsive
/>
</Menu.Item>
) : null}
</Menu.SubMenu>
<Menu.Divider />
{showReportSubMenu ? (
<>
<HeaderReportDropDown
submenuTitle={t('Manage email report')}
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
showReportSubMenu={showReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
<Menu.Divider />
</>
) : (
<HeaderReportDropDown
chart={chart}
setShowReportSubMenu={setShowReportSubMenu}
showReportModal={showReportModal}
setCurrentReportDeleting={setCurrentReportDeleting}
useTextMenu
/>
)}
<Menu.Item key={MENU_KEYS.VIEW_QUERY}>
<ModalTrigger
triggerNode={
<div data-test="embed-code-button">{t('Embed code')}</div>
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('Embed code')}
modalTitle={t('View query')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
}
maxWidth={`${theme.sizeUnit * 100}px`}
destroyOnHidden
draggable
resizable
responsive
/>
),
onClick: () => setIsDropdownVisible(false),
});
}
menuItems.push({
key: MENU_KEYS.SHARE_SUBMENU,
type: 'submenu',
label: t('Share'),
children: shareChildren,
});
// Divider
menuItems.push({ type: 'divider' });
// Report menu item
if (reportMenuItem) {
menuItems.push(reportMenuItem);
}
// View query
menuItems.push({
key: MENU_KEYS.VIEW_QUERY,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('View query')}</div>
}
modalTitle={t('View query')}
modalBody={
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
}
draggable
resizable
responsive
/>
),
onClick: () => setIsDropdownVisible(false),
});
// Run in SQL Lab
if (datasource) {
menuItems.push({
key: MENU_KEYS.RUN_IN_SQL_LAB,
label: t('Run in SQL Lab'),
onClick: e => {
onOpenInEditor(latestQueryFormData, e.domEvent.metaKey);
setIsDropdownVisible(false);
},
});
}
return <Menu selectable={false} items={menuItems} {...rest} />;
}, [
addDangerToast,
canDownloadCSV,
copyLink,
dashboards,
datasource,
dispatch,
exportCSV,
exportCSVPivoted,
exportExcel,
exportJson,
latestQueryFormData,
onOpenInEditor,
onOpenPropertiesModal,
reportMenuItem,
shareByEmail,
slice,
theme.sizeUnit,
]);
</Menu.Item>
{datasource && (
<Menu.Item key={MENU_KEYS.RUN_IN_SQL_LAB}>
{t('Run in SQL Lab')}
</Menu.Item>
)}
</Menu>
),
[
addDangerToast,
canDownloadCSV,
chart,
dashboards,
handleMenuClick,
isDropdownVisible,
latestQueryFormData,
showReportSubMenu,
slice,
theme.sizeUnit,
],
);
return [menu, isDropdownVisible, setIsDropdownVisible];
};

Some files were not shown because too many files have changed in this diff Show More