Compare commits

..

3 Commits

Author SHA1 Message Date
Beto Dealmeida
bc0e7b4984 WIP 2025-09-03 16:13:20 -04:00
Beto Dealmeida
d3c81fe6b4 WIP 2025-09-03 15:53:27 -04:00
Beto Dealmeida
df62bffadd WIP 2025-09-03 15:53:09 -04:00
737 changed files with 9335 additions and 23331 deletions

14
.github/CODEOWNERS vendored
View File

@@ -33,10 +33,10 @@
# Notify PMC members of changes to extension-related files
/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-extensions-cli/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/core/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-frontend/src/extensions/ @michael-s-molina @villebro @geido @eschutho @rusackas @kgabryje
/superset-core/ @michael-s-molina @villebro
/superset-extensions-cli/ @michael-s-molina @villebro
/superset/core/ @michael-s-molina @villebro
/superset/extensions/ @michael-s-molina @villebro
/superset-frontend/src/packages/superset-core/ @michael-s-molina @villebro
/superset-frontend/src/core/ @michael-s-molina @villebro
/superset-frontend/src/extensions/ @michael-s-molina @villebro

View File

@@ -182,76 +182,6 @@ cypress-run-all() {
kill $flaskProcessId
}
playwright-install() {
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Install Playwright browsers"
npx playwright install --with-deps chromium
# Create output directories for test results and debugging
mkdir -p playwright-results
mkdir -p test-results
say "::endgroup::"
}
playwright-run() {
local APP_ROOT=$1
# Start Flask from the project root (same as Cypress)
cd "$GITHUB_WORKSPACE"
local flasklog="${HOME}/flask-playwright.log"
local port=8081
PLAYWRIGHT_BASE_URL="http://localhost:${port}"
if [ -n "$APP_ROOT" ]; then
export SUPERSET_APP_ROOT=$APP_ROOT
PLAYWRIGHT_BASE_URL=${PLAYWRIGHT_BASE_URL}${APP_ROOT}/
fi
export PLAYWRIGHT_BASE_URL
nohup flask run --no-debugger -p $port >"$flasklog" 2>&1 </dev/null &
local flaskProcessId=$!
# Ensure cleanup on exit
trap "kill $flaskProcessId 2>/dev/null || true" EXIT
# Wait for server to be ready with health check
local timeout=60
say "Waiting for Flask server to start on port $port..."
while [ $timeout -gt 0 ]; do
if curl -f ${PLAYWRIGHT_BASE_URL}/health >/dev/null 2>&1; then
say "Flask server is ready"
break
fi
sleep 1
timeout=$((timeout - 1))
done
if [ $timeout -eq 0 ]; then
echo "::error::Flask server failed to start within 60 seconds"
echo "::group::Flask startup log"
cat "$flasklog"
echo "::endgroup::"
return 1
fi
# Change to frontend directory for Playwright execution
cd "$GITHUB_WORKSPACE/superset-frontend"
say "::group::Run Playwright tests"
echo "Running Playwright with baseURL: ${PLAYWRIGHT_BASE_URL}"
npx playwright test auth/login --reporter=github --output=playwright-results
local status=$?
say "::endgroup::"
# After job is done, print out Flask log for debugging
echo "::group::Flask log for Playwright run"
cat "$flasklog"
echo "::endgroup::"
# make sure the program exits
kill $flaskProcessId
return $status
}
eyes-storybook-dependencies() {
say "::group::install eyes-storyook dependencies"
sudo apt-get update -y && sudo apt-get -y install gconf-service ca-certificates libxshmfence-dev fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libgconf-2-4 libglib2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 libnspr4 libnss3 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils libappindicator1

View File

@@ -61,8 +61,17 @@ jobs:
console.log(`📊 Permission level for ${actor}: ${permission.permission}`);
const authorized = ['write', 'admin'].includes(permission.permission);
// If this is a synchronize event from unauthorized user, check if Showtime is active and set blocked label
if (!authorized && context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
// If this is a synchronize event, check if Showtime is active and set blocked label
if (context.eventName === 'pull_request_target' && context.payload.action === 'synchronize') {
console.log(`🔒 Synchronize event detected - checking if Showtime is active`);
// Check if PR has any circus tent labels (Showtime is in use)
@@ -90,15 +99,6 @@ jobs:
}
}
if (!authorized) {
console.log(`🚨 Unauthorized user ${actor} - skipping all operations`);
core.setOutput('authorized', 'false');
return;
}
console.log(`✅ Authorized maintainer: ${actor}`);
core.setOutput('authorized', 'true');
- name: Install Superset Showtime
if: steps.auth.outputs.authorized == 'true'
run: |

View File

@@ -143,7 +143,7 @@ jobs:
- name: tsc
run: |
docker run --rm $TAG bash -c \
"npm run plugins:build && npm run type"
"npm run type"
validate-frontend:
needs: frontend-build

View File

@@ -1,141 +0,0 @@
name: Playwright E2E Tests
on:
push:
branches:
- "master"
- "[0-9].[0-9]*"
pull_request:
types: [synchronize, opened, reopened, ready_for_review]
workflow_dispatch:
inputs:
ref:
description: 'The branch or tag to checkout'
required: false
default: ''
pr_id:
description: 'The pull request ID to checkout'
required: false
default: ''
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}
cancel-in-progress: true
jobs:
playwright-tests:
runs-on: ubuntu-22.04
# Allow workflow to succeed even if tests fail during shadow mode
continue-on-error: true
permissions:
contents: read
pull-requests: read
strategy:
fail-fast: false
matrix:
browser: ["chromium"]
app_root: ["", "/app/prefix"]
env:
SUPERSET_ENV: development
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
SUPERSET__SQLALCHEMY_DATABASE_URI: postgresql+psycopg2://superset:superset@127.0.0.1:15432/superset
PYTHONPATH: ${{ github.workspace }}
REDIS_PORT: 16379
GITHUB_TOKEN: ${{ github.token }}
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: superset
POSTGRES_PASSWORD: superset
ports:
- 15432:5432
redis:
image: redis:7-alpine
ports:
- 16379:6379
steps:
# -------------------------------------------------------
# Conditional checkout based on context (same as Cypress workflow)
- name: Checkout for push or pull_request event
if: github.event_name == 'push' || github.event_name == 'pull_request'
uses: actions/checkout@v5
with:
persist-credentials: false
submodules: recursive
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout using ref (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.ref != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: ${{ github.event.inputs.ref }}
submodules: recursive
- name: Checkout using PR ID (workflow_dispatch)
if: github.event_name == 'workflow_dispatch' && github.event.inputs.pr_id != ''
uses: actions/checkout@v5
with:
persist-credentials: false
ref: refs/pull/${{ github.event.inputs.pr_id }}/merge
submodules: recursive
# -------------------------------------------------------
- name: Check for file changes
id: check
uses: ./.github/actions/change-detector/
with:
token: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Python
uses: ./.github/actions/setup-backend/
if: steps.check.outputs.python || steps.check.outputs.frontend
- name: Setup postgres
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: setup-postgres
- name: Import test data
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: testdata
- name: Setup Node.js
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: actions/setup-node@v4
with:
node-version-file: './superset-frontend/.nvmrc'
- name: Install npm dependencies
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: npm-install
- name: Build javascript packages
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: build-instrumented-assets
- name: Install Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
with:
run: playwright-install
- name: Run Playwright
if: steps.check.outputs.python || steps.check.outputs.frontend
uses: ./.github/actions/cached-dependencies
env:
NODE_OPTIONS: "--max-old-space-size=4096"
with:
run: playwright-run ${{ matrix.app_root }}
- name: Set safe app root
if: failure()
id: set-safe-app-root
run: |
APP_ROOT="${{ matrix.app_root }}"
SAFE_APP_ROOT=${APP_ROOT//\//_}
echo "safe_app_root=$SAFE_APP_ROOT" >> $GITHUB_OUTPUT
- name: Upload Playwright Artifacts
uses: actions/upload-artifact@v4
if: failure()
with:
path: |
${{ github.workspace }}/superset-frontend/playwright-results/
${{ github.workspace }}/superset-frontend/test-results/
name: playwright-artifact-${{ github.run_id }}-${{ github.job }}-${{ matrix.browser }}--${{ steps.set-safe-app-root.outputs.safe_app_root }}

30
LLMS.md
View File

@@ -15,9 +15,8 @@ Apache Superset is a data visualization platform with Flask/Python backend and R
### Testing Strategy Migration
- **Prefer unit tests** over integration tests
- **Prefer integration tests** over end-to-end tests
- **Use Playwright for E2E tests** - Migrating from Cypress
- **Cypress is deprecated** - Will be removed once migration is completed
- **Prefer integration tests** over Cypress end-to-end tests
- **Cypress is last resort** - Actively moving away from Cypress
- **Use Jest + React Testing Library** for component testing
- **Use `test()` instead of `describe()`** - Follow [avoid nesting when testing](https://kentcdodds.com/blog/avoid-nesting-when-youre-testing) principles
@@ -108,18 +107,6 @@ superset/
npm run test # All tests
npm run test -- filename.test.tsx # Single file
# E2E Tests (Playwright - NEW)
npm run playwright:test # All Playwright tests
npm run playwright:ui # Interactive UI mode
npm run playwright:headed # See browser during tests
npx playwright test tests/auth/login.spec.ts # Single file
npm run playwright:debug tests/auth/login.spec.ts # Debug specific file
# E2E Tests (Cypress - DEPRECATED)
cd superset-frontend/cypress-base
npm run cypress-run-chrome # All Cypress tests (headless)
npm run cypress-debug # Interactive Cypress UI
# Backend
pytest # All tests
pytest tests/unit_tests/specific_test.py # Single file
@@ -149,19 +136,6 @@ curl -f http://localhost:8088/health || echo "❌ Setup required - see https://s
- **Use negation operator**: `~Model.field` instead of `== False` to avoid ruff E712 errors
- **Example**: `~Model.is_active` instead of `Model.is_active == False`
## Pull Request Guidelines
**When creating pull requests:**
1. **Read the current PR template**: Always check `.github/PULL_REQUEST_TEMPLATE.md` for the latest format
2. **Use the template sections**: Include all sections from the template (SUMMARY, BEFORE/AFTER, TESTING INSTRUCTIONS, ADDITIONAL INFORMATION)
3. **Follow PR title conventions**: Use [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/)
- Format: `type(scope): description`
- Example: `fix(dashboard): load charts correctly`
- Types: `fix`, `feat`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
**Important**: Always reference the actual template file at `.github/PULL_REQUEST_TEMPLATE.md` instead of using cached content, as the template may be updated over time.
## Pre-commit Validation
**Use pre-commit hooks for quality validation:**

348
SIDECAR_INTEGRATION.md Normal file
View File

@@ -0,0 +1,348 @@
# Query Sidecar Service Integration
This document describes the Node.js Query Sidecar Service integration that eliminates stale QueryObject issues in Superset Alerts & Reports.
## Problem Statement
Previously, Superset stored QueryObjects in the database after chart visualization logic transformed `form_data` into QueryObject format. This approach had a critical flaw: when the JavaScript visualization code changed, the stored QueryObjects became stale, causing Alerts & Reports to use outdated query logic.
## Solution
The Query Sidecar Service provides a Node.js service that computes QueryObjects from `form_data` on-demand using the same logic as the frontend, ensuring:
- **No stale data**: QueryObjects are computed fresh every time
- **Consistency**: Uses identical logic to the Superset frontend
- **Backward compatibility**: Falls back to legacy screenshot method if sidecar is unavailable
## Architecture
```mermaid
graph TB
A[Superset Frontend] --> B[form_data]
B --> C[Chart Database Record]
D[Alerts & Reports] --> E{Sidecar Available?}
E -->|Yes| F[Query Sidecar Service]
E -->|No| G[Legacy Screenshot Method]
F --> H[buildQueryObject.ts Logic]
H --> I[Fresh QueryObject]
C --> F
I --> J[Chart Data API]
G --> J
```
## Components
### 1. Node.js Sidecar Service (`sidecar-node/`)
Located in `sidecar-node/`, this service provides:
- **REST API**: `POST /api/v1/query-object` to transform form_data
- **Type Safety**: Full TypeScript implementation with Superset type definitions
- **Frontend Compatibility**: Uses identical logic from `superset-ui-core`
- **Health Checks**: `/health` endpoint for monitoring
- **Docker Support**: Production-ready containerization
Key files:
- `src/query/buildQueryObject.ts` - Main transformation logic
- `src/types/index.ts` - TypeScript type definitions
- `src/routes/queryObject.ts` - REST API endpoints
- `Dockerfile` - Container configuration
### 2. Python Client (`superset/utils/query_sidecar.py`)
Python client library that:
- **HTTP Communication**: Handles requests to the sidecar service
- **Error Handling**: Robust error handling with fallback mechanisms
- **QueryObject Creation**: Converts responses to Superset QueryObject instances
- **Configuration**: Configurable timeouts and URLs
### 3. Reports Integration (`superset/commands/report/execute.py`)
Updated report execution logic:
- **Primary Method**: Uses sidecar service to generate fresh QueryObjects
- **Fallback Method**: Falls back to legacy screenshot method on failure
- **Configuration**: Controlled by `QUERY_SIDECAR_ENABLED` setting
## Setup and Configuration
### 1. Deploy the Sidecar Service
#### Development
```bash
cd sidecar-node
npm install
npm run dev
```
#### Production with Docker
```bash
cd sidecar-node
docker build -t superset-query-sidecar .
docker run -p 3001:3001 superset-query-sidecar
```
#### Production with Docker Compose
```bash
docker-compose -f docker-compose.sidecar.yml up -d
```
### 2. Configure Superset
Add to your Superset configuration:
```python
# Enable sidecar service integration
QUERY_SIDECAR_ENABLED = True
# Sidecar service URL
QUERY_SIDECAR_BASE_URL = "http://localhost:3001"
# Request timeout (seconds)
QUERY_SIDECAR_TIMEOUT = 10
```
#### Production Configuration Example
```python
QUERY_SIDECAR_ENABLED = True
QUERY_SIDECAR_BASE_URL = "http://superset-query-sidecar:3001"
QUERY_SIDECAR_TIMEOUT = 30
```
### 3. Verify Integration
#### Check Sidecar Health
```bash
curl http://localhost:3001/health
```
#### Test API Endpoint
```bash
curl -X POST http://localhost:3001/api/v1/query-object \
-H "Content-Type: application/json" \
-d '{
"form_data": {
"datasource": "1__table",
"viz_type": "table",
"metrics": ["count"],
"columns": ["name"]
}
}'
```
## API Reference
### POST /api/v1/query-object
Transforms `form_data` into a QueryObject.
**Request:**
```json
{
"form_data": {
"datasource": "1__table",
"viz_type": "table",
"metrics": ["count"],
"columns": ["name"],
"time_range": "No filter"
},
"query_fields": {
"x": "columns",
"y": "metrics"
}
}
```
**Response:**
```json
{
"query_object": {
"metrics": ["count"],
"columns": ["name"],
"time_range": "No filter",
"filters": [],
"extras": {},
"row_limit": undefined,
"order_desc": true
}
}
```
**Error Response:**
```json
{
"error": "form_data must include datasource and viz_type"
}
```
### GET /health
Returns service health status.
**Response:**
```json
{
"status": "healthy",
"timestamp": "2023-12-01T12:00:00.000Z",
"version": "1.0.0"
}
```
## Error Handling
The integration includes comprehensive error handling:
### Sidecar Service Errors
- **Connection Errors**: Falls back to legacy screenshot method
- **Timeout Errors**: Configurable timeout with fallback
- **Service Errors**: Logs errors and uses fallback method
### Configuration
```python
# Disable sidecar to use legacy method only
QUERY_SIDECAR_ENABLED = False
# Increase timeout for slow networks
QUERY_SIDECAR_TIMEOUT = 30
```
## Migration Strategy
### Phase 1: Parallel Running
1. Deploy sidecar service alongside existing Superset
2. Enable `QUERY_SIDECAR_ENABLED = True`
3. Monitor logs for any fallback usage
### Phase 2: Validation
1. Compare QueryObjects generated by sidecar vs stored versions
2. Verify Alerts & Reports work correctly
3. Monitor performance metrics
### Phase 3: Full Migration
1. Remove dependency on stored query_context (future)
2. Optimize sidecar performance
3. Scale sidecar service as needed
## Testing
### Node.js Service Tests
```bash
cd sidecar-node
npm test
npm run test:watch
```
### Python Integration Tests
```bash
pytest tests/unit_tests/utils/test_query_sidecar.py
pytest tests/unit_tests/commands/report/test_execute_sidecar.py
```
### Integration Testing
1. Create test alerts/reports
2. Verify they execute with fresh QueryObjects
3. Test fallback behavior by stopping sidecar service
## Monitoring and Logging
### Service Monitoring
- Health check endpoint: `GET /health`
- Docker health checks included
- Prometheus metrics (future enhancement)
### Superset Logging
The integration adds detailed logging:
```python
logger.info("Successfully generated query context via sidecar for chart %s", chart_id)
logger.warning("Failed to generate query context via sidecar service: %s. Falling back to screenshot method.", error)
```
### Log Levels
- `INFO`: Successful sidecar operations
- `WARNING`: Fallback to legacy method
- `ERROR`: Critical failures
## Performance Considerations
### Latency
- Sidecar adds ~10-50ms per request
- Network latency between services
- Configurable timeout prevents blocking
### Scaling
- Sidecar service is stateless and can be horizontally scaled
- Consider load balancing for high-volume deployments
- Cache QueryObjects if needed (future enhancement)
### Resource Usage
- Node.js service: ~50-100MB RAM
- CPU usage minimal for typical workloads
- Network bandwidth: ~1-10KB per request
## Future Enhancements
1. **Caching**: Add Redis/Memcached for QueryObject caching
2. **Metrics**: Prometheus metrics for monitoring
3. **Load Balancing**: Support multiple sidecar instances
4. **Query Optimization**: Optimize query generation performance
5. **Database Cleanup**: Remove stored query_context column (breaking change)
## Troubleshooting
### Common Issues
#### Sidecar Service Not Starting
```bash
# Check logs
docker logs superset-query-sidecar
# Verify port availability
netstat -tlnp | grep 3001
```
#### Connection Errors
```bash
# Test connectivity
curl http://localhost:3001/health
# Check Superset logs
grep -i "sidecar" /var/log/superset/superset.log
```
#### Fallback Behavior
```bash
# Check if sidecar is being bypassed
grep -i "falling back" /var/log/superset/superset.log
```
### Configuration Issues
- Verify `QUERY_SIDECAR_BASE_URL` is correct
- Check network connectivity between services
- Ensure sidecar service is healthy
### Performance Issues
- Increase `QUERY_SIDECAR_TIMEOUT` if needed
- Monitor sidecar service resource usage
- Consider scaling sidecar service
## Security Considerations
### Network Security
- Run sidecar service on internal network
- Use HTTPS in production environments
- Configure proper firewall rules
### Input Validation
- Sidecar service validates all inputs
- Superset client validates responses
- No raw SQL execution in sidecar service
### Access Control
- Service-to-service authentication (future)
- Network-level access controls
- Audit logging of sidecar requests

View File

@@ -23,8 +23,6 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.
- [34782](https://github.com/apache/superset/pull/34782): Dataset exports now include the dataset ID in their file name (similar to charts and dashboards). If managing assets as code, make sure to rename existing dataset YAMLs to include the ID (and avoid duplicated files).
- [34536](https://github.com/apache/superset/pull/34536): The `ENVIRONMENT_TAG_CONFIG` color values have changed to support only Ant Design semantic colors. Update your `superset_config.py`:

View File

@@ -162,11 +162,8 @@ services:
SCARF_ANALYTICS: "${SCARF_ANALYTICS:-}"
# configuring the dev-server to use the host.docker.internal to connect to the backend
superset: "http://superset-light:8088"
# Webpack dev server configuration
WEBPACK_DEVSERVER_HOST: "${WEBPACK_DEVSERVER_HOST:-0.0.0.0}"
WEBPACK_DEVSERVER_PORT: "${WEBPACK_DEVSERVER_PORT:-9000}"
ports:
- "${NODE_PORT:-9001}:9000" # Parameterized port, accessible on all interfaces
- "127.0.0.1:${NODE_PORT:-9001}:9000" # Parameterized port
command: ["/app/docker/docker-frontend.sh"]
env_file:
- path: docker/.env # default

View File

@@ -0,0 +1,60 @@
# 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.
version: '3.8'
services:
superset-query-sidecar:
build:
context: ./sidecar-node
dockerfile: Dockerfile
container_name: superset-query-sidecar
ports:
- "3001:3001"
environment:
- NODE_ENV=production
- HOST=0.0.0.0
- PORT=3001
- SUPERSET_ORIGINS=http://localhost:8088,http://superset:8088
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
restart: unless-stopped
networks:
- superset
# Example integration with existing Superset service
# Uncomment and modify according to your setup
#
# superset:
# # ... your existing superset configuration
# environment:
# - QUERY_SIDECAR_ENABLED=true
# - QUERY_SIDECAR_BASE_URL=http://superset-query-sidecar:3001
# - QUERY_SIDECAR_TIMEOUT=30
# depends_on:
# superset-query-sidecar:
# condition: service_healthy
# networks:
# - superset
networks:
superset:
external: true

View File

@@ -138,7 +138,7 @@ try:
from superset_config_docker import * # noqa: F403
logger.info(
"Loaded your Docker configuration at [%s]", superset_config_docker.__file__
f"Loaded your Docker configuration at [{superset_config_docker.__file__}]"
)
except ImportError:
logger.info("Using default Docker config...")

View File

@@ -363,6 +363,110 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -10,15 +10,8 @@ version: 1
## Jinja Templates
SQL Lab and Explore supports [Jinja templating](https://jinja.palletsprojects.com/en/2.11.x/) in queries.
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in `superset_config.py`.
> #### ⚠️ Security Warning
>
> While powerful, this feature executes template code on the server. Within the Superset security model, this is **intended functionality**, as users with permissions to edit charts and virtual datasets are considered **trusted users**.
>
> If you grant these permissions to untrusted users, this feature can be exploited as a **Server-Side Template Injection (SSTI)** vulnerability. Do not enable `ENABLE_TEMPLATE_PROCESSING` unless you fully understand and accept the associated security risks.
When templating is enabled, python code can be embedded in virtual datasets and
To enable templating, the `ENABLE_TEMPLATE_PROCESSING` [feature flag](/docs/configuration/configuring-superset#feature-flags) needs to be enabled in
`superset_config.py`. When templating is enabled, python code can be embedded in virtual datasets and
in Custom SQL in the filter and metric controls in Explore. By default, the following variables are
made available in the Jinja context:

View File

@@ -165,206 +165,6 @@ Or in the CRUD interface theme JSON:
This feature works with the stock Docker image - no custom build required!
## ECharts Configuration Overrides
:::note
Available since Superset 6.0
:::
Superset provides fine-grained control over ECharts visualizations through theme-level configuration overrides. This allows you to customize the appearance and behavior of all ECharts-based charts without modifying individual chart configurations.
### Global ECharts Overrides
Apply settings to all ECharts visualizations using `echartsOptionsOverrides`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other Ant Design tokens
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%",
"top": "15%",
"bottom": "15%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)",
"borderColor": "#ccc",
"textStyle": {
"color": "#fff"
}
},
"legend": {
"textStyle": {
"fontSize": 14,
"fontWeight": "bold"
}
}
}
}
```
### Chart-Specific Overrides
Target specific chart types using `echartsOptionsOverridesByChartType`:
```python
THEME_DEFAULT = {
"token": {
"colorPrimary": "#2893B3",
# ... other tokens
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10,
"top": "center"
}
},
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"rotate": 45,
"fontSize": 12
}
},
"dataZoom": [{
"type": "slider",
"show": True,
"start": 0,
"end": 100
}]
},
"echarts_bubble": {
"grid": {
"left": "15%",
"bottom": "20%"
}
}
}
}
```
### UI Configuration
You can also configure ECharts overrides through the theme CRUD interface:
```json
{
"token": {
"colorPrimary": "#2893B3"
},
"echartsOptionsOverrides": {
"grid": {
"left": "10%",
"right": "10%"
},
"tooltip": {
"backgroundColor": "rgba(0, 0, 0, 0.8)"
}
},
"echartsOptionsOverridesByChartType": {
"echarts_pie": {
"legend": {
"orient": "vertical",
"right": 10
}
}
}
}
```
### Override Precedence
The system applies overrides in the following order (last wins):
1. **Base ECharts theme** - Default Superset styling
2. **Plugin options** - Chart-specific configurations
3. **Global overrides** - `echartsOptionsOverrides`
4. **Chart-specific overrides** - `echartsOptionsOverridesByChartType[chartType]`
This ensures chart-specific overrides take precedence over global ones.
### Common Chart Types
Available chart types for `echartsOptionsOverridesByChartType`:
- `echarts_timeseries` - Time series/line charts
- `echarts_pie` - Pie and donut charts
- `echarts_bubble` - Bubble/scatter charts
- `echarts_funnel` - Funnel charts
- `echarts_gauge` - Gauge charts
- `echarts_radar` - Radar charts
- `echarts_boxplot` - Box plot charts
- `echarts_treemap` - Treemap charts
- `echarts_sunburst` - Sunburst charts
- `echarts_graph` - Network/graph charts
- `echarts_sankey` - Sankey diagrams
- `echarts_heatmap` - Heatmaps
- `echarts_mixed_timeseries` - Mixed time series
### Best Practices
1. **Start with global overrides** for consistent styling across all charts
2. **Use chart-specific overrides** for unique requirements per visualization type
3. **Test thoroughly** as overrides use deep merge - nested objects are combined, but arrays are completely replaced
4. **Document your overrides** to help team members understand custom styling
5. **Consider performance** - complex overrides may impact chart rendering speed
### Example: Corporate Branding
```python
# Complete corporate theme with ECharts customization
THEME_DEFAULT = {
"token": {
"colorPrimary": "#1B4D3E",
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"echartsOptionsOverrides": {
"grid": {
"left": "8%",
"right": "8%",
"top": "12%",
"bottom": "12%"
},
"textStyle": {
"fontFamily": "Corporate Sans, Arial, sans-serif"
},
"title": {
"textStyle": {
"color": "#1B4D3E",
"fontSize": 18,
"fontWeight": "bold"
}
}
},
"echartsOptionsOverridesByChartType": {
"echarts_timeseries": {
"xAxis": {
"axisLabel": {
"color": "#666",
"fontSize": 11
}
}
},
"echarts_pie": {
"legend": {
"textStyle": {
"fontSize": 12
},
"itemGap": 20
}
}
}
}
```
This feature provides powerful theming capabilities while maintaining the flexibility of ECharts' extensive configuration options.
## Advanced Features
- **System Themes**: Manage system-wide default and dark themes via UI or configuration

View File

@@ -620,10 +620,10 @@ See [how tos](/docs/contributing/howtos#linting)
:::tip
`act` compatibility of Superset's GHAs is not fully tested. Running `act` locally may or may not
work for different actions, and may require fine tuning and local secret-handling.
work for different actions, and may require fine tunning and local secret-handling.
For those more intricate GHAs that are tricky to run locally, we recommend iterating
directly on GHA's infrastructure, by pushing directly on a branch and monitoring GHA logs.
For more targeted iteration, see the `gh workflow run --ref {BRANCH}` subcommand of the GitHub CLI.
For more targetted iteration, see the `gh workflow run --ref {BRANCH}` subcommand of the GitHub CLI.
:::
For automation and CI/CD, Superset makes extensive use of GitHub Actions (GHA). You
@@ -631,7 +631,7 @@ can find all of the workflows and other assets under the `.github/` folder. This
- running the backend unit test suites (`tests/`)
- running the frontend test suites (`superset-frontend/src/**.*.test.*`)
- running our Playwright end-to-end tests (`superset-frontend/playwright/`) and legacy Cypress tests (`superset-frontend/cypress-base/`)
- running our Cypress end-to-end tests (`superset-frontend/cypress-base/`)
- linting the codebase, including all Python, Typescript and Javascript, yaml and beyond
- checking for all sorts of other rules conventions

View File

@@ -225,57 +225,21 @@ npm run test -- path/to/file.js
### E2E Integration Testing
**Note: We are migrating from Cypress to Playwright. Use Playwright for new tests.**
#### Playwright (Recommended - NEW)
For E2E testing with Playwright, use the same `docker compose` backend:
For E2E testing, we recommend that you use a `docker compose` backend
```bash
CYPRESS_CONFIG=true docker compose up --build
```
`docker compose` will get to work and expose a Cypress-ready Superset app.
This app uses a different database schema (`superset_cypress`) to keep it isolated from
your other dev environmen(s)t, a specific set of examples, and a set of configurations that
aligns with the expectations within the end-to-end tests. Also note that it's served on a
different port than the default port for the backend (`8088`).
The backend setup is identical - this exposes a test-ready Superset app on port 8081 with isolated database schema (`superset_cypress`), test data, and configurations.
Now in another terminal, let's get ready to execute some Cypress commands. First, tell cypress
to connect to the Cypress-ready Superset backend.
Now in another terminal, run Playwright tests:
```bash
# Navigate to frontend directory (Playwright config is here)
cd superset-frontend
# Run all Playwright tests
npm run playwright:test
# or: npx playwright test
# Run with interactive UI for debugging
npm run playwright:ui
# or: npx playwright test --ui
# Run in headed mode (see browser)
npm run playwright:headed
# or: npx playwright test --headed
# Run specific test file
npx playwright test tests/auth/login.spec.ts
# Run with debug mode (step through tests)
npm run playwright:debug tests/auth/login.spec.ts
# or: npx playwright test --debug tests/auth/login.spec.ts
# Generate test report
npx playwright show-report
```
Configuration is in `superset-frontend/playwright.config.ts`. Base URL is automatically set to `http://localhost:8088` but will use `PLAYWRIGHT_BASE_URL` if provided.
#### Cypress (DEPRECATED - will be removed in Phase 5)
:::warning
Cypress is being phased out in favor of Playwright. Use Playwright for all new tests.
:::
```bash
# Set base URL for Cypress
CYPRESS_BASE_URL=http://localhost:8081
```
@@ -663,7 +627,7 @@ feature flag to `true`, you can add the following line to the PR body/descriptio
FEATURE_TAGGING_SYSTEM=true
```
Similarly, it's possible to disable feature flags with:
Simarly, it's possible to disable feature flags with:
```
FEATURE_TAGGING_SYSTEM=false

View File

@@ -282,5 +282,5 @@ address.
When running `docker compose up`, docker will build what is required behind the scene, but
may use the docker cache if assets already exist. Running `docker compose build` prior to
`docker compose up` or the equivalent shortcut `docker compose up --build` ensures that your
docker images match the definition in the repository. This should only apply to the main
docker images matche the definition in the repository. This should only apply to the main
docker-compose.yml file (default) and not to the alternative methods defined above.

View File

@@ -65,7 +65,7 @@
"storybook": "^8.6.11",
"swagger-ui-react": "^5.27.1",
"tinycolor2": "^1.4.2",
"ts-loader": "^9.5.4"
"ts-loader": "^9.5.2"
},
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.8.1",
@@ -73,7 +73,7 @@
"@eslint/js": "^9.32.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.42.0",
"@typescript-eslint/parser": "^8.37.0",
"eslint": "^9.34.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",

View File

@@ -363,6 +363,110 @@ CUSTOM_SECURITY_MANAGER = CustomSsoSecurityManager
]
```
### Keycloak-Specific Configuration using Flask-OIDC
If you are using Keycloak as OpenID Connect 1.0 Provider, the above configuration based on [`Authlib`](https://authlib.org/) might not work. In this case using [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is a viable option.
Make sure the pip package [`Flask-OIDC`](https://pypi.org/project/flask-oidc/) is installed on the webserver. This was successfully tested using version 2.2.0. This package requires [`Flask-OpenID`](https://pypi.org/project/Flask-OpenID/) as a dependency.
The following code defines a new security manager. Add it to a new file named `keycloak_security_manager.py`, placed in the same directory as your `superset_config.py` file.
```python
from flask_appbuilder.security.manager import AUTH_OID
from superset.security import SupersetSecurityManager
from flask_oidc import OpenIDConnect
from flask_appbuilder.security.views import AuthOIDView
from flask_login import login_user
from urllib.parse import quote
from flask_appbuilder.views import ModelView, SimpleFormView, expose
from flask import (
redirect,
request
)
import logging
class OIDCSecurityManager(SupersetSecurityManager):
def __init__(self, appbuilder):
super(OIDCSecurityManager, self).__init__(appbuilder)
if self.auth_type == AUTH_OID:
self.oid = OpenIDConnect(self.appbuilder.get_app)
self.authoidview = AuthOIDCView
class AuthOIDCView(AuthOIDView):
@expose('/login/', methods=['GET', 'POST'])
def login(self, flag=True):
sm = self.appbuilder.sm
oidc = sm.oid
@self.appbuilder.sm.oid.require_login
def handle_login():
user = sm.auth_user_oid(oidc.user_getfield('email'))
if user is None:
info = oidc.user_getinfo(['preferred_username', 'given_name', 'family_name', 'email'])
user = sm.add_user(info.get('preferred_username'), info.get('given_name'), info.get('family_name'),
info.get('email'), sm.find_role('Gamma'))
login_user(user, remember=False)
return redirect(self.appbuilder.get_url_for_index)
return handle_login()
@expose('/logout/', methods=['GET', 'POST'])
def logout(self):
oidc = self.appbuilder.sm.oid
oidc.logout()
super(AuthOIDCView, self).logout()
redirect_url = request.url_root.strip('/') + self.appbuilder.get_url_for_login
return redirect(
oidc.client_secrets.get('issuer') + '/protocol/openid-connect/logout?redirect_uri=' + quote(redirect_url))
```
Then add to your `superset_config.py` file:
```python
from keycloak_security_manager import OIDCSecurityManager
from flask_appbuilder.security.manager import AUTH_OID, AUTH_REMOTE_USER, AUTH_DB, AUTH_LDAP, AUTH_OAUTH
import os
AUTH_TYPE = AUTH_OID
SECRET_KEY: 'SomethingNotEntirelySecret'
OIDC_CLIENT_SECRETS = '/path/to/client_secret.json'
OIDC_ID_TOKEN_COOKIE_SECURE = False
OIDC_OPENID_REALM: '<myRealm>'
OIDC_INTROSPECTION_AUTH_METHOD: 'client_secret_post'
CUSTOM_SECURITY_MANAGER = OIDCSecurityManager
# Will allow user self registration, allowing to create Flask users from Authorized User
AUTH_USER_REGISTRATION = True
# The default user self registration role
AUTH_USER_REGISTRATION_ROLE = 'Public'
```
Store your client-specific OpenID information in a file called `client_secret.json`. Create this file in the same directory as `superset_config.py`:
```json
{
"<myOpenIDProvider>": {
"issuer": "https://<myKeycloakDomain>/realms/<myRealm>",
"auth_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/auth",
"client_id": "https://<myKeycloakDomain>",
"client_secret": "<myClientSecret>",
"redirect_uris": [
"https://<SupersetWebserver>/oauth-authorized/<myOpenIDProvider>"
],
"userinfo_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/userinfo",
"token_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token",
"token_introspection_uri": "https://<myKeycloakDomain>/realms/<myRealm>/protocol/openid-connect/token/introspect"
}
}
```
## LDAP Authentication
FAB supports authenticating user credentials against an LDAP server.

View File

@@ -620,10 +620,10 @@ See [how tos](/docs/contributing/howtos#linting)
:::tip
`act` compatibility of Superset's GHAs is not fully tested. Running `act` locally may or may not
work for different actions, and may require fine tuning and local secret-handling.
work for different actions, and may require fine tunning and local secret-handling.
For those more intricate GHAs that are tricky to run locally, we recommend iterating
directly on GHA's infrastructure, by pushing directly on a branch and monitoring GHA logs.
For more targeted iteration, see the `gh workflow run --ref {BRANCH}` subcommand of the GitHub CLI.
For more targetted iteration, see the `gh workflow run --ref {BRANCH}` subcommand of the GitHub CLI.
:::
For automation and CI/CD, Superset makes extensive use of GitHub Actions (GHA). You

View File

@@ -232,7 +232,7 @@ CYPRESS_CONFIG=true docker compose up --build
```
`docker compose` will get to work and expose a Cypress-ready Superset app.
This app uses a different database schema (`superset_cypress`) to keep it isolated from
your other dev environment(s), a specific set of examples, and a set of configurations that
your other dev environmen(s)t, a specific set of examples, and a set of configurations that
aligns with the expectations within the end-to-end tests. Also note that it's served on a
different port than the default port for the backend (`8088`).
@@ -627,7 +627,7 @@ feature flag to `true`, you can add the following line to the PR body/descriptio
FEATURE_TAGGING_SYSTEM=true
```
Similarly, it's possible to disable feature flags with:
Simarly, it's possible to disable feature flags with:
```
FEATURE_TAGGING_SYSTEM=false

View File

@@ -282,5 +282,5 @@ address.
When running `docker compose up`, docker will build what is required behind the scene, but
may use the docker cache if assets already exist. Running `docker compose build` prior to
`docker compose up` or the equivalent shortcut `docker compose up --build` ensures that your
docker images match the definition in the repository. This should only apply to the main
docker images matche the definition in the repository. This should only apply to the main
docker-compose.yml file (default) and not to the alternative methods defined above.

View File

@@ -4102,7 +4102,7 @@
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.40.0":
"@typescript-eslint/parser@8.40.0", "@typescript-eslint/parser@^8.37.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.40.0.tgz#1bc9f3701ced29540eb76ff2d95ce0d52ddc7e69"
integrity sha512-jCNyAuXx8dr5KJMkecGmZ8KI61KBUhkCob+SD+C+I5+Y1FWI2Y3QmY4/cxMCC5WAsZqoEtEETVhUiUMIGCf6Bw==
@@ -4113,17 +4113,6 @@
"@typescript-eslint/visitor-keys" "8.40.0"
debug "^4.3.4"
"@typescript-eslint/parser@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.42.0.tgz#20ea66f4867981fb5bb62cbe1454250fc4a440ab"
integrity sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==
dependencies:
"@typescript-eslint/scope-manager" "8.42.0"
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/typescript-estree" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.40.0.tgz#1b7ba6079ff580c3215882fe75a43e5d3ed166b9"
@@ -4133,15 +4122,6 @@
"@typescript-eslint/types" "^8.40.0"
debug "^4.3.4"
"@typescript-eslint/project-service@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.42.0.tgz#636eb3418b6c42c98554dce884943708bf41a583"
integrity sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.42.0"
"@typescript-eslint/types" "^8.42.0"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.40.0.tgz#2fbfcc8643340d8cd692267e61548b946190be8a"
@@ -4150,24 +4130,11 @@
"@typescript-eslint/types" "8.40.0"
"@typescript-eslint/visitor-keys" "8.40.0"
"@typescript-eslint/scope-manager@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz#36016757bc85b46ea42bae47b61f9421eddedde3"
integrity sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==
dependencies:
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
"@typescript-eslint/tsconfig-utils@8.40.0", "@typescript-eslint/tsconfig-utils@^8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.40.0.tgz#8e8fdb9b988854aedd04abdde3239c4bdd2d26e4"
integrity sha512-jtMytmUaG9d/9kqSl/W3E3xaWESo4hFDxAIHGVW/WKKtQhesnRIJSAJO6XckluuJ6KDB5woD1EiqknriCtAmcw==
"@typescript-eslint/tsconfig-utils@8.42.0", "@typescript-eslint/tsconfig-utils@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz#21a3e74396fd7443ff930bc41b27789ba7e9236e"
integrity sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==
"@typescript-eslint/type-utils@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.40.0.tgz#a7e4a1f0815dd0ba3e4eef945cc87193ca32c422"
@@ -4179,16 +4146,11 @@
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.40.0":
"@typescript-eslint/types@8.40.0", "@typescript-eslint/types@^8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.40.0.tgz#0b580fdf643737aa5c01285314b5c6e9543846a9"
integrity sha512-ETdbFlgbAmXHyFPwqUIYrfc12ArvpBhEVgGAxVYSwli26dn8Ko+lIo4Su9vI9ykTZdJn+vJprs/0eZU0YMAEQg==
"@typescript-eslint/types@8.42.0", "@typescript-eslint/types@^8.40.0", "@typescript-eslint/types@^8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.42.0.tgz#ae15c09cebda20473772902033328e87372db008"
integrity sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==
"@typescript-eslint/typescript-estree@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.40.0.tgz#295149440ce7da81c790a4e14e327599a3a1e5c9"
@@ -4205,22 +4167,6 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/typescript-estree@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz#593c3af87d4462252c0d7239d1720b84a1b56864"
integrity sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==
dependencies:
"@typescript-eslint/project-service" "8.42.0"
"@typescript-eslint/tsconfig-utils" "8.42.0"
"@typescript-eslint/types" "8.42.0"
"@typescript-eslint/visitor-keys" "8.42.0"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
minimatch "^9.0.4"
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.40.0":
version "8.40.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.40.0.tgz#8d0c6430ed2f5dc350784bb0d8be514da1e54054"
@@ -4239,14 +4185,6 @@
"@typescript-eslint/types" "8.40.0"
eslint-visitor-keys "^4.2.1"
"@typescript-eslint/visitor-keys@8.42.0":
version "8.42.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz#87c6caaa1ac307bc73a87c1fc469f88f0162f27e"
integrity sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==
dependencies:
"@typescript-eslint/types" "8.42.0"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8"
@@ -4766,9 +4704,9 @@ available-typed-arrays@^1.0.7:
possible-typed-array-names "^1.0.0"
axios@^1.9.0:
version "1.12.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.12.0.tgz#11248459be05a5ee493485628fa0e4323d0abfc3"
integrity sha512-oXTDccv8PcfjZmPGlWsPSwtOJCZ/b6W5jAMCNcfwJbCzDckwG0jrYJFaWH1yvivfCXjVzV/SPDEhMB3Q+DSurg==
version "1.11.0"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.11.0.tgz#c2ec219e35e414c025b2095e8b8280278478fdb6"
integrity sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==
dependencies:
follow-redirects "^1.15.6"
form-data "^4.0.4"
@@ -13201,10 +13139,10 @@ ts-dedent@^2.0.0, ts-dedent@^2.2.0:
resolved "https://registry.yarnpkg.com/ts-dedent/-/ts-dedent-2.2.0.tgz#39e4bd297cd036292ae2394eb3412be63f563bb5"
integrity sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==
ts-loader@^9.5.4:
version "9.5.4"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.4.tgz#44b571165c10fb5a90744aa5b7e119233c4f4585"
integrity sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==
ts-loader@^9.5.2:
version "9.5.2"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.5.2.tgz#1f3d7f4bb709b487aaa260e8f19b301635d08020"
integrity sha512-Qo4piXvOTWcMGIgRiuFa6nHNm+54HbYaZCKqc9eeZCLRy3XqafQgwX2F7mofrbJG3g7EEb+lkiR+z2Lic2s3Zw==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.0.0"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=5.0.0,<6",
"flask-appbuilder>=4.8.1, <5.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -76,7 +76,7 @@ dependencies = [
"packaging",
# --------------------------
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
"pandas[excel]>=2.0.3, <2.2",
"pandas[excel]>=2.0.3, <2.1",
"bottleneck", # recommended performance dependency for pandas, see https://pandas.pydata.org/docs/getting_started/install.html#performance-dependencies-recommended
# --------------------------
"parsedatetime",
@@ -84,12 +84,11 @@ dependencies = [
"pgsanity",
"Pillow>=11.0.0, <12",
"polyline>=2.0.0, <3.0",
"pydantic>=2.8.0",
"pyparsing>=3.0.6, <4",
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"python-geohash",
"pyarrow>=16.1.0, <19", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyarrow>=16.1.0, <17", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"pyyaml>=6.0.0, <7.0.0",
"PyJWT>=2.4.0, <3.0",
"redis>=4.6.0, <5.0",
@@ -314,7 +313,6 @@ select = [
"E",
"F",
"F",
"G",
"I",
"N",
"PT",
@@ -330,7 +328,6 @@ ignore = [
"PT006",
"T201",
"N999",
"G201",
]
extend-select = ["I"]

View File

@@ -6,8 +6,6 @@ alembic==1.15.2
# via flask-migrate
amqp==5.3.1
# via kombu
annotated-types==0.7.0
# via pydantic
apispec==6.6.1
# via
# -r requirements/base.in
@@ -116,11 +114,11 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.0
flask-appbuilder==4.8.1
# via
# apache-superset (pyproject.toml)
# apache-superset-core
flask-babel==3.1.0
flask-babel==2.0.0
# via flask-appbuilder
flask-caching==2.3.1
# via apache-superset (pyproject.toml)
@@ -160,7 +158,6 @@ greenlet==3.1.1
# via
# apache-superset (pyproject.toml)
# shillelagh
# sqlalchemy
gunicorn==23.0.0
# via apache-superset (pyproject.toml)
h11==0.16.0
@@ -267,7 +264,7 @@ packaging==25.0
# limits
# marshmallow
# shillelagh
pandas==2.1.4
pandas==2.0.3
# via apache-superset (pyproject.toml)
paramiko==3.5.1
# via
@@ -299,10 +296,6 @@ pyasn1-modules==0.4.2
# via google-auth
pycparser==2.22
# via cffi
pydantic==2.11.7
# via apache-superset (pyproject.toml)
pydantic-core==2.33.2
# via pydantic
pygments==2.19.1
# via rich
pyjwt==2.10.1
@@ -413,15 +406,10 @@ typing-extensions==4.14.0
# alembic
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via pydantic
tzdata==2025.2
# via
# kombu

View File

@@ -18,10 +18,6 @@ amqp==5.3.1
# via
# -c requirements/base-constraint.txt
# kombu
annotated-types==0.7.0
# via
# -c requirements/base-constraint.txt
# pydantic
apispec==6.6.1
# via
# -c requirements/base-constraint.txt
@@ -212,12 +208,12 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.0.0
flask-appbuilder==4.8.1
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
flask-babel==3.1.0
flask-babel==2.0.0
# via
# -c requirements/base-constraint.txt
# flask-appbuilder
@@ -331,7 +327,6 @@ greenlet==3.1.1
# apache-superset
# gevent
# shillelagh
# sqlalchemy
grpcio==1.71.0
# via
# apache-superset
@@ -537,7 +532,7 @@ packaging==25.0
# pytest
# shillelagh
# sqlalchemy-bigquery
pandas==2.1.4
pandas==2.0.3
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -637,14 +632,6 @@ pycparser==2.22
# via
# -c requirements/base-constraint.txt
# cffi
pydantic==2.11.7
# via
# -c requirements/base-constraint.txt
# apache-superset
pydantic-core==2.33.2
# via
# -c requirements/base-constraint.txt
# pydantic
pydata-google-auth==1.9.0
# via pandas-gbq
pydruid==0.6.9
@@ -888,17 +875,10 @@ typing-extensions==4.14.0
# apache-superset
# cattrs
# limits
# pydantic
# pydantic-core
# pyopenssl
# referencing
# selenium
# shillelagh
# typing-inspection
typing-inspection==0.4.1
# via
# -c requirements/base-constraint.txt
# pydantic
tzdata==2025.2
# via
# -c requirements/base-constraint.txt

View File

@@ -15,14 +15,37 @@
# specific language governing permissions and limitations
# under the License.
"""Tests for Impala dialect support in sqlglot."""
FROM node:18-alpine
from sqlglot import Dialects
# Set working directory
WORKDIR /app
from superset.sql.parse import SQLGLOT_DIALECTS
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm ci --only=production
def test_impala_dialect_mapped() -> None:
"""Test that Impala is correctly mapped to Hive dialect."""
assert "impala" in SQLGLOT_DIALECTS
assert SQLGLOT_DIALECTS["impala"] == Dialects.HIVE
# Copy source code
COPY . .
# Build the application
RUN npm run build
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S sidecar -u 1001
# Change ownership of the app directory
RUN chown -R sidecar:nodejs /app
USER sidecar
# Expose port
EXPOSE 3001
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD node -e "require('http').get('http://localhost:3001/health', (res) => { process.exit(res.statusCode === 200 ? 0 : 1) }).on('error', () => process.exit(1))"
# Start the application
CMD ["npm", "start"]

143
sidecar-node/README.md Normal file
View File

@@ -0,0 +1,143 @@
# Superset Query Sidecar Service
A Node.js sidecar service that computes Superset QueryObjects from form_data, eliminating the need to store stale query objects in the database for Alerts & Reports.
## Overview
This service provides a REST API that transforms Superset's `form_data` into `QueryObject` format using the same logic as the frontend, ensuring consistency and eliminating staleness issues in Alerts & Reports.
## Features
- **Real-time QueryObject generation**: No stale data from database
- **Frontend-compatible logic**: Uses the same transformation logic as superset-ui-core
- **TypeScript support**: Full type safety
- **Docker support**: Easy deployment
- **Health checks**: Built-in monitoring endpoints
- **CORS support**: Configurable for Superset integration
## Quick Start
### Development
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Run tests
npm test
```
### Production
```bash
# Build the application
npm run build
# Start production server
npm start
```
### Docker
```bash
# Build Docker image
docker build -t superset-query-sidecar .
# Run container
docker run -p 3001:3001 superset-query-sidecar
```
## API Reference
### POST /api/v1/query-object
Transforms form_data into a QueryObject.
**Request:**
```json
{
"form_data": {
"datasource": "1__table",
"viz_type": "table",
"metrics": ["count"],
"columns": ["name"],
"time_range": "No filter"
},
"query_fields": {
"x": "columns",
"y": "metrics"
}
}
```
**Response:**
```json
{
"query_object": {
"datasource": "1__table",
"metrics": ["count"],
"columns": ["name"],
"time_range": "No filter",
"filters": [],
"extras": {}
}
}
```
### GET /health
Health check endpoint.
**Response:**
```json
{
"status": "healthy",
"timestamp": "2023-12-01T12:00:00.000Z",
"version": "1.0.0"
}
```
## Configuration
Environment variables:
- `PORT`: Server port (default: 3001)
- `HOST`: Server host (default: localhost)
- `NODE_ENV`: Environment mode (development/production)
- `SUPERSET_ORIGINS`: Allowed CORS origins (comma-separated)
## Integration with Superset
This service is designed to be called by Superset's Python backend to generate QueryObjects for Alerts & Reports, replacing the current approach of reading stale query objects from the database.
## Architecture
```
┌─────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐
│ Superset │ │ Query Sidecar │ │ Alerts & Reports │
│ Frontend │ │ Service │ │ │
│ │ │ │ │ │
│ form_data ────┼────┼──► buildQueryObject │◄───┼─── Python Client │
│ │ │ │ │ │
└─────────────────┘ └──────────────────────┘ └─────────────────────┘
```
## Testing
```bash
# Run all tests
npm test
# Run tests in watch mode
npm run test:watch
# Run tests with coverage
npm test -- --coverage
```
## License
Licensed under the Apache License, Version 2.0.

View File

@@ -0,0 +1,16 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/__tests__/**/*.test.ts', '**/?(*.)+(spec|test).ts'],
transform: {
'^.+\\.ts$': 'ts-jest',
},
collectCoverageFrom: [
'src/**/*.ts',
'!src/**/*.d.ts',
'!src/**/*.test.ts',
],
coverageDirectory: 'coverage',
coverageReporters: ['text', 'lcov', 'html'],
};

41
sidecar-node/package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "superset-query-sidecar",
"version": "1.0.0",
"description": "Node.js sidecar service for computing Superset QueryObjects from form_data",
"main": "dist/index.js",
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node src/index.ts",
"test": "jest",
"test:watch": "jest --watch"
},
"dependencies": {
"express": "^4.18.2",
"cors": "^2.8.5",
"helmet": "^7.1.0",
"compression": "^1.7.4"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^20.10.0",
"@types/cors": "^2.8.17",
"@types/compression": "^1.7.5",
"@types/jest": "^29.5.8",
"typescript": "^5.3.2",
"ts-node": "^10.9.1",
"jest": "^29.7.0",
"ts-jest": "^29.1.1"
},
"engines": {
"node": ">=18.0.0"
},
"keywords": [
"superset",
"query",
"sidecar",
"analytics"
],
"author": "Apache Superset",
"license": "Apache-2.0"
}

View File

@@ -0,0 +1,149 @@
// 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 buildQueryObject from '../query/buildQueryObject';
import { QueryFormData } from '../types';
describe('buildQueryObject', () => {
const baseFormData: QueryFormData = {
datasource: '1__table',
viz_type: 'table',
time_range: 'No filter',
metrics: ['count'],
columns: ['name', 'category'],
row_limit: 1000,
order_desc: true,
};
it('should build a basic query object', () => {
const queryObject = buildQueryObject(baseFormData);
expect(queryObject).toEqual({
time_range: 'No filter',
since: undefined,
until: undefined,
granularity: undefined,
columns: ['name', 'category'],
metrics: ['count'],
orderby: undefined,
annotation_layers: [],
row_limit: 1000,
row_offset: undefined,
series_columns: undefined,
series_limit: 0,
series_limit_metric: undefined,
group_others_when_limit_reached: false,
order_desc: true,
url_params: undefined,
custom_params: {},
extras: {
filters: [],
},
filters: [],
custom_form_data: {},
});
});
it('should handle adhoc filters', () => {
const formDataWithFilters: QueryFormData = {
...baseFormData,
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SIMPLE',
subject: 'category',
operator: '==',
comparator: 'Electronics',
},
],
};
const queryObject = buildQueryObject(formDataWithFilters);
expect(queryObject.filters).toContainEqual({
col: 'category',
op: '==',
val: 'Electronics',
});
});
it('should handle SQL adhoc filters in extras', () => {
const formDataWithSQLFilters: QueryFormData = {
...baseFormData,
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SQL',
sqlExpression: 'price > 100',
},
],
};
const queryObject = buildQueryObject(formDataWithSQLFilters);
expect(queryObject.extras?.where).toBe('(price > 100)');
});
it('should handle extra_form_data overrides', () => {
const formDataWithExtraFormData: QueryFormData = {
...baseFormData,
extra_form_data: {
time_range: 'Last week',
adhoc_filters: [
{
clause: 'WHERE',
expressionType: 'SIMPLE',
subject: 'status',
operator: '==',
comparator: 'active',
},
],
},
};
const queryObject = buildQueryObject(formDataWithExtraFormData);
expect(queryObject.time_range).toBe('Last week');
expect(queryObject.filters).toContainEqual({
col: 'status',
op: '==',
val: 'active',
});
});
it('should handle series_limit from limit field', () => {
const formDataWithLimit: QueryFormData = {
...baseFormData,
limit: 5,
};
const queryObject = buildQueryObject(formDataWithLimit);
expect(queryObject.series_limit).toBe(5);
});
it('should handle granularity', () => {
const formDataWithGranularity: QueryFormData = {
...baseFormData,
granularity: 'created_at',
};
const queryObject = buildQueryObject(formDataWithGranularity);
expect(queryObject.granularity).toBe('created_at');
});
});

93
sidecar-node/src/index.ts Normal file
View File

@@ -0,0 +1,93 @@
// 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 express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import compression from 'compression';
import queryObjectRouter from './routes/queryObject';
const app = express();
const PORT = process.env.PORT || 3001;
const HOST = process.env.HOST || 'localhost';
// Security middleware
app.use(helmet());
// Enable CORS for Superset backend
app.use(cors({
origin: process.env.SUPERSET_ORIGINS?.split(',') || ['http://localhost:8088'],
credentials: true,
}));
// Compression middleware
app.use(compression());
// Body parsing middleware
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
// Request logging middleware
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`${timestamp} ${req.method} ${req.path}`);
next();
});
// Routes
app.use(queryObjectRouter);
// Global error handler
app.use((err: Error, req: express.Request, res: express.Response, next: express.NextFunction) => {
console.error('Unhandled error:', err);
res.status(500).json({
error: 'Internal server error',
message: process.env.NODE_ENV === 'development' ? err.message : undefined,
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({
error: 'Not found',
path: req.path,
});
});
// Start server
const server = app.listen(PORT, HOST, () => {
console.log(`🚀 Superset Query Sidecar Service running on http://${HOST}:${PORT}`);
console.log(`📊 Health check available at http://${HOST}:${PORT}/health`);
console.log(`🔧 Query object API available at http://${HOST}:${PORT}/api/v1/query-object`);
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('SIGTERM received, shutting down gracefully');
server.close(() => {
console.log('Process terminated');
});
});
process.on('SIGINT', () => {
console.log('SIGINT received, shutting down gracefully');
server.close(() => {
console.log('Process terminated');
});
});
export default app;

View File

@@ -0,0 +1,134 @@
// 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.
/* eslint-disable camelcase */
import {
QueryObject,
QueryFormData,
QueryFieldAliases,
isQueryFormMetric,
isDefined,
} from '../types';
import processFilters from '../utils/processFilters';
import extractExtras from '../utils/extractExtras';
import extractQueryFields from '../utils/extractQueryFields';
import { overrideExtraFormData } from '../utils/processExtraFormData';
/**
* Build the common segments of all query objects (e.g. the granularity field derived from
* SQLAlchemy). The segments specific to each viz type is constructed in the
* buildQuery method for each viz type (see `wordcloud/buildQuery.ts` for an example).
* Note the type of the formData argument passed in here is the type of the formData for a
* specific viz, which is a subtype of the generic formData shared among all viz types.
*/
export default function buildQueryObject<T extends QueryFormData>(
formData: T,
queryFields?: QueryFieldAliases,
): QueryObject {
const {
annotation_layers = [],
extra_form_data,
time_range,
since,
until,
row_limit,
row_offset,
order_desc,
limit,
timeseries_limit_metric,
granularity,
url_params = {},
custom_params = {},
series_columns,
series_limit,
series_limit_metric,
group_others_when_limit_reached,
...residualFormData
} = formData;
const {
adhoc_filters: appendAdhocFilters = [],
filters: appendFilters = [],
custom_form_data = {},
...overrides
} = extra_form_data || {};
const numericRowLimit = Number(row_limit);
const numericRowOffset = Number(row_offset);
const { metrics, columns, orderby } = extractQueryFields(
residualFormData,
queryFields,
);
// collect all filters for conversion to simple filters/freeform clauses
const extras = extractExtras(formData);
const { filters: extraFilters } = extras;
const filterFormData = {
...formData,
...extras,
filters: [...extraFilters, ...appendFilters],
adhoc_filters: [...(formData.adhoc_filters || []), ...appendAdhocFilters],
};
const extrasAndfilters = processFilters(filterFormData);
const normalizeSeriesLimitMetric = (metric: any) => {
if (isQueryFormMetric(metric)) {
return metric;
}
return undefined;
};
let queryObject: QueryObject = {
// fallback `null` to `undefined` so they won't be sent to the backend
// (JSON.stringify will ignore `undefined`.)
time_range: time_range || undefined,
since: since || undefined,
until: until || undefined,
granularity: granularity || undefined,
...extras,
...extrasAndfilters,
columns,
metrics,
orderby,
annotation_layers,
row_limit:
row_limit == null || Number.isNaN(numericRowLimit)
? undefined
: numericRowLimit,
row_offset:
row_offset == null || Number.isNaN(numericRowOffset)
? undefined
: numericRowOffset,
series_columns,
series_limit: series_limit ?? (isDefined(limit) ? Number(limit) : 0),
series_limit_metric:
normalizeSeriesLimitMetric(series_limit_metric) ??
timeseries_limit_metric ??
undefined,
group_others_when_limit_reached: group_others_when_limit_reached ?? false,
order_desc: typeof order_desc === 'undefined' ? true : order_desc,
url_params: url_params || undefined,
custom_params,
};
// override extra form data used by native and cross filters
queryObject = overrideExtraFormData(queryObject, overrides);
return { ...queryObject, custom_form_data };
}

View File

@@ -0,0 +1,91 @@
// 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 { Request, Response, Router } from 'express';
import buildQueryObject from '../query/buildQueryObject';
import { QueryFormData, QueryFieldAliases } from '../types';
const router = Router();
interface BuildQueryObjectRequest {
form_data: QueryFormData;
query_fields?: QueryFieldAliases;
}
interface BuildQueryObjectResponse {
query_object: any;
error?: string;
}
/**
* POST /api/v1/query-object
*
* Build a QueryObject from form_data
*
* Request body:
* - form_data: The form data from Superset frontend
* - query_fields: Optional query field aliases for visualization-specific mappings
*
* Response:
* - query_object: The computed QueryObject
* - error: Error message if processing failed
*/
router.post('/api/v1/query-object', (req: Request, res: Response) => {
try {
const { form_data, query_fields }: BuildQueryObjectRequest = req.body;
if (!form_data) {
return res.status(400).json({
error: 'form_data is required',
} as BuildQueryObjectResponse);
}
// Validate required form_data fields
if (!form_data.datasource || !form_data.viz_type) {
return res.status(400).json({
error: 'form_data must include datasource and viz_type',
} as BuildQueryObjectResponse);
}
const queryObject = buildQueryObject(form_data, query_fields);
res.json({
query_object: queryObject,
} as BuildQueryObjectResponse);
} catch (error: any) {
console.error('Error building query object:', error);
res.status(500).json({
error: `Failed to build query object: ${error.message}`,
} as BuildQueryObjectResponse);
}
});
/**
* GET /health
*
* Health check endpoint
*/
router.get('/health', (req: Request, res: Response) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
version: process.env.npm_package_version || '1.0.0',
});
});
export default router;

View File

@@ -0,0 +1,213 @@
// 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.
/* eslint-disable camelcase */
// Basic types for form data and query objects
export interface JsonObject {
[key: string]: any;
}
// Metric types
export interface SavedMetric {
metric_name: string;
expression?: string;
label?: string;
}
export interface AdhocMetric {
aggregate: string;
column?: any;
expressionType: 'SIMPLE' | 'SQL';
hasCustomLabel?: boolean;
label: string;
sqlExpression?: string;
optionName?: string;
}
export type QueryFormMetric = SavedMetric | AdhocMetric | string;
// Column types
export interface PhysicalColumn {
column_name: string;
type?: string;
}
export interface AdhocColumn {
hasCustomLabel?: boolean;
label: string;
sqlExpression: string;
expressionType: 'SQL';
optionName?: string;
}
export type QueryFormColumn = PhysicalColumn | AdhocColumn | string;
// Filter types
export type BinaryOperator =
| '==' | '!=' | '>' | '<' | '>=' | '<='
| 'LIKE' | 'ILIKE' | 'REGEX' | 'NOT REGEX';
export type SetOperator = 'IN' | 'NOT IN';
export interface AdhocFilter {
clause: 'WHERE' | 'HAVING';
comparator?: any;
expressionType: 'SIMPLE' | 'SQL';
operator?: BinaryOperator | SetOperator;
subject?: string | AdhocColumn;
sqlExpression?: string;
filterOptionName?: string;
}
export interface QueryObjectFilterClause {
col: string;
op: BinaryOperator | SetOperator;
val: any;
}
// Order by types
export type QueryFormOrderBy = [QueryFormColumn | QueryFormMetric | {}, boolean] | [];
// Annotation types
export interface AnnotationLayer {
annotationType: string;
name: string;
show: boolean;
sourceType?: string;
value?: string;
[key: string]: any;
}
// Time range types
export interface TimeRange {
time_range?: string;
since?: string;
until?: string;
}
// Extra form data types
export interface ExtraFormDataAppend {
adhoc_filters?: AdhocFilter[];
filters?: QueryObjectFilterClause[];
interactive_drilldown?: string[];
interactive_groupby?: string[];
interactive_highlight?: string[];
custom_form_data?: JsonObject;
}
export interface ExtraFormDataOverride {
granularity_sqla?: string;
granularity?: string;
time_range?: string;
time_column?: string;
time_grain?: string;
time_compare?: string[];
relative_start?: string;
relative_end?: string;
time_grain_sqla?: string;
}
export type ExtraFormData = ExtraFormDataAppend & ExtraFormDataOverride;
// Query extras interface
export interface QueryObjectExtras {
having?: string;
where?: string;
time_grain_sqla?: string;
time_range_endpoints?: [string, string];
relative_start?: string;
relative_end?: string;
time_compare?: string[];
[key: string]: any;
}
// Main form data interface
export interface BaseFormData extends TimeRange {
datasource: string;
viz_type: string;
metrics?: QueryFormMetric[];
where?: string;
columns?: QueryFormColumn[];
groupby?: QueryFormColumn[];
all_columns?: QueryFormColumn[];
adhoc_filters?: AdhocFilter[] | null;
extra_form_data?: ExtraFormData;
order_desc?: boolean;
limit?: number;
row_limit?: string | number | null;
row_offset?: string | number | null;
series_columns?: QueryFormColumn[];
series_limit?: number;
series_limit_metric?: QueryFormMetric;
annotation_layers?: AnnotationLayer[];
url_params?: Record<string, string>;
custom_params?: Record<string, string>;
[key: string]: any;
}
export interface SqlaFormData extends BaseFormData {
granularity?: string;
granularity_sqla?: string;
time_grain_sqla?: string;
having?: string;
}
export type QueryFormData = SqlaFormData;
// Query object interface
export interface QueryObject {
time_range?: string;
since?: string;
until?: string;
granularity?: string;
columns?: QueryFormColumn[];
metrics?: QueryFormMetric[];
orderby?: QueryFormOrderBy[];
annotation_layers?: AnnotationLayer[];
row_limit?: number;
row_offset?: number;
series_columns?: QueryFormColumn[];
series_limit?: number;
series_limit_metric?: QueryFormMetric;
group_others_when_limit_reached?: boolean;
order_desc?: boolean;
url_params?: Record<string, string>;
custom_params?: Record<string, string>;
extras?: QueryObjectExtras;
filters?: QueryObjectFilterClause[];
custom_form_data?: JsonObject;
}
// Query field aliases
export type QueryFieldAliases = {
[key: string]: 'metrics' | 'columns' | 'groupby';
};
// Utility functions
export function isAdhocMetric(metric: any): metric is AdhocMetric {
return metric && typeof metric === 'object' && 'expressionType' in metric;
}
export function isQueryFormMetric(metric: any): metric is QueryFormMetric {
return typeof metric === 'string' || isAdhocMetric(metric) ||
(metric && typeof metric === 'object' && 'metric_name' in metric);
}
export function isDefined<T>(value: T | undefined | null): value is T {
return value !== undefined && value !== null;
}

View File

@@ -0,0 +1,78 @@
// 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 {
QueryFormData,
QueryObjectExtras,
QueryObjectFilterClause,
} from '../types';
interface ExtrasResult extends QueryObjectExtras {
filters: QueryObjectFilterClause[];
}
/**
* Extract extras and filters from form data
*/
export default function extractExtras(formData: QueryFormData): ExtrasResult {
const {
where,
having,
time_grain_sqla,
granularity_sqla,
granularity,
extra_filters = [],
} = formData;
const extras: QueryObjectExtras = {};
const filters: QueryObjectFilterClause[] = [];
// Add SQL clauses to extras
if (where) {
extras.where = where;
}
if (having) {
extras.having = having;
}
if (time_grain_sqla) {
extras.time_grain_sqla = time_grain_sqla;
}
// Handle granularity - prefer granularity_sqla over granularity
const timeColumn = granularity_sqla || granularity;
if (timeColumn) {
// Time column handling would go here if needed
}
// Convert extra_filters to QueryObjectFilterClause format
if (extra_filters && Array.isArray(extra_filters)) {
for (const filter of extra_filters) {
if (filter.col && filter.op && filter.val !== undefined) {
filters.push({
col: filter.col,
op: filter.op,
val: filter.val,
});
}
}
}
return {
...extras,
filters,
};
}

View File

@@ -0,0 +1,73 @@
// 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 {
QueryFormData,
QueryFormMetric,
QueryFormColumn,
QueryFormOrderBy,
QueryFieldAliases,
} from '../types';
interface QueryFieldsResult {
metrics?: QueryFormMetric[];
columns?: QueryFormColumn[];
orderby?: QueryFormOrderBy[];
}
/**
* Extract query fields (metrics, columns, orderby) from form data
*/
export default function extractQueryFields(
formData: QueryFormData,
queryFieldAliases?: QueryFieldAliases,
): QueryFieldsResult {
const result: QueryFieldsResult = {};
// Extract metrics
if (formData.metrics && formData.metrics.length > 0) {
result.metrics = formData.metrics;
}
// Extract columns - prefer 'columns' over 'groupby'
const columns = formData.columns || formData.groupby;
if (columns && columns.length > 0) {
result.columns = columns;
}
// Handle query field aliases if provided
if (queryFieldAliases) {
for (const [formFieldName, queryField] of Object.entries(queryFieldAliases)) {
const formValue = (formData as any)[formFieldName];
if (formValue && Array.isArray(formValue) && formValue.length > 0) {
if (queryField === 'metrics') {
result.metrics = formValue;
} else if (queryField === 'columns' || queryField === 'groupby') {
result.columns = formValue;
}
}
}
}
// Extract orderby - this can be complex as it depends on the form structure
// For now, we'll handle basic cases
if (formData.orderby && Array.isArray(formData.orderby)) {
result.orderby = formData.orderby as QueryFormOrderBy[];
}
return result;
}

View File

@@ -0,0 +1,57 @@
// 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 { QueryObject, ExtraFormDataOverride } from '../types';
/**
* Override extra form data used by native and cross filters
*/
export function overrideExtraFormData(
queryObject: QueryObject,
overrides: ExtraFormDataOverride,
): QueryObject {
const result = { ...queryObject };
// Override top-level properties
if (overrides.time_range !== undefined) {
result.time_range = overrides.time_range;
}
if (overrides.granularity !== undefined) {
result.granularity = overrides.granularity;
}
if (overrides.granularity_sqla !== undefined) {
result.granularity = overrides.granularity_sqla;
}
// Override extras properties
if (result.extras) {
if (overrides.relative_start !== undefined) {
result.extras.relative_start = overrides.relative_start;
}
if (overrides.relative_end !== undefined) {
result.extras.relative_end = overrides.relative_end;
}
if (overrides.time_grain_sqla !== undefined) {
result.extras.time_grain_sqla = overrides.time_grain_sqla;
}
if (overrides.time_compare !== undefined) {
result.extras.time_compare = overrides.time_compare;
}
}
return result;
}

View File

@@ -0,0 +1,72 @@
// 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 {
AdhocFilter,
QueryObjectFilterClause,
QueryFormData,
QueryObjectExtras,
} from '../types';
interface FilterProcessorInput extends QueryFormData {
extras: QueryObjectExtras;
filters: QueryObjectFilterClause[];
adhoc_filters: AdhocFilter[];
}
interface FilterProcessorResult {
extras: QueryObjectExtras;
filters: QueryObjectFilterClause[];
}
/**
* Process filters from form data into QueryObject format
*/
export default function processFilters(
formData: FilterProcessorInput,
): FilterProcessorResult {
const { filters = [], adhoc_filters = [], extras = {} } = formData;
// Convert adhoc filters to simple filters where possible
const processedFilters: QueryObjectFilterClause[] = [...filters];
const processedExtras: QueryObjectExtras = { ...extras };
// Process adhoc filters
for (const adhocFilter of adhoc_filters) {
if (adhocFilter.expressionType === 'SIMPLE' && adhocFilter.subject && adhocFilter.operator) {
// Convert simple adhoc filters to QueryObjectFilterClause
if (typeof adhocFilter.subject === 'string') {
processedFilters.push({
col: adhocFilter.subject,
op: adhocFilter.operator as any,
val: adhocFilter.comparator,
});
}
} else if (adhocFilter.expressionType === 'SQL' && adhocFilter.sqlExpression) {
// Add SQL filters to WHERE clause in extras
const clause = adhocFilter.clause === 'HAVING' ? 'having' : 'where';
const existingClause = processedExtras[clause] || '';
const separator = existingClause ? ' AND ' : '';
processedExtras[clause] = existingClause + separator + `(${adhocFilter.sqlExpression})`;
}
}
return {
extras: processedExtras,
filters: processedFilters,
};
}

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2020",
"lib": ["ES2020"],
"module": "commonjs",
"declaration": true,
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"moduleResolution": "node",
"baseUrl": "./",
"paths": {
"@/*": ["src/*"]
},
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true
},
"include": [
"src/**/*"
],
"exclude": [
"node_modules",
"dist",
"**/*.test.ts"
]
}

View File

@@ -42,7 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=5.0.0,<6",
"flask-appbuilder>=4.5.3, <5.0.0",
]
[project.urls]

View File

@@ -19,6 +19,7 @@
"@babel/preset-typescript": "^7.24.7",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.4",
"axios": "^1.7.7",
"babel-loader": "^9.1.3",
"jest": "^29.7.0",
"tscw-config": "^1.1.2",
@@ -3232,6 +3233,24 @@
"sprintf-js": "~1.0.2"
}
},
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
},
"node_modules/axios": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -3625,6 +3644,19 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -3811,6 +3843,18 @@
"integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==",
"dev": true
},
"node_modules/combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/commander": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
@@ -4001,6 +4045,15 @@
"node": ">=0.10.0"
}
},
"node_modules/delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
@@ -4010,6 +4063,20 @@
"node": ">=8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/electron-to-chromium": {
"version": "1.5.19",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.19.tgz",
@@ -4070,12 +4137,57 @@
"is-arrayish": "^0.2.1"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
"dev": true
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"dependencies": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escalade": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
@@ -4516,6 +4628,42 @@
"node": ">=8"
}
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"dev": true,
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/fs-readdir-recursive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz",
@@ -4569,6 +4717,30 @@
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
@@ -4578,6 +4750,19 @@
"node": ">=8.0.0"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -4640,6 +4825,18 @@
"node": ">=4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -4668,6 +4865,45 @@
"node": ">=4"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true,
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"dependencies": {
"has-symbols": "^1.0.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -6856,6 +7092,15 @@
"tmpl": "1.0.5"
}
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true,
"engines": {
"node": ">= 0.4"
}
},
"node_modules/merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -7169,6 +7414,12 @@
"node": ">= 6"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"node_modules/punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
@@ -10473,6 +10724,23 @@
"sprintf-js": "~1.0.2"
}
},
"asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=",
"dev": true
},
"axios": {
"version": "1.8.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.8.2.tgz",
"integrity": "sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==",
"dev": true,
"requires": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -10755,6 +11023,16 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true
},
"call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dev": true,
"requires": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
}
},
"callsites": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz",
@@ -10880,6 +11158,15 @@
"integrity": "sha512-hUewv7oMjCp+wkBv5Rm0v87eJhq4woh5rSR+42YSQJKecCqgIqNkZ6lAlQms/BwHPJA5NKMRlpxPRv0n8HQW6g==",
"dev": true
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"requires": {
"delayed-stream": "~1.0.0"
}
},
"commander": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz",
@@ -11012,12 +11299,29 @@
"integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==",
"dev": true
},
"delayed-stream": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=",
"dev": true
},
"detect-newline": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz",
"integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==",
"dev": true
},
"dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dev": true,
"requires": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
}
},
"electron-to-chromium": {
"version": "1.5.19",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.19.tgz",
@@ -11061,12 +11365,45 @@
"is-arrayish": "^0.2.1"
}
},
"es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"dev": true
},
"es-errors": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"dev": true
},
"es-module-lexer": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz",
"integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==",
"dev": true
},
"es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dev": true,
"requires": {
"es-errors": "^1.3.0"
}
},
"es-set-tostringtag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
"integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
"dev": true,
"requires": {
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.6",
"has-tostringtag": "^1.0.2",
"hasown": "^2.0.2"
}
},
"escalade": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz",
@@ -11375,6 +11712,25 @@
"path-exists": "^4.0.0"
}
},
"follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"dev": true
},
"form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
"integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==",
"dev": true,
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"es-set-tostringtag": "^2.1.0",
"hasown": "^2.0.2",
"mime-types": "^2.1.12"
}
},
"fs-readdir-recursive": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/fs-readdir-recursive/-/fs-readdir-recursive-1.1.0.tgz",
@@ -11412,12 +11768,40 @@
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"dev": true
},
"get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dev": true,
"requires": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
}
},
"get-package-type": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/get-package-type/-/get-package-type-0.1.0.tgz",
"integrity": "sha512-pjzuKtY64GYfWizNAJ0fr9VqttZkNiK2iS430LtIHzjBEr6bX8Am2zm4sW4Ro5wjWW5cAlRL1qAMTcXbjNAO2Q==",
"dev": true
},
"get-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dev": true,
"requires": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
}
},
"get-stream": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz",
@@ -11460,6 +11844,12 @@
"integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==",
"dev": true
},
"gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"dev": true
},
"graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@@ -11481,6 +11871,30 @@
"integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==",
"dev": true
},
"has-symbols": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"dev": true
},
"has-tostringtag": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
"dev": true,
"requires": {
"has-symbols": "^1.0.3"
}
},
"hasown": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
"dev": true,
"requires": {
"function-bind": "^1.1.2"
}
},
"html-escaper": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz",
@@ -13091,6 +13505,12 @@
"tmpl": "1.0.5"
}
},
"math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"dev": true
},
"merge-stream": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz",
@@ -13322,6 +13742,12 @@
"sisteransi": "^1.0.5"
}
},
"proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true
},
"punycode": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",

View File

@@ -43,6 +43,7 @@
"@babel/preset-typescript": "^7.24.7",
"@types/jest": "^29.5.12",
"@types/node": "^22.5.4",
"axios": "^1.7.7",
"babel-loader": "^9.1.3",
"jest": "^29.7.0",
"tscw-config": "^1.1.2",

View File

@@ -18,6 +18,7 @@
*/
const { execSync } = require('child_process');
const axios = require('axios');
const { name, version } = require('./package.json');
function log(...args) {
@@ -35,7 +36,9 @@ function logError(...args) {
// npm commands output a bunch of garbage in the edge cases,
// and require sending semi-validated strings to the command line,
// so let's just use good old http.
const { status } = await fetch(packageUrl);
const { status } = await axios.get(packageUrl, {
validateStatus: (status) => true // we literally just want the status so any status is valid
});
if (status === 200) {
log('version already exists on npm, exiting');

View File

@@ -160,9 +160,7 @@ def test_validate_npm_handles_file_not_found_exception(mock_run, mock_which):
def test_validate_npm_does_not_catch_other_subprocess_exceptions(
mock_run, mock_which, exception_type
):
"""
Test validate_npm does not catch OSError and PermissionError (they propagate up).
"""
"""Test validate_npm does not catch OSError and PermissionError (they propagate up)."""
mock_which.return_value = "/usr/bin/npm"
mock_run.side_effect = exception_type("Test error")

View File

@@ -323,7 +323,6 @@ module.exports = {
'*.stories.tsx',
'*.stories.jsx',
'fixtures.*',
'playwright/**/*',
],
excludedFiles: 'cypress-base/cypress/**/*',
plugins: ['jest', 'jest-dom', 'no-only-tests', 'testing-library'],
@@ -398,13 +397,6 @@ module.exports = {
'react/no-void-elements': 0,
},
},
{
files: ['playwright/**/*'],
rules: {
'import/no-unresolved': 0, // Playwright is not installed in main build
'import/no-extraneous-dependencies': 0, // Playwright is not installed in main build
},
},
],
// eslint-disable-next-line no-dupe-keys
rules: {

View File

@@ -175,7 +175,7 @@ describe('Charts list', () => {
interceptDelete();
cy.getBySel('sort-header').contains('Name').click();
// Modal closes immediately without this
// Modal closes immediatly without this
cy.wait(2000);
cy.getBySel('table-row').eq(0).contains('3 - Sample chart');

View File

@@ -0,0 +1,766 @@
/**
* 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.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { Interception } from 'cypress/types/net-stubbing';
import { waitForChartLoad } from 'cypress/utils';
import { SUPPORTED_CHARTS_DASHBOARD } from 'cypress/utils/urls';
import {
openTopLevelTab,
SUPPORTED_TIER1_CHARTS,
SUPPORTED_TIER2_CHARTS,
} from './utils';
import {
interceptExploreJson,
interceptV1ChartData,
interceptFormDataKey,
} from '../explore/utils';
const interceptDrillInfo = () => {
cy.intercept('GET', '**/api/v1/dataset/*/drill_info/*', {
statusCode: 200,
body: {
result: {
id: 1,
changed_on_humanized: '2 days ago',
created_on_humanized: 'a week ago',
table_name: 'birth_names',
changed_by: {
first_name: 'Admin',
last_name: 'User',
},
created_by: {
first_name: 'Admin',
last_name: 'User',
},
owners: [
{
first_name: 'Admin',
last_name: 'User',
},
],
columns: [
{
column_name: 'gender',
verbose_name: null,
},
{
column_name: 'state',
verbose_name: null,
},
{
column_name: 'name',
verbose_name: null,
},
{
column_name: 'ds',
verbose_name: null,
},
],
},
},
}).as('drillInfo');
};
const closeModal = () => {
cy.get('body').then($body => {
if ($body.find('[data-test="close-drill-by-modal"]').length) {
cy.getBySel('close-drill-by-modal').click({ force: true });
}
});
};
const openTableContextMenu = (
cellContent: string,
tableSelector = "[data-test-viz-type='table']",
) => {
cy.get(tableSelector).scrollIntoView();
cy.get(tableSelector).contains(cellContent).first().rightclick();
};
const drillBy = (targetDrillByColumn: string, isLegacy = false) => {
if (isLegacy) {
interceptExploreJson('legacyData');
} else {
interceptV1ChartData();
}
cy.get('.ant-dropdown:not(.ant-dropdown-hidden)', { timeout: 15000 })
.should('be.visible')
.find("[role='menu'] [role='menuitem']")
.contains(/^Drill by$/)
.trigger('mouseover', { force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
{ timeout: 15000 },
)
.should('be.visible')
.find('[role="menuitem"]')
.contains(new RegExp(`^${targetDrillByColumn}$`))
.click();
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).trigger('mouseout', { clientX: 0, clientY: 0, force: true });
cy.get(
'.ant-dropdown-menu-submenu:not(.ant-dropdown-menu-submenu-hidden) [data-test="drill-by-submenu"]',
).should('not.exist');
if (isLegacy) {
return cy.wait('@legacyData');
}
return cy.wait('@v1Data');
};
const verifyExpectedFormData = (
interceptedRequest: Interception,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
expectedFormData: Record<string, any>,
) => {
const actualFormData = interceptedRequest.request.body?.form_data;
Object.entries(expectedFormData).forEach(([key, val]) => {
expect(actualFormData?.[key]).to.eql(val);
});
};
const testEchart = (
vizType: string,
chartName: string,
drillClickCoordinates: [[number, number], [number, number]],
furtherDrillDimension = 'name',
) => {
cy.get(`[data-test-viz-type='${vizType}'] canvas`).then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[0][0],
drillClickCoordinates[0][1],
);
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel(`"Drill by: ${chartName}-modal"`).as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', chartName);
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click 'other'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger(
'mouseover',
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
cy.wrap($canvas).rightclick(
drillClickCoordinates[1][0],
drillClickCoordinates[1][1],
);
drillBy(furtherDrillDimension).then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: [furtherDrillDimension],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'other',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (other)')
.and('contain', furtherDrillDimension)
.contains('state (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (other)')
.and('not.contain', furtherDrillDimension)
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
};
describe('Drill by modal', () => {
beforeEach(() => {
closeModal();
});
before(() => {
interceptDrillInfo();
cy.visit(SUPPORTED_CHARTS_DASHBOARD);
});
describe('Modal actions + Table', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it.only('opens the modal from the context menu', () => {
openTableContextMenu('boy');
drillBy('state').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Table');
cy.get('@drillByModal')
.find('[data-test="metadata-bar"]')
.should('be.visible');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'sum__num');
// further drilling
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
interceptFormDataKey();
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'state (CA)')
.and('contain', 'name')
.contains('state (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupby: ['state'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'name')
.and('contain', 'state')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'state (CA)')
.and('not.contain', 'name')
.and('contain', 'state');
cy.get('@drillByModal')
.find('[data-test="drill-by-display-toggle"]')
.contains('Table')
.click();
cy.getBySel('drill-by-chart').should('not.exist');
cy.get('@drillByModal')
.find('[data-test="drill-by-results-table"]')
.should('be.visible');
cy.wait('@formDataKey').then(intercept => {
cy.get('@drillByModal')
.contains('Edit chart')
.should('have.attr', 'href')
.and(
'contain',
`/explore/?form_data_key=${intercept.response?.body?.key}`,
);
});
});
});
describe('Tier 1 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 1');
SUPPORTED_TIER1_CHARTS.forEach(waitForChartLoad);
});
it('Pivot Table', () => {
openTableContextMenu('boy', "[data-test-viz-type='pivot_table_v2']");
drillBy('name').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.getBySel('"Drill by: Pivot Table-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Drill by: Pivot Table');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num')
.and('not.contain', 'Gender');
openTableContextMenu('CA', '[data-test="drill-by-chart"]');
drillBy('ds').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyColumns: ['name'],
groupbyRows: ['ds'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
{
clause: 'WHERE',
comparator: 'CA',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'state',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('contain', 'name')
.and('contain', 'ds')
.and('contain', 'sum__num')
.and('not.contain', 'state');
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (CA)')
.and('contain', 'ds')
.contains('name (CA)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
verifyExpectedFormData(intercepted, {
groupbyRows: ['state'],
groupbyColumns: ['name'],
adhoc_filters: [
{
clause: 'WHERE',
comparator: 'boy',
expressionType: 'SIMPLE',
operator: '==',
operatorId: 'EQUALS',
subject: 'gender',
},
],
});
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible')
.and('not.contain', 'ds')
.and('contain', 'state')
.and('contain', 'name')
.and('contain', 'sum__num');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (CA)')
.and('not.contain', 'ds')
.and('contain', 'name');
});
it('Line chart', () => {
testEchart('echarts_timeseries_line', 'Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Area Chart', () => {
testEchart('echarts_area', 'Area Chart', [
[85, 93],
[85, 93],
]);
});
it('Scatter Chart', () => {
testEchart('echarts_timeseries_scatter', 'Scatter Chart', [
[85, 93],
[85, 93],
]);
});
it.skip('Bar Chart', () => {
testEchart('echarts_timeseries_bar', 'Bar Chart', [
[85, 94],
[490, 68],
]);
});
it('Pie Chart', () => {
testEchart('pie', 'Pie Chart', [
[243, 167],
[534, 248],
]);
});
});
describe('Tier 2 charts', () => {
before(() => {
closeModal();
interceptDrillInfo();
openTopLevelTab('Tier 2');
SUPPORTED_TIER2_CHARTS.forEach(waitForChartLoad);
});
it('Box Plot Chart', () => {
testEchart(
'box_plot',
'Box Plot Chart',
[
[139, 277],
[787, 441],
],
'ds',
);
});
it('Generic Chart', () => {
testEchart('echarts_timeseries', 'Generic Chart', [
[85, 93],
[85, 93],
]);
});
it('Smooth Line Chart', () => {
testEchart('echarts_timeseries_smooth', 'Smooth Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Step Line Chart', () => {
testEchart('echarts_timeseries_step', 'Step Line Chart', [
[85, 93],
[85, 93],
]);
});
it('Funnel Chart', () => {
testEchart('funnel', 'Funnel Chart', [
[154, 80],
[421, 39],
]);
});
it('Gauge Chart', () => {
testEchart('gauge_chart', 'Gauge Chart', [
[151, 95],
[300, 143],
]);
});
it.skip('Radar Chart', () => {
testEchart('radar', 'Radar Chart', [
[182, 49],
[423, 91],
]);
});
it('Treemap V2 Chart', () => {
testEchart('treemap_v2', 'Treemap V2 Chart', [
[145, 84],
[220, 105],
]);
});
it.skip('Mixed Chart', () => {
cy.get('[data-test-viz-type="mixed_timeseries"] canvas').then($canvas => {
// click 'boy'
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 85, 93);
cy.wrap($canvas).rightclick(85, 93);
drillBy('name').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.getBySel('"Drill by: Mixed Chart-modal"').as('drillByModal');
cy.get('@drillByModal')
.find('.draggable-trigger')
.should('contain', 'Mixed Chart');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// further drill
cy.get(`[data-test="drill-by-chart"] canvas`).then($canvas => {
// click second query
cy.wrap($canvas).scrollIntoView();
cy.wrap($canvas).trigger('mouseover', 261, 114);
cy.wrap($canvas).rightclick(261, 114);
drillBy('ds').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['ds']);
expect(queries[1].filters).to.eql([
{ col: 'state', op: '==', val: 'other' },
]);
});
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
// undo - back to drill by state
interceptV1ChartData('drillByUndo');
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('contain', 'name (other)')
.and('contain', 'ds')
.contains('name (other)')
.click();
cy.wait('@drillByUndo').then(intercepted => {
const { queries } = intercepted.request.body;
expect(queries[0].columns).to.eql(['name']);
expect(queries[0].filters).to.eql([
{ col: 'gender', op: '==', val: 'boy' },
]);
expect(queries[1].columns).to.eql(['state']);
expect(queries[1].filters).to.eql([]);
});
cy.get('@drillByModal')
.find('.ant-breadcrumb')
.should('be.visible')
.and('contain', 'gender (boy)')
.and('contain', '/')
.and('not.contain', 'name (other)')
.and('not.contain', 'ds')
.and('contain', 'name');
cy.get('@drillByModal')
.find('[data-test="drill-by-chart"]')
.should('be.visible');
});
});
});
});
});

View File

@@ -0,0 +1,186 @@
/**
* 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.
*/
// ***********************************************
// Tests for setting controls in the UI
// ***********************************************
import { interceptChart, setSelectSearchInput } from 'cypress/utils';
describe('Datasource control', () => {
const newMetricName = `abc${Date.now()}`;
it('should allow edit dataset', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('[data-test="edit-dataset-tabs"]').within(() => {
cy.contains('Metrics').click();
});
// create new metric
cy.get('[data-test="crud-add-table-item"]', { timeout: 10000 }).click();
cy.wait(1000);
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.click();
cy.get('.ant-table-body [data-test="textarea-editable-title-input"]')
.first()
.focus();
cy.focused().clear({ force: true });
cy.focused().type(`${newMetricName}{enter}`, { force: true });
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
// select new metric
cy.get('[data-test=metrics]')
.contains('Drop columns/metrics here or click')
.click();
cy.get('input[aria-label="Select saved metrics"]')
.should('exist')
.then($input => {
setSelectSearchInput($input, newMetricName);
});
// delete metric
cy.get('[data-test="datasource-menu-trigger"]').click();
cy.get('[data-test="edit-dataset"]').click();
cy.get('.ant-modal-content').within(() => {
cy.get('[data-test="collection-tab-Metrics"]')
.contains('Metrics')
.click();
});
cy.get(`[data-test="textarea-editable-title-input"]`)
.contains(newMetricName)
.closest('tr')
.find('[data-test="crud-delete-icon"]')
.click();
cy.get('[data-test="datasource-modal-save"]').click();
cy.get('.ant-modal-confirm-btns button').contains('OK').click();
cy.get('[data-test="metrics"]').contains(newMetricName).should('not.exist');
});
});
describe('Color scheme control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
it('should show color options with and without tooltips', () => {
cy.get('#controlSections-tab-CUSTOMIZE').click();
cy.get('.ant-select-selection-item .color-scheme-label').contains(
'Superset Colors',
);
cy.get('.ant-select-selection-item .color-scheme-label').trigger(
'mouseover',
);
cy.get('.color-scheme-tooltip').should('be.visible');
cy.get('.color-scheme-tooltip').contains('Superset Colors');
cy.get('.Control[data-test="color_scheme"]').scrollIntoView();
cy.get('.Control[data-test="color_scheme"] input[type="search"]').focus();
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseover');
cy.get('.color-scheme-label')
.contains('Superset Colors')
.trigger('mouseout');
cy.focused().type('lyftColors');
cy.getBySel('lyftColors').should('exist');
cy.getBySel('lyftColors').trigger('mouseover', { force: true });
cy.get('.color-scheme-tooltip').should('not.be.visible');
});
});
describe('VizType control', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('bigNumberChartData');
});
it('Can change vizType', () => {
cy.visitChartByName('Daily Totals');
cy.verifySliceSuccess({ waitAlias: '@tableChartData' });
cy.contains('View all charts').click();
cy.get('.ant-modal-content').within(() => {
cy.get('button').contains('KPI').click(); // change categories
cy.get('[role="button"]').contains('Big Number').click();
cy.get('button').contains('Select').click();
});
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({
waitAlias: '@bigNumberChartData',
});
});
});
describe('Test datatable', () => {
beforeEach(() => {
interceptChart({ legacy: false }).as('tableChartData');
interceptChart({ legacy: false }).as('lineChartData');
cy.visitChartByName('Daily Totals');
});
it('Data Pane opens and loads results', () => {
cy.contains('Results').click();
cy.get('[data-test="row-count-label"]').contains('26 rows');
cy.get('.ant-empty-description').should('not.exist');
});
it('Datapane loads view samples', () => {
cy.intercept(
'**/datasource/samples?force=false&datasource_type=table&datasource_id=*',
).as('Samples');
cy.contains('Samples').click();
cy.wait('@Samples');
cy.get('.ant-tabs-tab-active').contains('Samples');
cy.get('[data-test="row-count-label"]').contains('1k rows');
cy.get('.ant-empty-description').should('not.exist');
});
});
describe('Groupby control', () => {
it('Set groupby', () => {
interceptChart({ legacy: false }).as('chartData');
cy.visitChartByName('Num Births Trend');
cy.verifySliceSuccess({ waitAlias: '@chartData' });
cy.get('[data-test=groupby]')
.contains('Drop columns here or click')
.click();
cy.get('[id="adhoc-metric-edit-tabs-tab-simple"]').click();
cy.get('input[aria-label="Column"]').click();
cy.get('input[aria-label="Column"]').type('state{enter}');
cy.get('[data-test="ColumnEdit#save"]').contains('Save').click();
cy.get('button[data-test="run-query-button"]').click();
cy.verifySliceSuccess({ waitAlias: '@chartData' });
});
});

View File

@@ -33,7 +33,6 @@ module.exports = {
'^@superset-ui/([^/]+)$': '<rootDir>/node_modules/@superset-ui/$1/src',
// mapping @apache-superset/core to local package
'^@apache-superset/core$': '<rootDir>/packages/superset-core/src',
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
},
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],

View File

@@ -21,7 +21,7 @@
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^5.24.13",
"@rjsf/utils": "^5.24.3",
"@rjsf/validator-ajv8": "^5.24.13",
"@rjsf/validator-ajv8": "^5.24.12",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -54,18 +54,16 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
"content-disposition": "^0.5.4",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -161,7 +159,6 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -177,7 +174,6 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
@@ -193,6 +189,7 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",
@@ -10112,22 +10109,6 @@
"url": "https://opencollective.com/unts"
}
},
"node_modules/@playwright/test": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.55.0.tgz",
"integrity": "sha512-04IXzPwHrW69XusN/SIdDdKZBzMfOT9UNT/YiJit/xpy2VuAoB8NHc8Aplb96zsWDddLnbkPL3TsmrS04ZU2xQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@pnpm/config.env-replace": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz",
@@ -10903,9 +10884,9 @@
}
},
"node_modules/@rjsf/validator-ajv8": {
"version": "5.24.13",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.13.tgz",
"integrity": "sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==",
"version": "5.24.12",
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.12.tgz",
"integrity": "sha512-IMXdCjvDNdvb+mDgZC3AlAtr0pjYKq5s0GcLECjG5PuiX7Ib4JaDQHZY5ZJdKblMfgzhsn8AAOi573jXAt7BHQ==",
"license": "Apache-2.0",
"dependencies": {
"ajv": "^8.12.0",
@@ -15269,13 +15250,6 @@
"@types/node": "*"
}
},
"node_modules/@types/content-disposition": {
"version": "0.5.9",
"resolved": "https://registry.npmjs.org/@types/content-disposition/-/content-disposition-0.5.9.tgz",
"integrity": "sha512-8uYXI3Gw35MhiVYhG3s295oihrxRyytcRHjSjqnqZVDDy/xcGBRny7+Xj1Wgfhv5QzRtN2hB2dVRBUX9XW3UcQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/conventional-commits-parser": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@types/conventional-commits-parser/-/conventional-commits-parser-5.0.1.tgz",
@@ -16095,6 +16069,16 @@
"@types/react": "*"
}
},
"node_modules/@types/react-ultimate-pagination": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@types/react-ultimate-pagination/-/react-ultimate-pagination-1.2.4.tgz",
"integrity": "sha512-1y9jLt3KEFGzFD+99qVpJUI/Eu4cEx48sClB957eGoepWRLVVi+r1UBj0157Mg7HYZcIF4I1/qGZYaBBQWhaqg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-virtualized-auto-sizer": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/react-virtualized-auto-sizer/-/react-virtualized-auto-sizer-1.0.8.tgz",
@@ -18715,27 +18699,27 @@
}
},
"node_modules/ag-charts-types": {
"version": "12.2.0",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.2.0.tgz",
"integrity": "sha512-d2qQrQirt9wP36YW5HPuOvXsiajyiFnr1CTsoCbs02bavPDz7Lk2jHp64+waM4YKgXb3GN7gafbBI9Qgk33BmQ==",
"version": "12.0.2",
"resolved": "https://registry.npmjs.org/ag-charts-types/-/ag-charts-types-12.0.2.tgz",
"integrity": "sha512-AWM1Y+XW+9VMmV3AbzdVEnreh/I2C9Pmqpc2iLmtId3Xbvmv7O56DqnuDb9EXjK5uPxmyUerTP+utL13UGcztw==",
"license": "MIT"
},
"node_modules/ag-grid-community": {
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.2.0.tgz",
"integrity": "sha512-peS7THEMYwpIrwLQHmkRxw/TlOnddD/F5A88RqlBxf8j+WqVYRWMOOhU5TqymGcha7z2oZ8IoL9ROl3gvtdEjg==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-community/-/ag-grid-community-34.0.2.tgz",
"integrity": "sha512-hVJp5vrmwHRB10YjfSOVni5YJkO/v+asLjT72S4YnIFSx8lAgyPmByNJgtojk1aJ5h6Up93jTEmGDJeuKiWWLA==",
"license": "MIT",
"dependencies": {
"ag-charts-types": "12.2.0"
"ag-charts-types": "12.0.2"
}
},
"node_modules/ag-grid-react": {
"version": "34.2.0",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.2.0.tgz",
"integrity": "sha512-dLKFw6hz75S0HLuZvtcwjm+gyiI4gXVzHEu7lWNafWAX0mb8DhogEOP5wbzAlsN6iCfi7bK/cgZImZFjenlqwg==",
"version": "34.0.2",
"resolved": "https://registry.npmjs.org/ag-grid-react/-/ag-grid-react-34.0.2.tgz",
"integrity": "sha512-1KBXkTvwtZiYVlSuDzBkiqfHjZgsATOmpLZdAtdmsCSOOOEWai0F9zHHgBuHfyciAE4nrbQWfojkx8IdnwsKFw==",
"license": "MIT",
"dependencies": {
"ag-grid-community": "34.2.0",
"ag-grid-community": "34.0.2",
"prop-types": "^15.8.1"
},
"peerDependencies": {
@@ -21944,6 +21928,7 @@
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"safe-buffer": "5.2.1"
@@ -24484,6 +24469,12 @@
"node": ">= 4"
}
},
"node_modules/emotion-rgba": {
"version": "0.0.12",
"resolved": "https://registry.npmjs.org/emotion-rgba/-/emotion-rgba-0.0.12.tgz",
"integrity": "sha512-lvtZ52BWisYDtis+HctQMkxcHwmFbzTiZhgMJGFfWXLsBYEzthfKE7nlysOiUwmmAdTM/8YBAPfwQ4MEDwiaWw==",
"license": "MIT"
},
"node_modules/encodable": {
"version": "0.7.8",
"resolved": "https://registry.npmjs.org/encodable/-/encodable-0.7.8.tgz",
@@ -45536,53 +45527,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/playwright": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.55.0.tgz",
"integrity": "sha512-sdCWStblvV1YU909Xqx0DhOjPZE4/5lJsIS84IfN9dAZfcl/CIZ5O8l3o0j7hPMjDvqoTF8ZUcc+i/GL5erstA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.55.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.55.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.55.0.tgz",
"integrity": "sha512-GvZs4vU3U5ro2nZpeiwyb0zuFaqb9sUiAJuyrWpcGouD8y9/HLgGbNRjIph7zU9D3hnPaisMl9zG9CgFi/biIg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/playwright/node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/png-async": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/png-async/-/png-async-0.9.4.tgz",
@@ -60690,7 +60634,7 @@
},
"packages/superset-core": {
"name": "@apache-superset/core",
"version": "0.0.1-rc4",
"version": "0.0.1-rc2",
"license": "ISC",
"devDependencies": {
"@babel/cli": "^7.26.4",
@@ -60700,8 +60644,7 @@
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^17.0.83",
"install": "^0.13.0",
"npm": "^11.1.0",
"typescript": "^5.0.0"
"npm": "^11.1.0"
},
"peerDependencies": {
"antd": "^5.24.6",
@@ -63387,10 +63330,10 @@
"version": "0.20.3",
"license": "Apache-2.0",
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
@@ -63415,15 +63358,14 @@
"license": "Apache-2.0",
"dependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"@visx/responsive": "^3.12.0",
"ace-builds": "^1.43.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"core-js": "^3.38.1",
@@ -65294,8 +65236,6 @@
"d3-array": "^1.2.4",
"d3-color": "^1.4.1",
"d3-scale": "^3.0.0",
"dayjs": "^1.11.13",
"handlebars": "^4.7.8",
"lodash": "^4.17.21",
"mousetrap": "^1.6.5",
"ngeohash": "^0.6.3",
@@ -65462,7 +65402,6 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",
@@ -65514,7 +65453,6 @@
"lodash": "^4.17.21"
},
"peerDependencies": {
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"echarts": "*",
@@ -66692,7 +66630,6 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"lodash": "^4.17.11",
@@ -67824,7 +67761,6 @@
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@testing-library/dom": "^8.20.1",

View File

@@ -63,11 +63,6 @@
"plugins:release-conventional": "npm run prune && npm run plugins:build && lerna publish --conventional-commits --create-release github --yes",
"plugins:release-from-tag": "npm run prune && npm run plugins:build && lerna publish from-package --yes",
"plugins:storybook": "cd packages/superset-ui-demo && npm run storybook",
"playwright:test": "playwright test",
"playwright:ui": "playwright test --ui",
"playwright:headed": "playwright test --headed",
"playwright:debug": "playwright test --debug",
"playwright:report": "playwright show-report",
"prettier": "npm run _prettier -- --write",
"prettier-check": "npm run _prettier -- --check",
"prod": "npm run build",
@@ -94,7 +89,7 @@
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^5.24.13",
"@rjsf/utils": "^5.24.3",
"@rjsf/validator-ajv8": "^5.24.13",
"@rjsf/validator-ajv8": "^5.24.12",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -127,18 +122,16 @@
"@visx/scale": "^3.5.0",
"@visx/tooltip": "^3.0.0",
"@visx/xychart": "^3.5.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"antd": "^5.24.6",
"chrono-node": "^2.7.8",
"classnames": "^2.2.5",
"content-disposition": "^0.5.4",
"d3-color": "^3.1.0",
"d3-scale": "^2.1.2",
"dayjs": "^1.11.13",
"dom-to-image-more": "^3.6.0",
"dom-to-pdf": "^0.3.2",
"echarts": "^5.6.0",
"emotion-rgba": "0.0.12",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"fast-glob": "^3.3.2",
"fs-extra": "^11.2.0",
@@ -234,7 +227,6 @@
"@hot-loader/react-dom": "^17.0.2",
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@mihkeleidast/storybook-addon-source": "^1.0.1",
"@playwright/test": "^1.49.1",
"@storybook/addon-actions": "8.1.11",
"@storybook/addon-controls": "8.1.11",
"@storybook/addon-essentials": "8.1.11",
@@ -250,7 +242,6 @@
"@testing-library/react": "^12.1.5",
"@testing-library/react-hooks": "^8.0.1",
"@testing-library/user-event": "^12.8.3",
"@types/content-disposition": "^0.5.9",
"@types/dom-to-image": "^2.6.7",
"@types/jest": "^29.5.14",
"@types/js-levenshtein": "^1.1.3",
@@ -266,6 +257,7 @@
"@types/react-resizable": "^3.0.8",
"@types/react-router-dom": "^5.3.3",
"@types/react-transition-group": "^4.4.12",
"@types/react-ultimate-pagination": "^1.2.4",
"@types/react-virtualized-auto-sizer": "^1.0.8",
"@types/react-window": "^1.8.8",
"@types/redux-localstorage": "^1.0.8",

View File

@@ -22,6 +22,19 @@ To add the package to Superset, go to the `superset-frontend` subdirectory in yo
npm i -S ../../<%= packageName %>
```
If your Superset plugin exists in the `superset-frontend` directory and you wish to resolve TypeScript errors about `@superset-ui/core` not being resolved correctly, add the following to your `tsconfig.json` file:
```
"references": [
{
"path": "../../packages/superset-ui-chart-controls"
},
{
"path": "../../packages/superset-ui-core"
}
]
```
You may also wish to add the following to the `include` array in `tsconfig.json` to make Superset types available to your plugin:
```

View File

@@ -1,19 +1,44 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": false,
"jsx": "react",
"lib": [
"dom",
"esnext"
],
"module": "esnext",
"moduleResolution": "node",
"noEmitOnError": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"outDir": "lib",
"pretty": true,
"removeComments": false,
"strict": true,
"target": "es2015",
"useDefineForClassFields": false,
"composite": true,
"declarationMap": true,
"rootDir": "src",
"skipLibCheck": true,
"emitDeclarationOnly": true,
"resolveJsonModule": true,
"types": ["jest"],
"typeRoots": [
"./node_modules/@types"
]
},
"include": ["src/**/*.ts", "src/**/*.tsx", "types/**/*"],
"exclude": [
"src/**/*.js",
"src/**/*.jsx",
"src/**/*.test.*",
"src/**/*.stories.*"
"lib",
"test"
],
"references": [
{ "path": "../../packages/superset-core" },
{ "path": "../../packages/superset-ui-core" },
{ "path": "../../packages/superset-ui-chart-controls" }
"include": [
"src/**/*",
"types/**/*"
]
}

View File

@@ -1,11 +1,12 @@
{
"name": "@apache-superset/core",
"version": "0.0.1-rc4",
"version": "0.0.1-rc2",
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
"sideEffects": false,
"main": "lib/index.js",
"types": "lib/index.d.ts",
"module": "esm/index.js",
"files": [
"esm",
"lib"
],
"author": "",
@@ -18,15 +19,14 @@
"@babel/preset-typescript": "^7.26.0",
"@types/react": "^17.0.83",
"install": "^0.13.0",
"npm": "^11.1.0",
"typescript": "^5.0.0"
"npm": "^11.1.0"
},
"peerDependencies": {
"antd": "^5.24.6",
"react": "^17.0.2"
},
"scripts": {
"build": "babel src --out-dir lib --extensions \".ts,.tsx\" && tsc --emitDeclarationOnly",
"build": "babel src --out-dir lib --extensions \".ts,.tsx\"",
"type": "tsc --noEmit"
},
"publishConfig": {

View File

@@ -31,32 +31,12 @@ import { Contributions } from './contributions';
/**
* Represents a database column with its name and data type.
*/
export type Column = {
/**
* Label of the column
*/
export declare interface Column {
/** The name of the column */
name: string;
/**
* Column name defined
*/
column_name: string;
/**
* The data type of the column (e.g., 'INTEGER', 'VARCHAR', 'TIMESTAMP')
*/
/** The data type of the column (e.g., 'INTEGER', 'VARCHAR', 'TIMESTAMP') */
type: string;
/**
* Generic data type format
*/
type_generic: GenericDataType;
/**
* True if the column is date format
*/
is_dttm: boolean;
};
}
/**
* Represents a database table with its name and column definitions.
@@ -96,45 +76,6 @@ export declare interface Database {
schemas: Schema[];
}
// Keep in sync with superset/errors.py
export type ErrorLevel = 'info' | 'warning' | 'error';
/**
* Superset error object structure.
* Contains details about an error that occurred within Superset.
*/
export type SupersetError = {
/**
* Error types, see enum of SupersetErrorType in superset/errors.py
*/
error_type: string;
/**
* Extra properties based on the error types
*/
extra: Record<string, any>;
/**
* Level of the error type
*/
level: ErrorLevel;
/**
* Detail description for the error
*/
message: string;
};
/**
* Generic data types, see enum of the same name in superset/utils/core.py.
*/
export enum GenericDataType {
Numeric = 0,
String = 1,
Temporal = 2,
Boolean = 3,
}
/**
* Represents a type which can release resources, such
* as event listening or a timer.

View File

@@ -29,7 +29,7 @@
* - Global APIs: Functions and events available across the entire SQL Lab interface
*/
import { Event, Database, SupersetError, Column } from './core';
import { Event, Database } from './core';
/**
* Represents an SQL editor instance within a SQL Lab tab.
@@ -111,126 +111,6 @@ export interface Tab {
panels: Panel[];
}
export enum CTASMethod {
Table = 'TABLE',
View = 'VIEW',
}
export interface CTAS {
/**
* Create method for CTAS creation request.
*/
method: CTASMethod;
/**
* Temporary table name for creation using a CTAS query.
*/
tempTable: string | null;
}
export interface QueryContext {
/**
* Unique query ID on client side.
*/
clientId: string;
/**
* Contains CTAS if the query requests table creation.
*/
ctas: CTAS | null;
/**
* Requested row limit for the query.
*/
requestedLimit: number | null;
/**
* True if the query execution result will be/was delivered asynchronously
*/
runAsync?: boolean;
/**
* Start datetime for the query in a numerical timestamp
*/
startDttm: number;
/**
* The tab instance associated with the request query
*/
tab: Tab;
/**
* A key-value JSON associated with Jinja template variables
*/
templateParameters: Record<string, any>;
}
export interface QueryErrorResultContext extends QueryContext {
/**
* Finished datetime for the query in a numerical timestamp
*/
endDttm: number;
/**
* Error message returned from DB engine
*/
errorMessage: string;
/**
* Error details in a SupersetError structure
*/
errors: SupersetError[] | null;
/**
* Executed SQL after parsing Jinja templates.
*/
executedSql: string | null;
}
export interface QueryResultContext extends QueryContext {
/**
* Actual number of rows returned by the query.
*/
appliedLimit: number;
/**
* Major factor that is determining the row limit of the query results.
*/
appliedLimitingFactor: string;
/**
* Finished datetime for the query in a numerical timestamp.
*/
endDttm: number;
/**
* Executed SQL after parsing Jinja templates.
*/
executedSql: string;
/**
* Remote query id stored in backend.
*/
remoteId: number;
/**
* Query result data and metadata.
*/
result: QueryResult;
}
export interface QueryResult {
/**
* Column metadata associated with the query result.
*/
columns: Column[];
/**
* Query result data.
*/
data: Record<string, any>[];
}
/**
* Tab-scoped Events and Functions
*
@@ -360,30 +240,30 @@ export declare const onDidChangeTabTitle: Event<string>;
/**
* Event fired when a query starts running in the current tab.
* Provides the query request state at the time of query execution.
* Provides the editor state at the time of query execution.
*
* @example
* ```typescript
* onDidQueryRun.event((query) => {
* console.log('Query started on database:', query.tab.editor.databaseId);
* console.log('Query content:', query.tab.editor.content);
* onDidQueryRun.event((editor) => {
* console.log('Query started on database:', editor.databaseId);
* console.log('Query content:', editor.content);
* });
* ```
*/
export declare const onDidQueryRun: Event<QueryContext>;
export declare const onDidQueryRun: Event<Editor>;
/**
* Event fired when a running query is stopped in the current tab.
* Provides the query request state when the query was stopped.
* Provides the editor state when the query was stopped.
*
* @example
* ```typescript
* onDidQueryStop.event((query) => {
* console.log('Query stopped for database:', query.tab.editor.databaseId);
* onDidQueryStop.event((editor) => {
* console.log('Query stopped for database:', editor.databaseId);
* });
* ```
*/
export declare const onDidQueryStop: Event<QueryContext>;
export declare const onDidQueryStop: Event<Editor>;
/**
* Event fired when a query fails in the current tab.
@@ -393,12 +273,12 @@ export declare const onDidQueryStop: Event<QueryContext>;
*
* @example
* ```typescript
* onDidQueryFail.event((result) => {
* console.error('Query failed:', result.errorMessage);
* onDidQueryFail.event((error) => {
* console.error('Query failed:', error);
* });
* ```
*/
export declare const onDidQueryFail: Event<QueryErrorResultContext>;
export declare const onDidQueryFail: Event<string>;
/**
* Event fired when a query succeeds in the current tab.
@@ -408,13 +288,12 @@ export declare const onDidQueryFail: Event<QueryErrorResultContext>;
*
* @example
* ```typescript
* onDidQuerySuccess.event((query) => {
* console.log('Query succeeded:', query.result.data);
* console.log('Query executed content:', query.executedSql);
* onDidQuerySuccess.event((result) => {
* console.log('Query succeeded:', result);
* });
* ```
*/
export declare const onDidQuerySuccess: Event<QueryResultContext>;
export declare const onDidQuerySuccess: Event<string>;
/**
* Global Events and Functions

View File

@@ -1,9 +1,17 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
"allowSyntheticDefaultImports": true,
"declaration": true,
"declarationDir": "lib",
"outDir": "lib",
"strict": true,
"rootDir": "src",
"jsx": "preserve",
"baseUrl": ".",
"module": "esnext",
"moduleResolution": "node",
"skipLibCheck": true
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"]
"include": ["src/**/*.ts*"],
"exclude": ["lib"]
}

View File

@@ -24,10 +24,10 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@react-icons/all-files": "^4.1.0",
"@types/react": "*",
"lodash": "^4.17.21"
"lodash": "^4.17.21",
"prop-types": "^15.8.1"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -43,17 +43,15 @@ export const renameOperator: PostProcessingFactory<PostProcessingRename> = (
// remove or rename top level of column name(metric name) in the MultiIndex when
// 1) at least 1 metric
// 2) xAxis exist
// 3a) isTimeComparisonValue
// 3b-1) dimension exist or multiple time shift metrics exist
// 3b-2) truncate_metric in form_data and truncate_metric is true
// 2) dimension exist or multiple time shift metrics exist
// 3) xAxis exist
// 4) truncate_metric in form_data and truncate_metric is true
if (
metrics.length > 0 &&
(columns.length > 0 || timeOffsets.length > 1) &&
xAxisLabel &&
(isTimeComparisonValue ||
((columns.length > 0 || timeOffsets.length > 1) &&
truncate_metric !== undefined &&
!!truncate_metric))
truncate_metric !== undefined &&
!!truncate_metric
) {
const renamePairs: [string, string | null][] = [];
if (

View File

@@ -20,32 +20,20 @@ import { t } from '@superset-ui/core';
import { ControlPanelSectionConfig } from '../types';
export const matrixifyEnableSection: ControlPanelSectionConfig = {
label: t('Matrixify'),
label: t('Enable matrixify'),
expanded: true,
controlSetRows: [
[
{
name: 'matrixify_enable_horizontal_layout',
name: 'matrixify_enabled',
config: {
type: 'CheckboxControl',
label: t('Enable horizontal layout (columns)'),
label: t('Enable matrixify'),
default: false,
renderTrigger: true,
description: t(
'Create matrix columns by placing charts side-by-side',
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
default: false,
renderTrigger: true,
},
},
],
[
{
name: 'matrixify_enable_vertical_layout',
config: {
type: 'CheckboxControl',
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
default: false,
renderTrigger: true,
},
},
],
@@ -54,11 +42,9 @@ export const matrixifyEnableSection: ControlPanelSectionConfig = {
};
export const matrixifySection: ControlPanelSectionConfig = {
label: t('Cell layout & styling'),
label: t('Matrixify'),
expanded: false,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
controlSetRows: [
[
{
@@ -119,10 +105,9 @@ export const matrixifySection: ControlPanelSectionConfig = {
};
export const matrixifyRowSection: ControlPanelSectionConfig = {
label: t('Vertical layout (rows)'),
label: t('Vertical layout'),
expanded: false,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
controlSetRows: [
['matrixify_show_row_labels'],
['matrixify_mode_rows'],
@@ -133,14 +118,13 @@ export const matrixifyRowSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_rows'],
['matrixify_topn_order_rows'],
],
tabOverride: 'matrixify',
tabOverride: 'data',
};
export const matrixifyColumnSection: ControlPanelSectionConfig = {
label: t('Horizontal layout (columns)'),
label: t('Horizontal layout'),
expanded: false,
visibility: ({ controls }) =>
controls?.matrixify_enable_horizontal_layout?.value === true,
visibility: ({ controls }) => controls?.matrixify_enabled?.value === true,
controlSetRows: [
['matrixify_show_column_headers'],
['matrixify_mode_columns'],
@@ -151,5 +135,5 @@ export const matrixifyColumnSection: ControlPanelSectionConfig = {
['matrixify_topn_metric_columns'],
['matrixify_topn_order_columns'],
],
tabOverride: 'matrixify',
tabOverride: 'data',
};

View File

@@ -18,49 +18,21 @@
* under the License.
*/
import { t, validateNonEmpty } from '@superset-ui/core';
import { t } from '@superset-ui/core';
import { SharedControlConfig } from '../types';
import { dndAdhocMetricControl } from './dndControls';
import { defineSavedMetrics } from '../utils';
/**
* Matrixify control definitions
* Controls for transforming charts into matrix/grid layouts
*/
// Utility function to check if matrixify controls should be visible
const isMatrixifyVisible = (
controls: any,
axis: 'rows' | 'columns',
mode?: 'metrics' | 'dimensions',
selectionMode?: 'members' | 'topn',
) => {
const layoutControl = `matrixify_enable_${axis === 'rows' ? 'vertical' : 'horizontal'}_layout`;
const modeControl = `matrixify_mode_${axis}`;
const selectionModeControl = `matrixify_dimension_selection_mode_${axis}`;
const isLayoutEnabled = controls?.[layoutControl]?.value === true;
if (!isLayoutEnabled) return false;
if (mode) {
const isModeMatch = controls?.[modeControl]?.value === mode;
if (!isModeMatch) return false;
if (selectionMode && mode === 'dimensions') {
return controls?.[selectionModeControl]?.value === selectionMode;
}
}
return true;
};
// Initialize the controls object that will be populated dynamically
const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
// Dynamically add axis-specific controls (rows and columns)
(['columns', 'rows'] as const).forEach(axisParam => {
const axis: 'rows' | 'columns' = axisParam;
['columns', 'rows'].forEach(axisParam => {
const axis = axisParam; // Capture the value in a local variable
matrixifyControls[`matrixify_mode_${axis}`] = {
type: 'RadioButtonControl',
@@ -71,18 +43,17 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['dimensions', t('Dimension members')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis),
};
matrixifyControls[`matrixify_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metrics`),
multi: true,
validators: [], // No validation - rely on visibility
validators: [], // Not required
// description: t(`Select metrics for ${axis}`),
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) => isMatrixifyVisible(controls, axis, 'metrics'),
visibility: ({ controls }) =>
controls?.[`matrixify_mode_${axis}`]?.value === 'metrics',
};
// Combined dimension and values control
@@ -91,9 +62,8 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
label: t(`Dimension selection`),
description: t(`Select dimension and values`),
default: { dimension: '', values: [] },
validators: [], // No validation - rely on visibility
validators: [], // Not required
renderTrigger: true,
tabOverride: 'matrixify',
shouldMapStateToProps: (prevState, state) => {
// Recalculate when any relevant form_data field changes
const fieldsToCheck = [
@@ -112,40 +82,24 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
const getValue = (key: string, defaultValue?: any) =>
form_data?.[key] ?? controls?.[key]?.value ?? defaultValue;
const selectionMode = getValue(
`matrixify_dimension_selection_mode_${axis}`,
'members',
);
const isVisible = isMatrixifyVisible(controls, axis, 'dimensions');
// Validate dimension is selected when visible
const dimensionValidator = (value: any) => {
if (!value?.dimension) {
return t('Dimension is required');
}
return false;
};
// Additional validation for topN mode
const validators = isVisible
? [dimensionValidator, validateNonEmpty]
: [];
return {
datasource,
selectionMode,
selectionMode: getValue(
`matrixify_dimension_selection_mode_${axis}`,
'members',
),
topNMetric: getValue(`matrixify_topn_metric_${axis}`),
topNValue: getValue(`matrixify_topn_value_${axis}`),
topNOrder: getValue(`matrixify_topn_order_${axis}`),
formData: form_data,
validators,
};
},
visibility: ({ controls }) =>
isMatrixifyVisible(controls, axis, 'dimensions'),
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
};
// Dimension picker for TopN mode (just dimension, no values)
// NOTE: This is now handled by matrixify_dimension control, so hiding it
matrixifyControls[`matrixify_topn_dimension_${axis}`] = {
type: 'SelectControl',
label: t('Dimension'),
@@ -173,67 +127,33 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['topn', t('Top n')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
isMatrixifyVisible(controls, axis, 'dimensions'),
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions',
};
// TopN controls
matrixifyControls[`matrixify_topn_value_${axis}`] = {
type: 'NumberControl',
type: 'TextControl',
label: t(`Number of top values`),
description: t(`How many top values to select`),
default: 10,
isInt: true,
validators: [],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: ({ controls }) => {
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
);
return {
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_metric_${axis}`] = {
...dndAdhocMetricControl,
label: t(`Metric for ordering`),
multi: false,
validators: [],
validators: [], // Not required
description: t(`Metric to use for ordering Top N values`),
tabOverride: 'matrixify',
visibility: ({ controls }) =>
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
mapStateToProps: (state, controlState) => {
const { controls, datasource } = state;
const isVisible = isMatrixifyVisible(
controls,
axis,
'dimensions',
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
);
const originalProps =
dndAdhocMetricControl.mapStateToProps?.(state, controlState) || {};
return {
...originalProps,
columns: datasource?.columns || [],
savedMetrics: defineSavedMetrics(datasource),
datasource,
datasourceType: datasource?.type,
validators: isVisible ? [validateNonEmpty] : [],
};
},
};
matrixifyControls[`matrixify_topn_order_${axis}`] = {
@@ -244,10 +164,10 @@ const matrixifyControls: Record<string, SharedControlConfig<any>> = {};
['asc', t('Ascending')],
['desc', t('Descending')],
],
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
isMatrixifyVisible(controls, axis, 'dimensions', 'topn'),
controls?.[`matrixify_mode_${axis}`]?.value === 'dimensions' &&
controls?.[`matrixify_dimension_selection_mode_${axis}`]?.value ===
'topn',
};
});
@@ -293,22 +213,15 @@ matrixifyControls.matrixify_charts_per_row = {
!controls?.matrixify_fit_columns_dynamically?.value,
};
matrixifyControls.matrixify_enable_vertical_layout = {
// Main enable control
matrixifyControls.matrixify_enabled = {
type: 'CheckboxControl',
label: t('Enable vertical layout (rows)'),
description: t('Create matrix rows by stacking charts vertically'),
label: t('Enable matrixify'),
description: t(
'Transform this chart into a matrix/grid of charts based on dimensions or metrics',
),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
matrixifyControls.matrixify_enable_horizontal_layout = {
type: 'CheckboxControl',
label: t('Enable horizontal layout (columns)'),
description: t('Create matrix columns by placing charts side-by-side'),
default: false,
renderTrigger: true,
tabOverride: 'matrixify',
};
// Cell title control for Matrixify
@@ -321,8 +234,8 @@ matrixifyControls.matrixify_cell_title_template = {
default: '',
renderTrigger: true,
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true ||
controls?.matrixify_enable_horizontal_layout?.value === true,
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
};
// Matrix display controls
@@ -332,9 +245,9 @@ matrixifyControls.matrixify_show_row_labels = {
description: t('Display labels for each row on the left side of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.matrixify_enable_vertical_layout?.value === true,
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
};
matrixifyControls.matrixify_show_column_headers = {
@@ -343,9 +256,9 @@ matrixifyControls.matrixify_show_column_headers = {
description: t('Display headers for each column at the top of the matrix'),
default: true,
renderTrigger: true,
tabOverride: 'matrixify',
visibility: ({ controls }) =>
controls?.matrixify_enable_horizontal_layout?.value === true,
(controls?.matrixify_mode_rows?.value ||
controls?.matrixify_mode_columns?.value) !== undefined,
};
export { matrixifyControls };

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { ensureIsArray, GenericDataType, ValueOf } from '@superset-ui/core';
import { ControlPanelState, isDataset, isQueryResponse } from '../types';
import {
ControlPanelState,
isDataset,
isQueryResponse,
} from '@superset-ui/chart-controls';
export function checkColumnType(
columnName: string,

View File

@@ -160,44 +160,6 @@ test('should add renameOperator if exists derived metrics', () => {
});
});
test('should add renameOperator if isTimeComparisonValue without columns', () => {
[
ComparisonType.Difference,
ComparisonType.Ratio,
ComparisonType.Percentage,
].forEach(type => {
expect(
renameOperator(
{
...formData,
...{
comparison_type: type,
time_compare: ['1 year ago'],
},
},
{
...queryObject,
...{
columns: [],
metrics: ['sum(val)', 'avg(val2)'],
},
},
),
).toEqual({
operation: 'rename',
options: {
columns: {
[`${type}__avg(val2)__avg(val2)__1 year ago`]:
'avg(val2), 1 year ago',
[`${type}__sum(val)__sum(val)__1 year ago`]: 'sum(val), 1 year ago',
},
inplace: true,
level: 0,
},
});
});
});
test('should add renameOperator if x_axis does not exist', () => {
expect(
renameOperator(

View File

@@ -2,8 +2,18 @@
"compilerOptions": {
"composite": false,
"emitDeclarationOnly": false,
"noEmit": true,
"rootDir": "."
},
"extends": "../../../tsconfig.json",
"include": ["**/*", "../types/**/*", "../../../types/**/*"]
"include": [
"**/*",
"../types/**/*",
"../../../types/**/*"
],
"references": [
{
"path": ".."
}
]
}

View File

@@ -1,13 +1,22 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "../..",
"outDir": "lib"
"declarationDir": "lib",
"outDir": "lib",
"rootDir": "src"
},
"include": ["src/**/*", "types/**/*"],
"exclude": ["src/**/*.test.*", "src/**/*.stories.*"],
"exclude": [
"lib",
"test"
],
"extends": "../../tsconfig.json",
"include": [
"src/**/*",
"types/**/*",
"../../types/**/*"
],
"references": [
{ "path": "../superset-core" },
{ "path": "../superset-ui-core" }
{
"path": "../superset-ui-core"
}
]
}

View File

@@ -24,15 +24,14 @@
"lib"
],
"dependencies": {
"@apache-superset/core": "*",
"@ant-design/icons": "^5.2.6",
"@babel/runtime": "^7.28.2",
"@fontsource/fira-code": "^5.2.6",
"@fontsource/inter": "^5.2.6",
"@types/json-bigint": "^1.0.4",
"ace-builds": "^1.43.1",
"ag-grid-community": "34.2.0",
"ag-grid-react": "34.2.0",
"ag-grid-community": "^34.0.2",
"ag-grid-react": "34.0.2",
"brace": "^0.11.1",
"classnames": "^2.2.5",
"csstype": "^3.1.3",

View File

@@ -276,10 +276,10 @@ export function generateMatrixifyGrid(
const cellFormData = generateCellFormData(
formData,
rowCount > 0 ? config.rows : null,
colCount > 0 ? config.columns : null,
rowCount > 0 ? row : null,
colCount > 0 ? col : null,
rowCount > 1 ? config.rows : null,
colCount > 1 ? config.columns : null,
rowCount > 1 ? row : null,
colCount > 1 ? col : null,
);
// Generate title using template if provided

View File

@@ -74,8 +74,7 @@ test('should create single group when fitting columns dynamically', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: true,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -124,8 +123,7 @@ test('should create multiple groups when not fitting columns dynamically', () =>
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 3,
matrixify_show_row_labels: true,
@@ -160,8 +158,7 @@ test('should handle exact division of columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -189,8 +186,7 @@ test('should handle case where charts_per_row exceeds total columns', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 5,
matrixify_show_row_labels: true,
@@ -220,8 +216,7 @@ test('should show headers for each group when wrapping occurs', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -255,8 +250,7 @@ test('should show headers only on first row when not wrapping', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: true, // No wrapping
matrixify_show_row_labels: true,
matrixify_show_column_headers: true,
@@ -285,8 +279,7 @@ test('should hide headers when disabled', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_show_row_labels: false,
matrixify_show_column_headers: false,
};
@@ -313,8 +306,7 @@ test('should place cells correctly in wrapped layout', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
matrixify_fit_columns_dynamically: false,
matrixify_charts_per_row: 2,
matrixify_show_row_labels: true,
@@ -344,8 +336,7 @@ test('should handle null grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
};
const { container } = renderWithTheme(
@@ -366,8 +357,7 @@ test('should handle empty grid gracefully', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
};
const { container } = renderWithTheme(
@@ -391,8 +381,7 @@ test('should use default values for missing configuration', () => {
const formData = {
viz_type: 'test_chart',
matrixify_enable_vertical_layout: true,
matrixify_enable_horizontal_layout: true,
matrixify_enabled: true,
// Missing optional configurations
};

View File

@@ -128,13 +128,9 @@ function MatrixifyGridRenderer({
[formData],
);
// Determine layout parameters - only show headers/labels if layout is enabled
const showRowLabels =
formData.matrixify_enable_vertical_layout === true &&
(formData.matrixify_show_row_labels ?? true);
const showColumnHeaders =
formData.matrixify_enable_horizontal_layout === true &&
(formData.matrixify_show_column_headers ?? true);
// Determine layout parameters
const showRowLabels = formData.matrixify_show_row_labels ?? true;
const showColumnHeaders = formData.matrixify_show_column_headers ?? true;
const rowHeight = formData.matrixify_row_height || DEFAULT_ROW_HEIGHT;
const fitColumnsDynamically =
formData.matrixify_fit_columns_dynamically ?? true;

View File

@@ -26,7 +26,6 @@ interface LookupTable {
export interface ExampleImage {
url: string;
urlDark?: string;
caption?: string;
}
@@ -39,7 +38,6 @@ export interface ChartMetadataConfig {
enableNoResults?: boolean;
supportedAnnotationTypes?: string[];
thumbnail: string;
thumbnailDark?: string;
useLegacyApi?: boolean;
behaviors?: Behavior[];
exampleGallery?: ExampleImage[];
@@ -73,8 +71,6 @@ export default class ChartMetadata {
thumbnail: string;
thumbnailDark?: string;
useLegacyApi: boolean;
behaviors: Behavior[];
@@ -111,7 +107,6 @@ export default class ChartMetadata {
description = '',
supportedAnnotationTypes = [],
thumbnail,
thumbnailDark,
useLegacyApi = false,
behaviors = [],
datasourceCount = 1,
@@ -143,7 +138,6 @@ export default class ChartMetadata {
);
this.supportedAnnotationTypes = supportedAnnotationTypes;
this.thumbnail = thumbnail;
this.thumbnailDark = thumbnailDark;
this.useLegacyApi = useLegacyApi;
this.behaviors = behaviors;
this.datasourceCount = datasourceCount;

View File

@@ -37,11 +37,10 @@ test('isMatrixifyEnabled should return false when no matrixify configuration exi
expect(isMatrixifyEnabled(formData)).toBe(false);
});
test('isMatrixifyEnabled should return false when layout controls are false', () => {
test('isMatrixifyEnabled should return false when matrixify_enabled is false', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
matrixify_enabled: false,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
} as MatrixifyFormData;
@@ -52,7 +51,7 @@ test('isMatrixifyEnabled should return false when layout controls are false', ()
test('isMatrixifyEnabled should return true for valid metrics mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -65,7 +64,7 @@ test('isMatrixifyEnabled should return true for valid metrics mode configuration
test('isMatrixifyEnabled should return true for valid dimensions mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -78,7 +77,7 @@ test('isMatrixifyEnabled should return true for valid dimensions mode configurat
test('isMatrixifyEnabled should return true for mixed mode configuration', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'dimensions',
matrixify_rows: [createMetric('Revenue')],
@@ -91,7 +90,7 @@ test('isMatrixifyEnabled should return true for mixed mode configuration', () =>
test('isMatrixifyEnabled should return true for topn dimension selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -110,7 +109,7 @@ test('isMatrixifyEnabled should return true for topn dimension selection mode',
test('isMatrixifyEnabled should return false when both axes have empty metrics arrays', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [],
@@ -123,7 +122,7 @@ test('isMatrixifyEnabled should return false when both axes have empty metrics a
test('isMatrixifyEnabled should return false when both dimensions have empty values and no topn mode', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: [] },
@@ -141,7 +140,7 @@ test('getMatrixifyConfig should return null when no matrixify configuration exis
test('getMatrixifyConfig should return valid config for metrics mode', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -159,7 +158,7 @@ test('getMatrixifyConfig should return valid config for metrics mode', () => {
test('getMatrixifyConfig should return valid config for dimensions mode', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: { dimension: 'country', values: ['USA'] },
@@ -183,7 +182,7 @@ test('getMatrixifyConfig should return valid config for dimensions mode', () =>
test('getMatrixifyConfig should handle topn selection mode', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'dimensions',
matrixify_mode_columns: 'dimensions',
matrixify_dimension_rows: {
@@ -204,8 +203,7 @@ test('getMatrixifyConfig should handle topn selection mode', () => {
test('getMatrixifyValidationErrors should return empty array when matrixify is not enabled', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: false,
matrixify_enable_horizontal_layout: false,
matrixify_enabled: false,
} as MatrixifyFormData;
expect(getMatrixifyValidationErrors(formData)).toEqual([]);
@@ -214,7 +212,7 @@ test('getMatrixifyValidationErrors should return empty array when matrixify is n
test('getMatrixifyValidationErrors should return empty array when properly configured', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_mode_columns: 'metrics',
matrixify_rows: [createMetric('Revenue')],
@@ -227,7 +225,7 @@ test('getMatrixifyValidationErrors should return empty array when properly confi
test('getMatrixifyValidationErrors should return error when enabled but no configuration exists', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
} as MatrixifyFormData;
const errors = getMatrixifyValidationErrors(formData);
@@ -237,7 +235,7 @@ test('getMatrixifyValidationErrors should return error when enabled but no confi
test('getMatrixifyValidationErrors should return error when metrics mode has no metrics', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [],
matrixify_columns: [],
@@ -263,7 +261,7 @@ test('should handle empty form data object', () => {
test('should handle partial configuration with one axis only', () => {
const formData = {
viz_type: 'table',
matrixify_enable_vertical_layout: true,
matrixify_enabled: true,
matrixify_mode_rows: 'metrics',
matrixify_rows: [createMetric('Revenue')],
// No columns configuration

View File

@@ -96,9 +96,8 @@ export interface MatrixifyAxisConfig {
* Complete Matrixify configuration in form data
*/
export interface MatrixifyFormData {
// Layout enable controls
matrixify_enable_vertical_layout?: boolean;
matrixify_enable_horizontal_layout?: boolean;
// Enable/disable matrixify functionality
matrixify_enabled?: boolean;
// Row axis configuration
matrixify_mode_rows?: MatrixifyMode;
@@ -178,12 +177,8 @@ export function getMatrixifyConfig(
* Check if Matrixify is enabled and properly configured
*/
export function isMatrixifyEnabled(formData: MatrixifyFormData): boolean {
// Check if either vertical or horizontal layout is enabled
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
// First check if matrixify is explicitly enabled via checkbox
if (!formData.matrixify_enabled) {
return false;
}
@@ -221,11 +216,7 @@ export function getMatrixifyValidationErrors(
const errors: string[] = [];
// Only validate if matrixify is enabled
const hasVerticalLayout = formData.matrixify_enable_vertical_layout === true;
const hasHorizontalLayout =
formData.matrixify_enable_horizontal_layout === true;
if (!hasVerticalLayout && !hasHorizontalLayout) {
if (!formData.matrixify_enabled) {
return errors;
}

View File

@@ -123,33 +123,21 @@ export function AsyncAceEditor(
const cssWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-css'
);
const javascriptWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-javascript'
);
const htmlWorkerUrlPromise = import(
'ace-builds/src-min-noconflict/worker-html'
);
const acequirePromise = import('ace-builds/src-min-noconflict/ace');
const [
{ default: ReactAceEditor },
{ config },
{ default: cssWorkerUrl },
{ default: javascriptWorkerUrl },
{ default: htmlWorkerUrl },
{ require: acequire },
] = await Promise.all([
reactAcePromise,
aceBuildsConfigPromise,
cssWorkerUrlPromise,
javascriptWorkerUrlPromise,
htmlWorkerUrlPromise,
acequirePromise,
]);
config.setModuleUrl('ace/mode/css_worker', cssWorkerUrl);
config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));
@@ -405,7 +393,10 @@ export const FullSQLEditor = AsyncAceEditor(
},
);
export const MarkdownEditor = AsyncAceEditor(['mode/markdown', 'theme/github']);
export const MarkdownEditor = AsyncAceEditor([
'mode/markdown',
'theme/textmate',
]);
export const TextAreaEditor = AsyncAceEditor([
'mode/markdown',

View File

@@ -1,106 +0,0 @@
/* eslint-disable import/first */
/**
* 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 { FC } from 'react';
import AceEditor, { IAceEditorProps } from 'react-ace';
import ace from 'ace-builds/src-noconflict/ace';
// Disable workers to avoid localhost loading issues
ace.config.set('useWorker', false);
// Import required modes and themes after ace is loaded
import 'ace-builds/src-min-noconflict/mode-handlebars';
import 'ace-builds/src-min-noconflict/mode-css';
import 'ace-builds/src-min-noconflict/mode-json';
import 'ace-builds/src-min-noconflict/mode-sql';
import 'ace-builds/src-min-noconflict/mode-markdown';
import 'ace-builds/src-min-noconflict/mode-javascript';
import 'ace-builds/src-min-noconflict/mode-html';
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode =
| 'handlebars'
| 'css'
| 'json'
| 'sql'
| 'markdown'
| 'javascript'
| 'html';
export type CodeEditorTheme = 'light' | 'dark';
export interface CodeEditorProps
extends Omit<IAceEditorProps, 'mode' | 'theme'> {
mode?: CodeEditorMode;
theme?: CodeEditorTheme;
name?: string;
}
export const CodeEditor: FC<CodeEditorProps> = ({
mode = 'handlebars',
theme = 'dark',
name,
width = '100%',
height = '300px',
value,
fontSize = 14,
showPrintMargin = true,
focus = true,
wrapEnabled = true,
highlightActiveLine = true,
editorProps = { $blockScrolling: true },
setOptions,
...rest
}: CodeEditorProps) => {
const editorName = name || Math.random().toString(36).substring(7);
const aceTheme = theme === 'light' ? 'github' : 'monokai';
return (
<AceEditor
mode={mode}
theme={aceTheme}
name={editorName}
height={height}
width={width}
value={value}
fontSize={fontSize}
showPrintMargin={showPrintMargin}
focus={focus}
editorProps={editorProps}
wrapEnabled={wrapEnabled}
highlightActiveLine={highlightActiveLine}
setOptions={{
enableBasicAutocompletion: true,
enableLiveAutocompletion: true,
enableSnippets: true,
showLineNumbers: true,
tabSize: 2,
showGutter: true,
fontFamily:
'Menlo, Consolas, Courier New, Ubuntu Mono, source-code-pro, Lucida Console, monospace',
...setOptions,
}}
{...rest}
/>
);
};
export default CodeEditor;

View File

@@ -60,7 +60,7 @@ const EmptyStateContainer = styled.div`
flex-direction: column;
width: 100%;
height: 100%;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
align-items: center;
justify-content: center;
padding: ${theme.sizeUnit * 4}px;
@@ -84,7 +84,7 @@ const EmptyStateContainer = styled.div`
const Title = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSizeLG : theme.fontSize}px;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
margin-top: ${size === 'large' ? theme.sizeUnit * 4 : theme.sizeUnit * 2}px;
font-weight: ${theme.fontWeightStrong};
`}
@@ -93,7 +93,7 @@ const Title = styled.p<{ size: EmptyStateSize }>`
const Description = styled.p<{ size: EmptyStateSize }>`
${({ theme, size }) => css`
font-size: ${size === 'large' ? theme.fontSize : theme.fontSizeSM}px;
color: ${theme.colorTextTertiary};
color: ${theme.colorTextQuaternary};
margin-top: ${theme.sizeUnit * 2}px;
`}
`;

View File

@@ -19,10 +19,8 @@
import { useEffect, useState, FunctionComponent } from 'react';
import { t, styled, css, useTheme } from '@superset-ui/core';
import { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { extendedDayjs } from '../../utils/dates';
import 'dayjs/plugin/updateLocale';
import 'dayjs/plugin/calendar';
import { Icons } from '../Icons';
import type { LastUpdatedProps } from './types';
@@ -48,7 +46,9 @@ export const LastUpdated: FunctionComponent<LastUpdatedProps> = ({
update,
}) => {
const theme = useTheme();
const [timeSince, setTimeSince] = useState<Dayjs>(extendedDayjs(updatedAt));
const [timeSince, setTimeSince] = useState<dayjs.Dayjs>(
extendedDayjs(updatedAt),
);
useEffect(() => {
setTimeSince(() => extendedDayjs(updatedAt));

View File

@@ -0,0 +1,37 @@
/**
* 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 } from '@superset-ui/core/spec';
import { Ellipsis } from './Ellipsis';
test('Ellipsis - click when the button is enabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Ellipsis - click when the button is disabled', async () => {
const click = jest.fn();
render(<Ellipsis onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -0,0 +1,38 @@
/**
* 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 classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Ellipsis({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
</span>
</li>
);
}

View File

@@ -0,0 +1,47 @@
/**
* 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 } from '@superset-ui/core/spec';
import { Item } from './Item';
test('Item - click when the item is not active', async () => {
const click = jest.fn();
render(
<Item onClick={click}>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Item - click when the item is active', async () => {
const click = jest.fn();
render(
<Item onClick={click} active>
<div data-test="test" />
</Item>,
);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
expect(screen.getByTestId('test')).toBeInTheDocument();
});

View File

@@ -0,0 +1,45 @@
/**
* 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 { ReactNode } from 'react';
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
interface PaginationItemButton extends PaginationButtonProps {
active?: boolean;
children: ReactNode;
}
export function Item({ active, children, onClick }: PaginationItemButton) {
return (
<li className={classNames({ active })}>
<span
role="button"
tabIndex={0}
aria-current={active ? 'page' : undefined}
onClick={e => {
e.preventDefault();
if (!active) onClick(e);
}}
>
{children}
</span>
</li>
);
}

View File

@@ -0,0 +1,37 @@
/**
* 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 } from '@superset-ui/core/spec';
import { Next } from './Next';
test('Next - click when the button is enabled', async () => {
const click = jest.fn();
render(<Next onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Next - click when the button is disabled', async () => {
const click = jest.fn();
render(<Next onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -17,18 +17,22 @@
* under the License.
*/
/**
* Configuration options for setting up code overrides in different contexts.
*/
interface CodeOverrideOptions {
/**
* Whether the application is running in embedded mode.
*/
embedded?: boolean;
}
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
/**
* Hook for individual deployments to add custom overrides
* @param options - Configuration options for the setup process
*/
export default function setupCodeOverrides(options: CodeOverrideOptions = {}) {}
export function Next({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
»
</span>
</li>
);
}

View File

@@ -0,0 +1,37 @@
/**
* 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 } from '@superset-ui/core/spec';
import { Prev } from './Prev';
test('Prev - click when the button is enabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(1);
});
test('Prev - click when the button is disabled', async () => {
const click = jest.fn();
render(<Prev onClick={click} disabled />);
expect(click).toHaveBeenCalledTimes(0);
await userEvent.click(screen.getByRole('button'));
expect(click).toHaveBeenCalledTimes(0);
});

View File

@@ -17,7 +17,22 @@
* under the License.
*/
export const URL = {
LOGIN: 'login/',
WELCOME: 'superset/welcome/',
} as const;
import classNames from 'classnames';
import { PaginationButtonProps } from './types';
export function Prev({ disabled, onClick }: PaginationButtonProps) {
return (
<li className={classNames({ disabled })}>
<span
role="button"
tabIndex={disabled ? -1 : 0}
onClick={e => {
e.preventDefault();
if (!disabled) onClick(e);
}}
>
«
</span>
</li>
);
}

View File

@@ -0,0 +1,75 @@
/**
* 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, cleanup } from '@superset-ui/core/spec';
import Wrapper from './Wrapper';
// Add cleanup after each test
afterEach(async () => {
cleanup();
// Wait for any pending effects to complete
await new Promise(resolve => setTimeout(resolve, 0));
});
jest.mock('./Next', () => ({
Next: () => <div data-test="next" />,
}));
jest.mock('./Prev', () => ({
Prev: () => <div data-test="prev" />,
}));
jest.mock('./Item', () => ({
Item: () => <div data-test="item" />,
}));
jest.mock('./Ellipsis', () => ({
Ellipsis: () => <div data-test="ellipsis" />,
}));
test('Pagination rendering correctly', async () => {
render(
<Wrapper>
<li data-test="test" />
</Wrapper>,
);
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getByTestId('test')).toBeInTheDocument();
});
test('Next attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Prev attribute', async () => {
render(<Wrapper.Next onClick={jest.fn()} />);
expect(screen.getByTestId('next')).toBeInTheDocument();
});
test('Item attribute', async () => {
render(
<Wrapper.Item onClick={jest.fn()}>
<></>
</Wrapper.Item>,
);
expect(screen.getByTestId('item')).toBeInTheDocument();
});
test('Ellipsis attribute', async () => {
render(<Wrapper.Ellipsis onClick={jest.fn()} />);
expect(screen.getByTestId('ellipsis')).toBeInTheDocument();
});

View File

@@ -0,0 +1,90 @@
/**
* 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 { styled } from '@superset-ui/core';
import { Next } from './Next';
import { Prev } from './Prev';
import { Item } from './Item';
import { Ellipsis } from './Ellipsis';
interface PaginationProps {
children: JSX.Element | JSX.Element[];
}
const PaginationList = styled.ul`
${({ theme }) => `
display: inline-block;
padding: ${theme.sizeUnit * 3}px;
li {
display: inline;
margin: 0 4px;
> span {
padding: 8px 12px;
text-decoration: none;
background-color: ${theme.colorBgContainer};
border: 1px solid ${theme.colorBorder};
border-radius: ${theme.borderRadius}px;
color: ${theme.colorText};
&:hover,
&:focus {
z-index: 2;
color: ${theme.colorText};
background-color: ${theme.colorBgLayout};
}
}
&.disabled {
span {
background-color: transparent;
cursor: default;
&:focus {
outline: none;
}
}
}
&.active {
span {
z-index: 3;
color: ${theme.colorBgLayout};
cursor: default;
background-color: ${theme.colorPrimary};
&:focus {
outline: none;
}
}
}
}
`}
`;
function Pagination({ children }: PaginationProps) {
return <PaginationList role="navigation">{children}</PaginationList>;
}
Pagination.Next = Next;
Pagination.Prev = Prev;
Pagination.Item = Item;
Pagination.Ellipsis = Ellipsis;
export default Pagination;

View File

@@ -0,0 +1,47 @@
/**
* 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 Pagination from '@superset-ui/core/components/Pagination/Wrapper';
import {
createUltimatePagination,
ITEM_TYPES,
} from 'react-ultimate-pagination';
const ListViewPagination = createUltimatePagination({
WrapperComponent: Pagination,
itemTypeToComponent: {
[ITEM_TYPES.PAGE]: ({ value, isActive, onClick }) => (
<Pagination.Item active={isActive} onClick={onClick}>
{value}
</Pagination.Item>
),
[ITEM_TYPES.ELLIPSIS]: ({ isActive, onClick }) => (
<Pagination.Ellipsis disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.PREVIOUS_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Prev disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.NEXT_PAGE_LINK]: ({ isActive, onClick }) => (
<Pagination.Next disabled={isActive} onClick={onClick} />
),
[ITEM_TYPES.FIRST_PAGE_LINK]: () => null,
[ITEM_TYPES.LAST_PAGE_LINK]: () => null,
},
});
export default ListViewPagination;

View File

@@ -17,7 +17,9 @@
* under the License.
*/
// Core Playwright Components for Superset
export { Button } from './Button';
export { Form } from './Form';
export { Input } from './Input';
import { EventHandler, SyntheticEvent } from 'react';
export interface PaginationButtonProps {
disabled?: boolean;
onClick: EventHandler<SyntheticEvent<HTMLElement>>;
}

View File

@@ -100,109 +100,3 @@ test('Should the loading-indicator be visible during loading', () => {
expect(screen.getByTestId('loading-indicator')).toBeVisible();
});
test('Pagination controls should be rendered when pageSize is provided', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...paginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination should call onPageChange when page is changed', async () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Simulate pagination change
await screen.findByTitle('Next Page');
// Verify onPageChange would be called with correct arguments
// The actual AntD pagination will handle the click internally
expect(onPageChange).toBeDefined();
// Verify that re-rendering with new pageIndex works
rerender(<TableCollection {...paginationProps} pageIndex={1} />);
expect(screen.getByRole('list')).toBeInTheDocument();
});
test('Pagination callback should be stable across re-renders', () => {
const onPageChange = jest.fn();
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange,
};
const { rerender } = render(<TableCollection {...paginationProps} />);
// Re-render with same props
rerender(<TableCollection {...paginationProps} />);
// onPageChange should not have been called during re-render
expect(onPageChange).not.toHaveBeenCalled();
});
test('Should display correct page info when showRowCount is true', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: true,
};
render(<TableCollection {...paginationProps} />);
// AntD pagination shows page info
expect(screen.getByText('1-2 of 3')).toBeInTheDocument();
});
test('Should not display page info when showRowCount is false', () => {
const paginationProps = {
...defaultProps,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
showRowCount: false,
};
render(<TableCollection {...paginationProps} />);
// Page info should not be shown
expect(screen.queryByText('1-2 of 3')).not.toBeInTheDocument();
});
test('Bulk selection should work with pagination', () => {
const toggleRowSelected = jest.fn();
const toggleAllRowsSelected = jest.fn();
const selectionProps = {
...defaultProps,
bulkSelectEnabled: true,
selectedFlatRows: [],
toggleRowSelected,
toggleAllRowsSelected,
pageSize: 2,
totalCount: 3,
pageIndex: 0,
onPageChange: jest.fn(),
};
render(<TableCollection {...selectionProps} />);
// Check that selection checkboxes are rendered
const checkboxes = screen.getAllByRole('checkbox');
expect(checkboxes.length).toBeGreaterThan(0);
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { HTMLAttributes, memo, useMemo, useCallback } from 'react';
import { HTMLAttributes, memo, useMemo } from 'react';
import {
ColumnInstance,
HeaderGroup,
@@ -47,25 +47,15 @@ interface TableCollectionProps<T extends object> {
toggleAllRowsSelected?: (value?: boolean) => void;
sticky?: boolean;
size?: TableSize;
pageIndex?: number;
pageSize?: number;
totalCount?: number;
onPageChange?: (page: number, pageSize: number) => void;
isPaginationSticky?: boolean;
showRowCount?: boolean;
}
const StyledTable = styled(Table)<{
isPaginationSticky?: boolean;
showRowCount?: boolean;
}>`
${({ theme, isPaginationSticky, showRowCount }) => `
const StyledTable = styled(Table)`
${({ theme }) => `
th.ant-column-cell {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.actions {
opacity: 0;
font-size: ${theme.fontSizeXL}px;
@@ -82,18 +72,15 @@ const StyledTable = styled(Table)<{
}
}
}
.ant-table-column-title {
line-height: initial;
}
.ant-table-row:hover {
.actions {
opacity: 1;
transition: opacity ease-in ${theme.motionDurationMid};
}
}
.ant-table-cell {
max-width: 320px;
font-feature-settings: 'tnum' 1;
@@ -104,37 +91,10 @@ const StyledTable = styled(Table)<{
padding-left: ${theme.sizeUnit * 4}px;
white-space: nowrap;
}
.ant-table-placeholder .ant-table-cell {
border-bottom: 0;
}
&.ant-table-wrapper .ant-table-pagination.ant-pagination {
display: flex;
justify-content: center;
margin: ${showRowCount ? theme.sizeUnit * 4 : 0}px 0 ${showRowCount ? theme.sizeUnit * 14 : 0}px 0;
position: relative;
.ant-pagination-total-text {
color: ${theme.colorTextBase};
margin-inline-end: 0;
position: absolute;
top: ${theme.sizeUnit * 12}px;
}
${
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
z-index: 1;
background-color: ${theme.colorBgElevated};
padding: ${theme.sizeUnit * 2}px 0;
`
}
}
// Hotfix - antd doesn't apply background color to overflowing cells
& table {
background-color: ${theme.colorBgContainer};
@@ -156,22 +116,13 @@ function TableCollection<T extends object>({
prepareRow,
sticky,
size = TableSize.Middle,
pageIndex = 0,
pageSize = 25,
totalCount = 0,
onPageChange,
isPaginationSticky = false,
showRowCount = true,
}: TableCollectionProps<T>) {
const mappedColumns = useMemo(
() => mapColumns<T>(columns, headerGroups, columnsForWrapText),
[columns, headerGroups, columnsForWrapText],
);
const mappedRows = useMemo(
() => mapRows(rows, prepareRow),
[rows, prepareRow],
const mappedColumns = mapColumns<T>(
columns,
headerGroups,
columnsForWrapText,
);
const mappedRows = mapRows(rows, prepareRow);
const selectedRowKeys = useMemo(
() => selectedFlatRows?.map(row => row.id) || [],
@@ -196,68 +147,6 @@ function TableCollection<T extends object>({
toggleRowSelected,
toggleAllRowsSelected,
]);
const handlePaginationChange = useCallback(
(page: number, size: number) => {
const validPage = Math.max(0, (page || 1) - 1);
const validSize = size || pageSize;
onPageChange?.(validPage, validSize);
},
[pageSize, onPageChange],
);
const showTotalFunc = useCallback(
(total: number, range: [number, number]) =>
`${range[0]}-${range[1]} of ${total}`,
[],
);
const handleTableChange = useCallback(
(_pagination: any, _filters: any, sorter: SorterResult) => {
if (sorter && sorter.field) {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}
},
[setSortBy],
);
const paginationConfig = useMemo(() => {
if (totalCount === 0) return false;
const config: any = {
pageSize,
size: 'default' as const,
showSizeChanger: false,
showQuickJumper: false,
align: 'center' as const,
showTotal: showRowCount ? showTotalFunc : undefined,
};
if (onPageChange) {
config.current = pageIndex + 1;
config.total = totalCount;
config.onChange = handlePaginationChange;
} else {
if (pageIndex > 0) config.defaultCurrent = pageIndex + 1;
config.total = totalCount;
}
return config;
}, [
pageSize,
totalCount,
showRowCount,
showTotalFunc,
pageIndex,
handlePaginationChange,
onPageChange,
]);
return (
<StyledTable
loading={loading}
@@ -266,15 +155,12 @@ function TableCollection<T extends object>({
data={mappedRows}
size={size}
data-test="listview-table"
pagination={paginationConfig}
scroll={{ x: 'max-content' }}
pagination={false}
tableLayout="auto"
rowKey="rowId"
rowSelection={rowSelection}
locale={{ emptyText: null }}
sortDirections={['ascend', 'descend', 'ascend']}
isPaginationSticky={isPaginationSticky}
showRowCount={showRowCount}
components={{
header: {
cell: (props: HTMLAttributes<HTMLTableCellElement>) => (
@@ -290,7 +176,14 @@ function TableCollection<T extends object>({
),
},
}}
onChange={handleTableChange}
onChange={(_pagination, _filters, sorter: SorterResult) => {
setSortBy?.([
{
id: sorter.field,
desc: sorter.order === 'descend',
},
] as SortingRule<T>[]);
}}
/>
);
}

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
import { render, screen, userEvent } from '@superset-ui/core/spec';
import { TableView, TableViewProps } from '.';
const mockedProps: TableViewProps = {
@@ -30,7 +30,6 @@ const mockedProps: TableViewProps = {
{
accessor: 'age',
Header: 'Age',
sortable: true,
id: 'age',
},
{
@@ -79,10 +78,10 @@ test('should render the cells', () => {
test('should render the pagination', () => {
render(<TableView {...mockedProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(2);
expect(screen.getByTitle('Previous Page')).toBeInTheDocument();
expect(screen.getByTitle('Next Page')).toBeInTheDocument();
expect(screen.getByRole('navigation')).toBeInTheDocument();
expect(screen.getAllByRole('button')).toHaveLength(4);
expect(screen.getByText('«')).toBeInTheDocument();
expect(screen.getByText('»')).toBeInTheDocument();
});
test('should show the row count by default', () => {
@@ -105,63 +104,45 @@ test('should NOT render the pagination when disabled', () => {
withPagination: false,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.queryByRole('list')).not.toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should render the pagination even when fewer rows than page size', () => {
test('should NOT render the pagination when fewer rows than page size', () => {
const withoutPaginationProps = {
...mockedProps,
pageSize: 3,
};
render(<TableView {...withoutPaginationProps} />);
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.queryByRole('navigation')).not.toBeInTheDocument();
});
test('should change page when pagination is clicked', async () => {
test('should change page when « and » buttons are clicked', async () => {
render(<TableView {...mockedProps} />);
const nextBtn = screen.getByText('»');
const prevBtn = screen.getByText('«');
await userEvent.click(nextBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
await userEvent.click(prevBtn);
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('321')).toBeInTheDocument();
expect(screen.getByText('10')).toBeInTheDocument();
expect(screen.getByText('Kate')).toBeInTheDocument();
expect(screen.queryByText('Emily')).not.toBeInTheDocument();
});
const page1 = screen.getByRole('listitem', { name: '1' });
await userEvent.click(page1);
await waitFor(() => {
expect(screen.getAllByRole('cell')).toHaveLength(3);
expect(screen.getByText('123')).toBeInTheDocument();
expect(screen.getByText('27')).toBeInTheDocument();
expect(screen.getByText('Emily')).toBeInTheDocument();
expect(screen.queryByText('Kate')).not.toBeInTheDocument();
});
});
test('should sort by age', async () => {
render(<TableView {...mockedProps} />);
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
});
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('10');
await userEvent.click(screen.getAllByTestId('sort-header')[1]);
await waitFor(() => {
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
expect(screen.getAllByTestId('table-row-cell')[1]).toHaveTextContent('27');
});
test('should sort by initialSortBy DESC', () => {
@@ -227,146 +208,3 @@ test('should render the right wrap content text by columnsForWrapText', () => {
'ant-table-cell-ellipsis',
);
});
test('should handle server-side pagination', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
render(<TableView {...serverPaginationProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 1,
});
});
});
test('should handle server-side sorting', async () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
};
render(<TableView {...serverPaginationProps} />);
// Click on sortable column
await userEvent.click(screen.getAllByTestId('sort-header')[0]);
await waitFor(() => {
expect(onServerPagination).toHaveBeenCalledWith({
pageIndex: 0,
sortBy: [{ id: 'id', desc: false }],
});
});
});
test('pagination callbacks should be stable across re-renders', () => {
const onServerPagination = jest.fn();
const serverPaginationProps = {
...mockedProps,
serverPagination: true,
onServerPagination,
totalCount: 10,
pageSize: 2,
};
const { rerender } = render(<TableView {...serverPaginationProps} />);
// Re-render with same props
rerender(<TableView {...serverPaginationProps} />);
// onServerPagination should not have been called during re-render
expect(onServerPagination).not.toHaveBeenCalled();
});
test('should scroll to top when scrollTopOnPagination is true', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: true,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(scrollToSpy).toHaveBeenCalledWith({ top: 0, behavior: 'smooth' });
});
scrollToSpy.mockRestore();
});
test('should NOT scroll to top when scrollTopOnPagination is false', async () => {
const scrollToSpy = jest
.spyOn(window, 'scrollTo')
.mockImplementation(() => {});
const scrollProps = {
...mockedProps,
scrollTopOnPagination: false,
pageSize: 1,
};
render(<TableView {...scrollProps} />);
// Click next page
const page2 = screen.getByRole('listitem', { name: '2' });
await userEvent.click(page2);
await waitFor(() => {
expect(screen.getByText('321')).toBeInTheDocument();
});
expect(scrollToSpy).not.toHaveBeenCalled();
scrollToSpy.mockRestore();
});
test('should handle totalCount of 0 correctly', () => {
const emptyProps = {
...mockedProps,
data: [],
totalCount: 0,
};
render(<TableView {...emptyProps} />);
// Pagination should not be shown when totalCount is 0
expect(screen.queryByRole('list')).not.toBeInTheDocument();
});
test('should handle large datasets with pagination', () => {
const largeDataset = Array.from({ length: 100 }, (_, i) => ({
id: i,
age: 20 + i,
name: `Person ${i}`,
}));
const largeDataProps = {
...mockedProps,
data: largeDataset,
pageSize: 10,
};
render(<TableView {...largeDataProps} />);
// Should show only first page (10 items)
expect(screen.getAllByTestId('table-row')).toHaveLength(10);
// Should show pagination with correct page count
expect(screen.getByRole('list')).toBeInTheDocument();
expect(screen.getByText('1-10 of 100')).toBeInTheDocument();
});

View File

@@ -16,17 +16,16 @@
* specific language governing permissions and limitations
* under the License.
*/
import { memo, useEffect, useRef, useMemo, useCallback } from 'react';
import { memo, useEffect, useRef } from 'react';
import { isEqual } from 'lodash';
import { styled } from '@superset-ui/core';
import { styled, t } from '@superset-ui/core';
import { useFilters, usePagination, useSortBy, useTable } from 'react-table';
import { Empty } from '@superset-ui/core/components';
import Pagination from '@superset-ui/core/components/Pagination';
import TableCollection from '@superset-ui/core/components/TableCollection';
import { TableSize } from '@superset-ui/core/components/Table';
import { SortByType, ServerPagination } from './types';
const NOOP_SERVER_PAGINATION = () => {};
const DEFAULT_PAGE_SIZE = 10;
export enum EmptyWrapperType {
@@ -97,6 +96,29 @@ const TableViewStyles = styled.div<{
}
`;
const PaginationStyles = styled.div<{
isPaginationSticky?: boolean;
}>`
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: ${({ theme }) => theme.colorBgElevated};
${({ isPaginationSticky }) =>
isPaginationSticky &&
`
position: sticky;
bottom: 0;
left: 0;
`};
.row-count-container {
margin-top: ${({ theme }) => theme.sizeUnit * 2}px;
color: ${({ theme }) => theme.colorText};
}
`;
const RawTableView = ({
columns,
data,
@@ -111,21 +133,16 @@ const RawTableView = ({
showRowCount = true,
serverPagination = false,
columnsForWrapText,
onServerPagination = NOOP_SERVER_PAGINATION,
scrollTopOnPagination = true,
onServerPagination = () => {},
scrollTopOnPagination = false,
size = TableSize.Middle,
...props
}: TableViewProps) => {
const tableRef = useRef<HTMLTableElement>(null);
const initialState = useMemo(
() => ({
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
}),
[initialPageSize, initialPageIndex, initialSortBy],
);
const initialState = {
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
pageIndex: initialPageIndex ?? 0,
sortBy: initialSortBy,
};
const {
getTableProps,
@@ -134,9 +151,10 @@ const RawTableView = ({
page,
rows,
prepareRow,
pageCount,
gotoPage,
setSortBy,
state: { pageIndex, sortBy },
state: { pageIndex, pageSize, sortBy },
} = useTable(
{
columns,
@@ -144,94 +162,36 @@ const RawTableView = ({
initialState,
manualPagination: serverPagination,
manualSortBy: serverPagination,
pageCount: serverPagination
? Math.ceil(totalCount / initialState.pageSize)
: undefined,
autoResetSortBy: false,
pageCount: Math.ceil(totalCount / initialState.pageSize),
},
useFilters,
useSortBy,
...(withPagination ? [usePagination] : []),
usePagination,
);
const EmptyWrapperComponent = useMemo(() => {
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
return ({ children }: any) => <>{children}</>;
case EmptyWrapperType.Default:
default:
return ({ children }: any) => <EmptyWrapper>{children}</EmptyWrapper>;
}
}, [emptyWrapperType]);
const content = withPagination ? page : rows;
const content = useMemo(
() => (withPagination ? page : rows),
[withPagination, page, rows],
);
let EmptyWrapperComponent;
switch (emptyWrapperType) {
case EmptyWrapperType.Small:
EmptyWrapperComponent = ({ children }: any) => <>{children}</>;
break;
case EmptyWrapperType.Default:
default:
EmptyWrapperComponent = ({ children }: any) => (
<EmptyWrapper>{children}</EmptyWrapper>
);
}
const isEmpty = useMemo(
() => !loading && content.length === 0,
[loading, content.length],
);
const handleScrollToTop = useCallback(() => {
const isEmpty = !loading && content.length === 0;
const hasPagination = pageCount > 1 && withPagination;
const tableRef = useRef<HTMLTableElement>(null);
const handleGotoPage = (p: number) => {
if (scrollTopOnPagination) {
if (tableRef?.current) {
if (typeof tableRef.current.scrollTo === 'function') {
tableRef.current.scrollTo({ top: 0, behavior: 'smooth' });
} else if (typeof tableRef.current.scroll === 'function') {
tableRef.current.scroll(0, 0);
}
}
if (typeof window !== 'undefined' && window.scrollTo)
window.scrollTo({ top: 0, behavior: 'smooth' });
tableRef?.current?.scroll(0, 0);
}
}, [scrollTopOnPagination]);
const handlePageChange = useCallback(
(p: number) => {
if (scrollTopOnPagination) handleScrollToTop();
gotoPage(p);
},
[scrollTopOnPagination, handleScrollToTop, gotoPage],
);
const paginationProps = useMemo(() => {
if (!withPagination) {
return {
pageIndex: 0,
pageSize: data.length,
totalCount: 0,
onPageChange: undefined,
};
}
if (serverPagination) {
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount,
onPageChange: handlePageChange,
};
}
return {
pageIndex,
pageSize: initialPageSize ?? DEFAULT_PAGE_SIZE,
totalCount: data.length,
onPageChange: handlePageChange,
};
}, [
withPagination,
serverPagination,
pageIndex,
initialPageSize,
totalCount,
data.length,
handlePageChange,
]);
gotoPage(p);
};
useEffect(() => {
if (serverPagination && pageIndex !== initialState.pageIndex) {
@@ -239,7 +199,7 @@ const RawTableView = ({
pageIndex,
});
}
}, [initialState.pageIndex, onServerPagination, pageIndex, serverPagination]);
}, [pageIndex]);
useEffect(() => {
if (serverPagination && !isEqual(sortBy, initialState.sortBy)) {
@@ -248,38 +208,61 @@ const RawTableView = ({
sortBy,
});
}
}, [initialState.sortBy, onServerPagination, serverPagination, sortBy]);
}, [sortBy]);
return (
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
isPaginationSticky={props.isPaginationSticky}
showRowCount={showRowCount}
{...paginationProps}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
<>
<TableViewStyles {...props} ref={tableRef}>
<TableCollection
getTableProps={getTableProps}
getTableBodyProps={getTableBodyProps}
prepareRow={prepareRow}
headerGroups={headerGroups}
rows={content}
columns={columns}
loading={loading}
setSortBy={setSortBy}
size={size}
columnsForWrapText={columnsForWrapText}
/>
{isEmpty && (
<EmptyWrapperComponent>
{noDataText ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={noDataText}
/>
) : (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} />
)}
</EmptyWrapperComponent>
)}
</TableViewStyles>
{hasPagination && (
<PaginationStyles
className="pagination-container"
isPaginationSticky={props.isPaginationSticky}
>
<Pagination
totalPages={pageCount || 0}
currentPage={pageCount ? pageIndex + 1 : 0}
onChange={(p: number) => handleGotoPage(p - 1)}
hideFirstAndLastPageLinks
/>
{showRowCount && (
<div className="row-count-container">
{!loading &&
t(
'%s-%s of %s',
pageSize * pageIndex + (page.length && 1),
pageSize * pageIndex + page.length,
totalCount,
)}
</div>
)}
</EmptyWrapperComponent>
</PaginationStyles>
)}
</TableViewStyles>
</>
);
};

View File

@@ -1,306 +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 { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
describe('Tabs', () => {
const defaultItems = [
{
key: '1',
label: 'Tab 1',
children: <div data-testid="tab1-content">Tab 1 content</div>,
},
{
key: '2',
label: 'Tab 2',
children: <div data-testid="tab2-content">Tab 2 content</div>,
},
{
key: '3',
label: 'Tab 3',
children: <div data-testid="tab3-content">Tab 3 content</div>,
},
];
describe('Basic Tabs', () => {
it('should render tabs with default props', () => {
const { getByText, container } = render(<Tabs items={defaultItems} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
expect(getByText('Tab 3')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active',
);
expect(activeTabContent).toBeDefined();
expect(
activeTabContent?.querySelector('[data-testid="tab1-content"]'),
).toBeDefined();
});
it('should render tabs component structure', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
const tabsNav = container.querySelector('.ant-tabs-nav');
const tabsContent = container.querySelector('.ant-tabs-content-holder');
expect(tabsElement).toBeDefined();
expect(tabsNav).toBeDefined();
expect(tabsContent).toBeDefined();
});
it('should apply default tabBarStyle with padding', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
// Check that tabBarStyle is applied (default padding is added)
expect(tabsNav?.style?.paddingLeft).toBeDefined();
});
it('should merge custom tabBarStyle with defaults', () => {
const customStyle = { paddingRight: '20px', backgroundColor: 'red' };
const { container } = render(
<Tabs items={defaultItems} tabBarStyle={customStyle} />,
);
const tabsNav = container.querySelector('.ant-tabs-nav') as HTMLElement;
expect(tabsNav?.style?.paddingLeft).toBeDefined();
expect(tabsNav?.style?.paddingRight).toBe('20px');
expect(tabsNav?.style?.backgroundColor).toBe('red');
});
it('should handle allowOverflow prop', () => {
const { container: allowContainer } = render(
<Tabs items={defaultItems} allowOverflow />,
);
const { container: disallowContainer } = render(
<Tabs items={defaultItems} allowOverflow={false} />,
);
expect(allowContainer.querySelector('.ant-tabs')).toBeDefined();
expect(disallowContainer.querySelector('.ant-tabs')).toBeDefined();
});
it('should disable animation by default', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).not.toContain('ant-tabs-animated');
});
it('should handle tab change events', () => {
const onChangeMock = jest.fn();
const { getByText } = render(
<Tabs items={defaultItems} onChange={onChangeMock} />,
);
fireEvent.click(getByText('Tab 2'));
expect(onChangeMock).toHaveBeenCalledWith('2');
});
it('should pass through additional props to Antd Tabs', () => {
const onTabClickMock = jest.fn();
const { getByText } = render(
<Tabs
items={defaultItems}
onTabClick={onTabClickMock}
size="large"
centered
/>,
);
fireEvent.click(getByText('Tab 2'));
expect(onTabClickMock).toHaveBeenCalled();
});
});
describe('EditableTabs', () => {
it('should render with editable features', () => {
const { container } = render(<EditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should handle onEdit callback for add/remove actions', () => {
const onEditMock = jest.fn();
const itemsWithRemove = defaultItems.map(item => ({
...item,
closable: true,
}));
const { container } = render(
<EditableTabs items={itemsWithRemove} onEdit={onEditMock} />,
);
const removeButton = container.querySelector('.ant-tabs-tab-remove');
expect(removeButton).toBeDefined();
fireEvent.click(removeButton!);
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
});
it('should have default props set correctly', () => {
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
expect(EditableTabs.defaultProps?.animated).toEqual({
inkBar: true,
tabPane: false,
});
});
});
describe('LineEditableTabs', () => {
it('should render as line-style editable tabs', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('ant-tabs-card');
expect(tabsElement?.className).toContain('ant-tabs-editable-card');
});
it('should render with line-specific styling', () => {
const { container } = render(<LineEditableTabs items={defaultItems} />);
const inkBar = container.querySelector('.ant-tabs-ink-bar');
expect(inkBar).toBeDefined();
});
});
describe('TabPane Legacy Support', () => {
it('should support TabPane component access', () => {
expect(Tabs.TabPane).toBeDefined();
expect(EditableTabs.TabPane).toBeDefined();
expect(LineEditableTabs.TabPane).toBeDefined();
});
it('should render using legacy TabPane syntax', () => {
const { getByText, container } = render(
<Tabs>
<Tabs.TabPane tab="Legacy Tab 1" key="1">
<div data-testid="legacy-content-1">Legacy content 1</div>
</Tabs.TabPane>
<Tabs.TabPane tab="Legacy Tab 2" key="2">
<div data-testid="legacy-content-2">Legacy content 2</div>
</Tabs.TabPane>
</Tabs>,
);
expect(getByText('Legacy Tab 1')).toBeInTheDocument();
expect(getByText('Legacy Tab 2')).toBeInTheDocument();
const activeTabContent = container.querySelector(
'.ant-tabs-tabpane-active [data-testid="legacy-content-1"]',
);
expect(activeTabContent).toBeDefined();
expect(activeTabContent?.textContent).toBe('Legacy content 1');
});
});
describe('Edge Cases', () => {
it('should handle empty items array', () => {
const { container } = render(<Tabs items={[]} />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle undefined items', () => {
const { container } = render(<Tabs />);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement).toBeDefined();
});
it('should handle tabs with no content', () => {
const itemsWithoutContent = [
{ key: '1', label: 'Tab 1' },
{ key: '2', label: 'Tab 2' },
];
const { getByText } = render(<Tabs items={itemsWithoutContent} />);
expect(getByText('Tab 1')).toBeInTheDocument();
expect(getByText('Tab 2')).toBeInTheDocument();
});
it('should handle allowOverflow default value', () => {
const { container } = render(<Tabs items={defaultItems} />);
expect(container.querySelector('.ant-tabs')).toBeDefined();
});
});
describe('Accessibility', () => {
it('should render with proper ARIA roles', () => {
const { container } = render(<Tabs items={defaultItems} />);
const tablist = container.querySelector('[role="tablist"]');
const tabs = container.querySelectorAll('[role="tab"]');
expect(tablist).toBeDefined();
expect(tabs.length).toBe(3);
});
it('should support keyboard navigation', () => {
const { container, getByText } = render(<Tabs items={defaultItems} />);
const firstTab = container.querySelector('[role="tab"]');
const secondTab = getByText('Tab 2');
if (firstTab) {
fireEvent.keyDown(firstTab, { key: 'ArrowRight', code: 'ArrowRight' });
}
fireEvent.click(secondTab);
expect(secondTab).toBeInTheDocument();
});
});
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
const tabsElement = container.querySelector('.ant-tabs');
expect(tabsElement?.className).toContain('custom-tabs-class');
});
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
<Tabs items={defaultItems} style={customStyle} />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
expect(tabsElement?.style?.minHeight).toBe('200px');
});
});
});

View File

@@ -29,18 +29,14 @@ export interface TabsProps extends AntdTabsProps {
const StyledTabs = ({
animated = false,
allowOverflow = true,
tabBarStyle,
...props
}: TabsProps) => {
const theme = useTheme();
const defaultTabBarStyle = { paddingLeft: theme.sizeUnit * 4 };
const mergedStyle = { ...defaultTabBarStyle, ...tabBarStyle };
return (
<AntdTabs
animated={animated}
{...props}
tabBarStyle={mergedStyle}
tabBarStyle={{ paddingLeft: theme.sizeUnit * 4 }}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};

View File

@@ -181,9 +181,3 @@ export {
type ThemedAgGridReactProps,
setupAGGridModules,
} from './ThemedAgGridReact';
export {
CodeEditor,
type CodeEditorProps,
type CodeEditorMode,
type CodeEditorTheme,
} from './CodeEditor';

View File

@@ -60,7 +60,10 @@ export function useTheme() {
const styled: CreateStyled = emotionStyled;
const themeObject: Theme = Theme.fromConfig();
// launching in in dark mode for now while iterating
const themeObject: Theme = Theme.fromConfig({
algorithm: ThemeAlgorithm.DEFAULT,
});
const { theme } = themeObject;
const supersetTheme = theme;

View File

@@ -127,15 +127,6 @@ export interface SupersetSpecificTokens {
// Spinner-related
brandSpinnerUrl?: string;
brandSpinnerSvg?: string;
// ECharts-related
/** Global ECharts configuration overrides applied to all chart types */
echartsOptionsOverrides?: any;
/** Chart-specific ECharts configuration overrides keyed by viz_type */
echartsOptionsOverridesByChartType?: {
[chartType: string]: any;
};
}
/**

View File

@@ -43,7 +43,6 @@ dayjs.updateLocale('en', {
});
export const extendedDayjs = dayjs;
export type { Dayjs };
export const fDuration = function (
t1: number,

View File

@@ -26,7 +26,6 @@ export enum FeatureFlag {
AlertReports = 'ALERT_REPORTS',
AlertReportTabs = 'ALERT_REPORT_TABS',
AlertReportSlackV2 = 'ALERT_REPORT_SLACK_V2',
AlertReportsFilter = 'ALERT_REPORTS_FILTER',
AllowFullCsvExport = 'ALLOW_FULL_CSV_EXPORT',
AvoidColorsCollision = 'AVOID_COLORS_COLLISION',
ChartPluginsExperimental = 'CHART_PLUGINS_EXPERIMENTAL',
@@ -35,7 +34,6 @@ export enum FeatureFlag {
DashboardVirtualization = 'DASHBOARD_VIRTUALIZATION',
DashboardRbac = 'DASHBOARD_RBAC',
DatapanelClosedByDefault = 'DATAPANEL_CLOSED_BY_DEFAULT',
DateRangeTimeshiftsEnabled = 'DATE_RANGE_TIMESHIFTS_ENABLED',
/** @deprecated */
DrillToDetail = 'DRILL_TO_DETAIL',
DrillBy = 'DRILL_BY',

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