mirror of
https://github.com/apache/superset.git
synced 2026-05-14 04:15:12 +00:00
Compare commits
23 Commits
sidecar
...
memory_lea
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
793a075915 | ||
|
|
64d25c85f7 | ||
|
|
0fce5ecfa5 | ||
|
|
385471c34d | ||
|
|
bef1f4d045 | ||
|
|
5a3182ce21 | ||
|
|
9efb80dbf4 | ||
|
|
a20b236809 | ||
|
|
4e969d19d1 | ||
|
|
876257fb94 | ||
|
|
472e599f91 | ||
|
|
d826e90395 | ||
|
|
c65cb284e6 | ||
|
|
bc54b7970a | ||
|
|
ce74ae095d | ||
|
|
9424538bb1 | ||
|
|
031fb4b5a8 | ||
|
|
7fb7ac8bef | ||
|
|
569a7b33a5 | ||
|
|
59df0d6f15 | ||
|
|
2e4ccffc11 | ||
|
|
2e51d02806 | ||
|
|
8406a827dd |
@@ -1,348 +0,0 @@
|
||||
# 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
|
||||
@@ -1,60 +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.
|
||||
|
||||
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
|
||||
@@ -65,7 +65,7 @@
|
||||
"storybook": "^8.6.11",
|
||||
"swagger-ui-react": "^5.27.1",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"ts-loader": "^9.5.2"
|
||||
"ts-loader": "^9.5.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@docusaurus/module-type-aliases": "^3.8.1",
|
||||
|
||||
@@ -13139,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.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==
|
||||
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==
|
||||
dependencies:
|
||||
chalk "^4.1.0"
|
||||
enhanced-resolve "^5.0.0"
|
||||
|
||||
@@ -88,7 +88,7 @@ dependencies = [
|
||||
"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, <17", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"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
|
||||
"pyyaml>=6.0.0, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=4.6.0, <5.0",
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,16 +0,0 @@
|
||||
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'],
|
||||
};
|
||||
@@ -1,41 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
@@ -1,149 +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 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');
|
||||
});
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
// Licensed to the Apache Software Foundation (ASF) under one
|
||||
// or more contributor license agreements. See the NOTICE file
|
||||
// distributed with this work for additional information
|
||||
// regarding copyright ownership. The ASF licenses this file
|
||||
// to you under the Apache License, Version 2.0 (the
|
||||
// "License"); you may not use this file except in compliance
|
||||
// with the License. You may obtain a copy of the License at
|
||||
//
|
||||
// http://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing,
|
||||
// software distributed under the License is distributed on an
|
||||
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
// KIND, either express or implied. See the License for the
|
||||
// specific language governing permissions and limitations
|
||||
// under the License.
|
||||
|
||||
import 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;
|
||||
@@ -1,134 +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.
|
||||
|
||||
/* 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 };
|
||||
}
|
||||
@@ -1,91 +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 { 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;
|
||||
@@ -1,213 +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.
|
||||
|
||||
/* 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;
|
||||
}
|
||||
@@ -1,78 +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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,73 +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 {
|
||||
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;
|
||||
}
|
||||
@@ -1,57 +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 { 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;
|
||||
}
|
||||
@@ -1,72 +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 {
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
]
|
||||
}
|
||||
18
superset-frontend/package-lock.json
generated
18
superset-frontend/package-lock.json
generated
@@ -21,7 +21,7 @@
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^5.24.13",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.12",
|
||||
"@rjsf/validator-ajv8": "^5.24.13",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -57,6 +57,7 @@
|
||||
"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",
|
||||
@@ -174,6 +175,7 @@
|
||||
"@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",
|
||||
@@ -10884,9 +10886,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rjsf/validator-ajv8": {
|
||||
"version": "5.24.12",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.12.tgz",
|
||||
"integrity": "sha512-IMXdCjvDNdvb+mDgZC3AlAtr0pjYKq5s0GcLECjG5PuiX7Ib4JaDQHZY5ZJdKblMfgzhsn8AAOi573jXAt7BHQ==",
|
||||
"version": "5.24.13",
|
||||
"resolved": "https://registry.npmjs.org/@rjsf/validator-ajv8/-/validator-ajv8-5.24.13.tgz",
|
||||
"integrity": "sha512-oWHP7YK581M8I5cF1t+UXFavnv+bhcqjtL1a7MG/Kaffi0EwhgcYjODrD8SsnrhncsEYMqSECr4ZOEoirnEUWw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ajv": "^8.12.0",
|
||||
@@ -15250,6 +15252,13 @@
|
||||
"@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",
|
||||
@@ -21928,7 +21937,6 @@
|
||||
"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"
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"@reduxjs/toolkit": "^1.9.3",
|
||||
"@rjsf/core": "^5.24.13",
|
||||
"@rjsf/utils": "^5.24.3",
|
||||
"@rjsf/validator-ajv8": "^5.24.12",
|
||||
"@rjsf/validator-ajv8": "^5.24.13",
|
||||
"@scarf/scarf": "^1.4.0",
|
||||
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
|
||||
"@superset-ui/core": "file:./packages/superset-ui-core",
|
||||
@@ -125,6 +125,7 @@
|
||||
"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",
|
||||
@@ -242,6 +243,7 @@
|
||||
"@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",
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
{
|
||||
"name": "@apache-superset/core",
|
||||
"version": "0.0.1-rc2",
|
||||
"version": "0.0.1-rc3",
|
||||
"description": "This package contains UI elements, APIs, and utility functions used by Superset.",
|
||||
"sideEffects": false,
|
||||
"main": "lib/index.js",
|
||||
"module": "esm/index.js",
|
||||
"types": "lib/index.d.ts",
|
||||
"files": [
|
||||
"esm",
|
||||
"lib"
|
||||
],
|
||||
"author": "",
|
||||
@@ -19,14 +18,15 @@
|
||||
"@babel/preset-typescript": "^7.26.0",
|
||||
"@types/react": "^17.0.83",
|
||||
"install": "^0.13.0",
|
||||
"npm": "^11.1.0"
|
||||
"npm": "^11.1.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"antd": "^5.24.6",
|
||||
"react": "^17.0.2"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "babel src --out-dir lib --extensions \".ts,.tsx\"",
|
||||
"build": "babel src --out-dir lib --extensions \".ts,.tsx\" && tsc --emitDeclarationOnly",
|
||||
"type": "tsc --noEmit"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
"baseUrl": ".",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "node",
|
||||
"skipLibCheck": true
|
||||
"skipLibCheck": true,
|
||||
"target": "es2020",
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*.ts*"],
|
||||
"exclude": ["lib"]
|
||||
|
||||
@@ -43,15 +43,17 @@ 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) dimension exist or multiple time shift metrics exist
|
||||
// 3) xAxis exist
|
||||
// 4) truncate_metric in form_data and truncate_metric is true
|
||||
// 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
|
||||
if (
|
||||
metrics.length > 0 &&
|
||||
(columns.length > 0 || timeOffsets.length > 1) &&
|
||||
xAxisLabel &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric
|
||||
(isTimeComparisonValue ||
|
||||
((columns.length > 0 || timeOffsets.length > 1) &&
|
||||
truncate_metric !== undefined &&
|
||||
!!truncate_metric))
|
||||
) {
|
||||
const renamePairs: [string, string | null][] = [];
|
||||
if (
|
||||
|
||||
@@ -160,6 +160,44 @@ 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(
|
||||
|
||||
@@ -393,10 +393,7 @@ export const FullSQLEditor = AsyncAceEditor(
|
||||
},
|
||||
);
|
||||
|
||||
export const MarkdownEditor = AsyncAceEditor([
|
||||
'mode/markdown',
|
||||
'theme/textmate',
|
||||
]);
|
||||
export const MarkdownEditor = AsyncAceEditor(['mode/markdown', 'theme/github']);
|
||||
|
||||
export const TextAreaEditor = AsyncAceEditor([
|
||||
'mode/markdown',
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,14 +29,18 @@ 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={{ paddingLeft: theme.sizeUnit * 4 }}
|
||||
tabBarStyle={mergedStyle}
|
||||
css={theme => css`
|
||||
overflow: ${allowOverflow ? 'visible' : 'hidden'};
|
||||
|
||||
|
||||
@@ -58,11 +58,13 @@ export default styled(CountryMap)`
|
||||
}
|
||||
|
||||
.superset-legacy-chart-country-map text.result-text {
|
||||
fill: ${theme.colorText};
|
||||
font-weight: ${theme.fontWeightLight};
|
||||
font-size: ${theme.fontSizeXL}px;
|
||||
}
|
||||
|
||||
.superset-legacy-chart-country-map text.big-text {
|
||||
fill: ${theme.colorText};
|
||||
font-weight: ${theme.fontWeightStrong};
|
||||
font-size: ${theme.fontSizeLG}px;
|
||||
}
|
||||
|
||||
@@ -128,6 +128,7 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
|
||||
legendOrientation,
|
||||
legendMargin,
|
||||
legendType,
|
||||
legendSort,
|
||||
sliceId,
|
||||
}: EchartsBubbleFormData = { ...DEFAULT_FORM_DATA, ...formData };
|
||||
const colorFn = CategoricalColorNamespace.getScale(colorScheme as string);
|
||||
@@ -230,7 +231,10 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) {
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: Array.from(legends),
|
||||
data: Array.from(legends).sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}),
|
||||
},
|
||||
tooltip: {
|
||||
show: !inContextMenu,
|
||||
|
||||
@@ -112,6 +112,7 @@ export default function transformProps(
|
||||
legendMargin,
|
||||
legendOrientation,
|
||||
legendType,
|
||||
legendSort,
|
||||
metric = '',
|
||||
numberFormat,
|
||||
currencyFormat,
|
||||
@@ -290,7 +291,10 @@ export default function transformProps(
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: keys,
|
||||
data: keys.sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}),
|
||||
},
|
||||
series,
|
||||
};
|
||||
|
||||
@@ -131,6 +131,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
legendMargin,
|
||||
legendOrientation,
|
||||
legendType,
|
||||
legendSort,
|
||||
showLegend,
|
||||
yAxisTitle,
|
||||
yAxisTitleMargin,
|
||||
@@ -330,6 +331,18 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
},
|
||||
);
|
||||
|
||||
const legendData = series
|
||||
.map(entry => {
|
||||
const { name } = entry;
|
||||
if (name === null || name === undefined) return '';
|
||||
return String(name);
|
||||
})
|
||||
.filter(name => name !== '')
|
||||
.sort((a, b) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
});
|
||||
|
||||
const tooltipFormatterMap = {
|
||||
[GenericDataType.Numeric]: tooltipValuesFormatter,
|
||||
[GenericDataType.String]: undefined,
|
||||
@@ -365,6 +378,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) {
|
||||
zoomable,
|
||||
legendState,
|
||||
),
|
||||
data: legendData,
|
||||
},
|
||||
grid: {
|
||||
...defaultGrid,
|
||||
|
||||
@@ -188,6 +188,7 @@ export default function transformProps(
|
||||
legendMargin,
|
||||
legendOrientation,
|
||||
legendType,
|
||||
legendSort,
|
||||
showLegend,
|
||||
baseEdgeWidth,
|
||||
baseNodeSize,
|
||||
@@ -353,7 +354,10 @@ export default function transformProps(
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: categoryList,
|
||||
data: categoryList.sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}),
|
||||
},
|
||||
series,
|
||||
};
|
||||
|
||||
@@ -129,6 +129,7 @@ export default function transformProps(
|
||||
theme,
|
||||
inContextMenu,
|
||||
emitCrossFilters,
|
||||
legendState,
|
||||
} = chartProps;
|
||||
|
||||
let focusedSeries: string | null = null;
|
||||
@@ -157,7 +158,9 @@ export default function transformProps(
|
||||
timeShiftColor,
|
||||
contributionMode,
|
||||
legendOrientation,
|
||||
legendMargin,
|
||||
legendType,
|
||||
legendSort,
|
||||
logAxis,
|
||||
logAxisSecondary,
|
||||
markerEnabled,
|
||||
@@ -423,6 +426,7 @@ export default function transformProps(
|
||||
{
|
||||
...entry,
|
||||
id: `${displayName || ''}`,
|
||||
name: `${displayName || ''}`,
|
||||
},
|
||||
colorScale,
|
||||
colorScaleKey,
|
||||
@@ -489,6 +493,7 @@ export default function transformProps(
|
||||
{
|
||||
...entry,
|
||||
id: `${displayName || ''}`,
|
||||
name: `${displayName || ''}`,
|
||||
},
|
||||
|
||||
colorScale,
|
||||
@@ -550,7 +555,7 @@ export default function transformProps(
|
||||
legendOrientation,
|
||||
addYAxisTitleOffset,
|
||||
zoomable,
|
||||
null,
|
||||
legendMargin,
|
||||
addXAxisTitleOffset,
|
||||
yAxisTitlePosition,
|
||||
convertInteger(yAxisTitleMargin),
|
||||
@@ -713,6 +718,8 @@ export default function transformProps(
|
||||
showLegend,
|
||||
theme,
|
||||
zoomable,
|
||||
legendState,
|
||||
chartPadding,
|
||||
),
|
||||
// @ts-ignore
|
||||
data: series
|
||||
@@ -722,7 +729,11 @@ export default function transformProps(
|
||||
ForecastSeriesEnum.Observation,
|
||||
)
|
||||
.map(entry => entry.id || entry.name || '')
|
||||
.concat(extractAnnotationLabels(annotationLayers)),
|
||||
.concat(extractAnnotationLabels(annotationLayers))
|
||||
.sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}),
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
toolbox: {
|
||||
|
||||
@@ -29,7 +29,7 @@ import {
|
||||
sharedControls,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { DEFAULT_FORM_DATA } from './types';
|
||||
import { legendSection, legendSortControl } from '../controls';
|
||||
import { legendSection } from '../controls';
|
||||
|
||||
const {
|
||||
donut,
|
||||
@@ -119,7 +119,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
],
|
||||
...legendSection,
|
||||
[legendSortControl],
|
||||
// eslint-disable-next-line react/jsx-key
|
||||
[<ControlSubSectionHeader>{t('Labels')}</ControlSubSectionHeader>],
|
||||
[
|
||||
|
||||
@@ -40,7 +40,6 @@ export type EchartsPieFormData = QueryFormData &
|
||||
labelType: EchartsPieLabelType;
|
||||
labelTemplate: string | null;
|
||||
labelsOutside: boolean;
|
||||
legendSort: 'asc' | 'desc' | null;
|
||||
metric?: string;
|
||||
outerRadius: number;
|
||||
showLabels: boolean;
|
||||
|
||||
@@ -116,6 +116,7 @@ export default function transformProps(
|
||||
dateFormat,
|
||||
showLabels,
|
||||
showLegend,
|
||||
legendSort,
|
||||
isCircle,
|
||||
columnConfig,
|
||||
sliceId,
|
||||
@@ -354,7 +355,10 @@ export default function transformProps(
|
||||
},
|
||||
legend: {
|
||||
...getLegendProps(legendType, legendOrientation, showLegend, theme),
|
||||
data: Array.from(columnsLabelMap.keys()),
|
||||
data: Array.from(columnsLabelMap.keys()).sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}),
|
||||
},
|
||||
series,
|
||||
radar: {
|
||||
|
||||
@@ -149,6 +149,7 @@ export default function transformProps(
|
||||
legendOrientation,
|
||||
legendType,
|
||||
legendMargin,
|
||||
legendSort,
|
||||
logAxis,
|
||||
markerEnabled,
|
||||
markerSize,
|
||||
@@ -646,7 +647,10 @@ export default function transformProps(
|
||||
padding,
|
||||
),
|
||||
scrollDataIndex: legendIndex || 0,
|
||||
data: legendData as string[],
|
||||
data: legendData.sort((a: string, b: string) => {
|
||||
if (!legendSort) return 0;
|
||||
return legendSort === 'asc' ? a.localeCompare(b) : b.localeCompare(a);
|
||||
}) as string[],
|
||||
},
|
||||
series: dedupSeries(reorderForecastSeries(series) as SeriesOption[]),
|
||||
toolbox: {
|
||||
|
||||
@@ -103,6 +103,7 @@ export const DEFAULT_LEGEND_FORM_DATA: LegendFormData = {
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Scroll,
|
||||
showLegend: true,
|
||||
legendSort: null,
|
||||
};
|
||||
|
||||
export const DEFAULT_TITLE_FORM_DATA: TitleFormData = {
|
||||
|
||||
@@ -121,6 +121,7 @@ export const legendSection: ControlSetRow[] = [
|
||||
[legendTypeControl],
|
||||
[legendOrientationControl],
|
||||
[legendMarginControl],
|
||||
[legendSortControl],
|
||||
];
|
||||
|
||||
export const showValueControl: ControlSetItem = {
|
||||
|
||||
@@ -98,6 +98,7 @@ export type LegendFormData = {
|
||||
legendOrientation: LegendOrientation;
|
||||
legendType: LegendType;
|
||||
showLegend: boolean;
|
||||
legendSort: 'asc' | 'desc' | null;
|
||||
};
|
||||
|
||||
export type EventHandlers = Record<string, { (props: any): void }>;
|
||||
|
||||
@@ -27,62 +27,65 @@ import { EchartsBubbleChartProps } from 'plugins/plugin-chart-echarts/src/Bubble
|
||||
|
||||
import transformProps, { formatTooltip } from '../../src/Bubble/transformProps';
|
||||
|
||||
describe('Bubble transformProps', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_bubble',
|
||||
entity: 'customer_name',
|
||||
x: 'count',
|
||||
y: {
|
||||
aggregate: 'sum',
|
||||
column: {
|
||||
column_name: 'price_each',
|
||||
},
|
||||
expressionType: 'simple',
|
||||
label: 'SUM(price_each)',
|
||||
const defaultFormData: SqlaFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'echarts_bubble',
|
||||
entity: 'customer_name',
|
||||
x: 'count',
|
||||
y: {
|
||||
aggregate: 'sum',
|
||||
column: {
|
||||
column_name: 'price_each',
|
||||
},
|
||||
size: {
|
||||
aggregate: 'sum',
|
||||
column: {
|
||||
column_name: 'sales',
|
||||
},
|
||||
expressionType: 'simple',
|
||||
label: 'SUM(sales)',
|
||||
expressionType: 'simple',
|
||||
label: 'SUM(price_each)',
|
||||
},
|
||||
size: {
|
||||
aggregate: 'sum',
|
||||
column: {
|
||||
column_name: 'sales',
|
||||
},
|
||||
xAxisBounds: [null, null],
|
||||
yAxisBounds: [null, null],
|
||||
};
|
||||
const chartConfig: ChartPropsConfig = {
|
||||
formData: defaultFormData,
|
||||
height: 800,
|
||||
width: 800,
|
||||
queriesData: [
|
||||
expressionType: 'simple',
|
||||
label: 'SUM(sales)',
|
||||
},
|
||||
xAxisBounds: [null, null],
|
||||
yAxisBounds: [null, null],
|
||||
};
|
||||
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
customer_name: 'AV Stores, Co.',
|
||||
count: 10,
|
||||
'SUM(price_each)': 20,
|
||||
'SUM(sales)': 30,
|
||||
},
|
||||
{
|
||||
customer_name: 'Alpha Cognac',
|
||||
count: 40,
|
||||
'SUM(price_each)': 50,
|
||||
'SUM(sales)': 60,
|
||||
},
|
||||
{
|
||||
customer_name: 'Amica Models & Co.',
|
||||
count: 70,
|
||||
'SUM(price_each)': 80,
|
||||
'SUM(sales)': 90,
|
||||
},
|
||||
],
|
||||
customer_name: 'AV Stores, Co.',
|
||||
count: 10,
|
||||
'SUM(price_each)': 20,
|
||||
'SUM(sales)': 30,
|
||||
},
|
||||
{
|
||||
customer_name: 'Alpha Cognac',
|
||||
count: 40,
|
||||
'SUM(price_each)': 50,
|
||||
'SUM(sales)': 60,
|
||||
},
|
||||
{
|
||||
customer_name: 'Amica Models & Co.',
|
||||
count: 70,
|
||||
'SUM(price_each)': 80,
|
||||
'SUM(sales)': 90,
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
};
|
||||
},
|
||||
];
|
||||
|
||||
const chartConfig: ChartPropsConfig = {
|
||||
formData: defaultFormData,
|
||||
height: 800,
|
||||
width: 800,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('Bubble transformProps', () => {
|
||||
it('Should transform props for viz', () => {
|
||||
const chartProps = new ChartProps(chartConfig);
|
||||
expect(transformProps(chartProps as EchartsBubbleChartProps)).toEqual(
|
||||
@@ -201,3 +204,49 @@ describe('Bubble formatTooltip', () => {
|
||||
expect(html).toContain('300.0%');
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const createChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartConfig,
|
||||
formData: {
|
||||
...defaultFormData,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
it('preserves original data order when no sort specified', () => {
|
||||
const props = createChartProps({ legendSort: null });
|
||||
const result = transformProps(props as EchartsBubbleChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'AV Stores, Co.',
|
||||
'Alpha Cognac',
|
||||
'Amica Models & Co.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically ascending when legendSort is "asc"', () => {
|
||||
const props = createChartProps({ legendSort: 'asc' });
|
||||
const result = transformProps(props as EchartsBubbleChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'Alpha Cognac',
|
||||
'Amica Models & Co.',
|
||||
'AV Stores, Co.',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically descending when legendSort is "desc"', () => {
|
||||
const props = createChartProps({ legendSort: 'desc' });
|
||||
const result = transformProps(props as EchartsBubbleChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'AV Stores, Co.',
|
||||
'Amica Models & Co.',
|
||||
'Alpha Cognac',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,29 +27,30 @@ import {
|
||||
PercentCalcType,
|
||||
} from '../../src/Funnel/types';
|
||||
|
||||
describe('Funnel transformProps', () => {
|
||||
const formData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo', 'bar'],
|
||||
};
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
|
||||
],
|
||||
},
|
||||
const formData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo', 'bar'],
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{ foo: 'Sylvester', bar: 1, sum__num: 10 },
|
||||
{ foo: 'Arnold', bar: 2, sum__num: 2.5 },
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
},
|
||||
];
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
describe('Funnel transformProps', () => {
|
||||
it('should transform chart props for viz', () => {
|
||||
expect(transformProps(chartProps as EchartsFunnelChartProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -123,3 +124,49 @@ describe('formatFunnelLabel', () => {
|
||||
).toEqual(['<NULL>', '1.23k', '12.34%']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const legendQueriesData = [
|
||||
{
|
||||
data: [
|
||||
{ foo: 'Sylvester', sum__num: 10 },
|
||||
{ foo: 'Arnold', sum__num: 2.5 },
|
||||
{ foo: 'Mark', sum__num: 13 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const createChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartProps,
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: ['foo'],
|
||||
...overrides,
|
||||
},
|
||||
queriesData: legendQueriesData,
|
||||
});
|
||||
|
||||
it('preserves original data order when no sort specified', () => {
|
||||
const props = createChartProps({ legendSort: null });
|
||||
const result = transformProps(props as EchartsFunnelChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['Sylvester', 'Arnold', 'Mark']);
|
||||
});
|
||||
|
||||
it('sorts alphabetically ascending when legendSort is "asc"', () => {
|
||||
const props = createChartProps({ legendSort: 'asc' });
|
||||
const result = transformProps(props as EchartsFunnelChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['Arnold', 'Mark', 'Sylvester']);
|
||||
});
|
||||
|
||||
it('sorts alphabetically descending when legendSort is "desc"', () => {
|
||||
const props = createChartProps({ legendSort: 'desc' });
|
||||
const result = transformProps(props as EchartsFunnelChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['Sylvester', 'Mark', 'Arnold']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,63 +27,64 @@ import {
|
||||
EchartsGanttFormData,
|
||||
} from '../../src/Gantt/types';
|
||||
|
||||
const formData: EchartsGanttFormData = {
|
||||
viz_type: 'gantt_chart',
|
||||
datasource: '1__table',
|
||||
|
||||
startTime: 'startTime',
|
||||
endTime: 'endTime',
|
||||
yAxis: {
|
||||
label: 'Y Axis',
|
||||
sqlExpression: 'y_axis',
|
||||
expressionType: 'SQL',
|
||||
},
|
||||
tooltipMetrics: ['tooltip_metric'],
|
||||
tooltipColumns: ['tooltip_column'],
|
||||
series: 'series',
|
||||
xAxisTimeFormat: '%H:%M',
|
||||
tooltipTimeFormat: '%H:%M',
|
||||
tooltipValuesFormat: 'DURATION_SEC',
|
||||
colorScheme: 'bnbColors',
|
||||
zoomable: true,
|
||||
xAxisTitleMargin: undefined,
|
||||
yAxisTitleMargin: undefined,
|
||||
xAxisTimeBounds: [null, '19:00:00'],
|
||||
subcategories: true,
|
||||
legendMargin: 0,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Scroll,
|
||||
showLegend: true,
|
||||
sortSeriesAscending: true,
|
||||
legendSort: null,
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
startTime: Date.UTC(2025, 1, 1, 13, 0, 0),
|
||||
endTime: Date.UTC(2025, 1, 1, 14, 0, 0),
|
||||
'Y Axis': 'first',
|
||||
tooltip_column: 'tooltip value 1',
|
||||
series: 'series value 1',
|
||||
},
|
||||
{
|
||||
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
|
||||
endTime: Date.UTC(2025, 1, 1, 20, 0, 0),
|
||||
'Y Axis': 'second',
|
||||
tooltip_column: 'tooltip value 2',
|
||||
series: 'series value 2',
|
||||
},
|
||||
],
|
||||
colnames: ['startTime', 'endTime', 'Y Axis', 'tooltip_column', 'series'],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('Gantt transformProps', () => {
|
||||
const formData: EchartsGanttFormData = {
|
||||
viz_type: 'gantt_chart',
|
||||
datasource: '1__table',
|
||||
|
||||
startTime: 'startTime',
|
||||
endTime: 'endTime',
|
||||
yAxis: {
|
||||
label: 'Y Axis',
|
||||
sqlExpression: 'y_axis',
|
||||
expressionType: 'SQL',
|
||||
},
|
||||
tooltipMetrics: ['tooltip_metric'],
|
||||
tooltipColumns: ['tooltip_column'],
|
||||
series: 'series',
|
||||
xAxisTimeFormat: '%H:%M',
|
||||
tooltipTimeFormat: '%H:%M',
|
||||
tooltipValuesFormat: 'DURATION_SEC',
|
||||
colorScheme: 'bnbColors',
|
||||
zoomable: true,
|
||||
xAxisTitleMargin: undefined,
|
||||
yAxisTitleMargin: undefined,
|
||||
xAxisTimeBounds: [null, '19:00:00'],
|
||||
subcategories: true,
|
||||
legendMargin: 0,
|
||||
legendOrientation: LegendOrientation.Top,
|
||||
legendType: LegendType.Scroll,
|
||||
showLegend: true,
|
||||
sortSeriesAscending: true,
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
startTime: Date.UTC(2025, 1, 1, 13, 0, 0),
|
||||
endTime: Date.UTC(2025, 1, 1, 14, 0, 0),
|
||||
'Y Axis': 'first',
|
||||
tooltip_column: 'tooltip value 1',
|
||||
series: 'series value 1',
|
||||
},
|
||||
{
|
||||
startTime: Date.UTC(2025, 1, 1, 18, 0, 0),
|
||||
endTime: Date.UTC(2025, 1, 1, 20, 0, 0),
|
||||
'Y Axis': 'second',
|
||||
tooltip_column: 'tooltip value 2',
|
||||
series: 'series value 2',
|
||||
},
|
||||
],
|
||||
colnames: ['startTime', 'endTime', 'Y Axis', 'tooltip_column', 'series'],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
it('should transform chart props', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
const transformedProps = transformProps(
|
||||
@@ -267,3 +268,38 @@ describe('Gantt transformProps', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const createChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
|
||||
it('preserves original data order when no sort specified', () => {
|
||||
const props = createChartProps({ legendSort: null });
|
||||
const result = transformProps(props as EchartsGanttChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['series value 1', 'series value 2']);
|
||||
});
|
||||
|
||||
it('sorts alphabetically ascending when legendSort is "asc"', () => {
|
||||
const props = createChartProps({ legendSort: 'asc' });
|
||||
const result = transformProps(props as EchartsGanttChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['series value 1', 'series value 2']);
|
||||
});
|
||||
|
||||
it('sorts alphabetically descending when legendSort is "desc"', () => {
|
||||
const props = createChartProps({ legendSort: 'desc' });
|
||||
const result = transformProps(props as EchartsGanttChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual(['series value 2', 'series value 1']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,43 +21,43 @@ import transformProps from '../../src/Graph/transformProps';
|
||||
import { DEFAULT_GRAPH_SERIES_OPTION } from '../../src/Graph/constants';
|
||||
import { EchartsGraphChartProps } from '../../src/Graph/types';
|
||||
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'count',
|
||||
source: 'source_column',
|
||||
target: 'target_column',
|
||||
category: null,
|
||||
viz_type: 'graph',
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
colnames: ['source_column', 'target_column', 'count'],
|
||||
data: [
|
||||
{
|
||||
source_column: 'source_value_1',
|
||||
target_column: 'target_value_1',
|
||||
count: 6,
|
||||
},
|
||||
{
|
||||
source_column: 'source_value_2',
|
||||
target_column: 'target_value_2',
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('EchartsGraph transformProps', () => {
|
||||
it('should transform chart props for viz without category', () => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'count',
|
||||
source: 'source_column',
|
||||
target: 'target_column',
|
||||
category: null,
|
||||
viz_type: 'graph',
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
colnames: ['source_column', 'target_column', 'count'],
|
||||
data: [
|
||||
{
|
||||
source_column: 'source_value_1',
|
||||
target_column: 'target_value_1',
|
||||
count: 6,
|
||||
},
|
||||
{
|
||||
source_column: 'source_value_2',
|
||||
target_column: 'target_value_2',
|
||||
count: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
expect(transformProps(chartProps as EchartsGraphChartProps)).toEqual(
|
||||
expect.objectContaining({
|
||||
@@ -263,3 +263,91 @@ describe('EchartsGraph transformProps', () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const queriesData = [
|
||||
{
|
||||
colnames: [
|
||||
'source_column',
|
||||
'target_column',
|
||||
'source_category_column',
|
||||
'target_category_column',
|
||||
'count',
|
||||
],
|
||||
data: [
|
||||
{
|
||||
source_column: 'source_value',
|
||||
target_column: 'target_value',
|
||||
source_category_column: 'category_value_1',
|
||||
target_category_column: 'category_value_3',
|
||||
count: 6,
|
||||
},
|
||||
{
|
||||
source_column: 'source_value',
|
||||
target_column: 'target_value',
|
||||
source_category_column: 'category_value_3',
|
||||
target_category_column: 'category_value_2',
|
||||
count: 5,
|
||||
},
|
||||
{
|
||||
source_column: 'source_value',
|
||||
target_column: 'target_value',
|
||||
source_category_column: 'category_value_2',
|
||||
target_category_column: 'category_value_1',
|
||||
count: 4,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
...overrides,
|
||||
sourceCategory: 'source_category_column',
|
||||
targetCategory: 'target_category_column',
|
||||
},
|
||||
queriesData,
|
||||
});
|
||||
|
||||
it('sort legend by data', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: null,
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsGraphChartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'category_value_1',
|
||||
'category_value_3',
|
||||
'category_value_2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label ascending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'asc',
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsGraphChartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'category_value_1',
|
||||
'category_value_2',
|
||||
'category_value_3',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label descending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'desc',
|
||||
});
|
||||
const transformed = transformProps(chartProps as EchartsGraphChartProps);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'category_value_3',
|
||||
'category_value_2',
|
||||
'category_value_1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ const formData: EchartsMixedTimeseriesFormData = {
|
||||
forecastPeriods: [],
|
||||
forecastInterval: 0,
|
||||
forecastSeasonalityDaily: 0,
|
||||
legendSort: null,
|
||||
};
|
||||
|
||||
const queriesData = [
|
||||
@@ -137,6 +138,24 @@ it('should transform chart props for viz with showQueryIdentifiers=false', () =>
|
||||
expect(seriesIds).not.toContain('sum__num (Query A), boy');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), girl');
|
||||
expect(seriesIds).not.toContain('sum__num (Query B), boy');
|
||||
|
||||
// Check that series name include query identifiers
|
||||
const seriesName = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.name,
|
||||
);
|
||||
expect(seriesName).toContain('sum__num, girl');
|
||||
expect(seriesName).toContain('sum__num, boy');
|
||||
expect(seriesName).not.toContain('sum__num (Query A), girl');
|
||||
expect(seriesName).not.toContain('sum__num (Query A), boy');
|
||||
expect(seriesName).not.toContain('sum__num (Query B), girl');
|
||||
expect(seriesName).not.toContain('sum__num (Query B), boy');
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num, girl',
|
||||
'sum__num, boy',
|
||||
'sum__num, girl',
|
||||
'sum__num, boy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should transform chart props for viz with showQueryIdentifiers=true', () => {
|
||||
@@ -160,4 +179,145 @@ it('should transform chart props for viz with showQueryIdentifiers=true', () =>
|
||||
expect(seriesIds).toContain('sum__num (Query B), boy');
|
||||
expect(seriesIds).not.toContain('sum__num, girl');
|
||||
expect(seriesIds).not.toContain('sum__num, boy');
|
||||
|
||||
// Check that series name include query identifiers
|
||||
const seriesName = (transformed.echartOptions.series as any[]).map(
|
||||
(s: any) => s.name,
|
||||
);
|
||||
expect(seriesName).toContain('sum__num (Query A), girl');
|
||||
expect(seriesName).toContain('sum__num (Query A), boy');
|
||||
expect(seriesName).toContain('sum__num (Query B), girl');
|
||||
expect(seriesName).toContain('sum__num (Query B), boy');
|
||||
expect(seriesName).not.toContain('sum__num, girl');
|
||||
expect(seriesName).not.toContain('sum__num, boy');
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query A), girl',
|
||||
'sum__num (Query A), boy',
|
||||
'sum__num (Query B), girl',
|
||||
'sum__num (Query B), boy',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const getChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
...overrides,
|
||||
showQueryIdentifiers: true,
|
||||
},
|
||||
});
|
||||
|
||||
it('sort legend by data', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: null,
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query A), girl',
|
||||
'sum__num (Query A), boy',
|
||||
'sum__num (Query B), girl',
|
||||
'sum__num (Query B), boy',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label ascending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'asc',
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query A), boy',
|
||||
'sum__num (Query A), girl',
|
||||
'sum__num (Query B), boy',
|
||||
'sum__num (Query B), girl',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label descending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'desc',
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsMixedTimeseriesProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'sum__num (Query B), girl',
|
||||
'sum__num (Query B), boy',
|
||||
'sum__num (Query A), girl',
|
||||
'sum__num (Query A), boy',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('legend margin: top orientation sets grid.top correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).top).toEqual(270);
|
||||
});
|
||||
|
||||
it('legend margin: bottom orientation sets grid.bottom correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Bottom,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).bottom).toEqual(270);
|
||||
});
|
||||
|
||||
it('legend margin: left orientation sets grid.left correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 250,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Left,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).left).toEqual(270);
|
||||
});
|
||||
|
||||
it('legend margin: right orientation sets grid.right correctly', () => {
|
||||
const chartPropsConfigWithoutIdentifiers = {
|
||||
...chartPropsConfig,
|
||||
formData: {
|
||||
...formData,
|
||||
legendMargin: 270,
|
||||
showLegend: true,
|
||||
legendOrientation: LegendOrientation.Right,
|
||||
},
|
||||
};
|
||||
const chartProps = new ChartProps(chartPropsConfigWithoutIdentifiers);
|
||||
const transformed = transformProps(chartProps as EchartsMixedTimeseriesProps);
|
||||
|
||||
expect((transformed.echartOptions.grid as any).right).toEqual(270);
|
||||
});
|
||||
|
||||
@@ -446,7 +446,7 @@ describe('Other category', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sort Legend', () => {
|
||||
describe('legend sorting', () => {
|
||||
const defaultFormData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
|
||||
@@ -42,54 +42,56 @@ interface RadarSeriesData {
|
||||
name: string;
|
||||
}
|
||||
|
||||
describe('Radar transformProps', () => {
|
||||
const formData: Partial<EchartsRadarFormData> = {
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
columnConfig: {
|
||||
'MAX(na_sales)': {
|
||||
radarMetricMaxValue: null,
|
||||
radarMetricMinValue: 0,
|
||||
},
|
||||
'SUM(eu_sales)': {
|
||||
radarMetricMaxValue: 5000,
|
||||
},
|
||||
const formData: Partial<EchartsRadarFormData> = {
|
||||
colorScheme: 'supersetColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
columnConfig: {
|
||||
'MAX(na_sales)': {
|
||||
radarMetricMaxValue: null,
|
||||
radarMetricMinValue: 0,
|
||||
},
|
||||
groupby: [],
|
||||
metrics: [
|
||||
'MAX(na_sales)',
|
||||
'SUM(jp_sales)',
|
||||
'SUM(other_sales)',
|
||||
'SUM(eu_sales)',
|
||||
],
|
||||
viz_type: 'radar',
|
||||
numberFormat: 'SMART_NUMBER',
|
||||
dateFormat: 'smart_date',
|
||||
showLegend: true,
|
||||
showLabels: true,
|
||||
isCircle: false,
|
||||
};
|
||||
'SUM(eu_sales)': {
|
||||
radarMetricMaxValue: 5000,
|
||||
},
|
||||
},
|
||||
groupby: [],
|
||||
metrics: [
|
||||
'MAX(na_sales)',
|
||||
'SUM(jp_sales)',
|
||||
'SUM(other_sales)',
|
||||
'SUM(eu_sales)',
|
||||
],
|
||||
viz_type: 'radar',
|
||||
numberFormat: 'SMART_NUMBER',
|
||||
dateFormat: 'smart_date',
|
||||
showLegend: true,
|
||||
showLabels: true,
|
||||
isCircle: false,
|
||||
};
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
'MAX(na_sales)': 41.49,
|
||||
'SUM(jp_sales)': 1290.99,
|
||||
'SUM(other_sales)': 797.73,
|
||||
'SUM(eu_sales)': 2434.13,
|
||||
},
|
||||
],
|
||||
'MAX(na_sales)': 41.49,
|
||||
'SUM(jp_sales)': 1290.99,
|
||||
'SUM(other_sales)': 797.73,
|
||||
'SUM(eu_sales)': 2434.13,
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
},
|
||||
];
|
||||
|
||||
const chartProps = new ChartProps({
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
describe('Radar transformProps', () => {
|
||||
it('should transform chart props for normalized radar chart & normalize all metrics except the ones with custom min & max', () => {
|
||||
const transformedProps = transformProps(
|
||||
chartProps as EchartsRadarChartProps,
|
||||
@@ -125,3 +127,77 @@ describe('Radar transformProps', () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const legendSortData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
name: 'Sylvester sales',
|
||||
'SUM(jp_sales)': 1290.99,
|
||||
'SUM(other_sales)': 797.73,
|
||||
'SUM(eu_sales)': 2434.13,
|
||||
},
|
||||
{
|
||||
name: 'Arnold sales',
|
||||
'SUM(jp_sales)': 290.99,
|
||||
'SUM(other_sales)': 627.73,
|
||||
'SUM(eu_sales)': 434.13,
|
||||
},
|
||||
{
|
||||
name: 'Mark sales',
|
||||
'SUM(jp_sales)': 2290.99,
|
||||
'SUM(other_sales)': 1297.73,
|
||||
'SUM(eu_sales)': 934.13,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
const createChartProps = (overrides = {}) =>
|
||||
new ChartProps({
|
||||
...chartProps,
|
||||
formData: {
|
||||
...formData,
|
||||
groupby: ['name'],
|
||||
metrics: ['SUM(jp_sales)', 'SUM(other_sales)', 'SUM(eu_sales)'],
|
||||
...overrides,
|
||||
},
|
||||
queriesData: legendSortData,
|
||||
});
|
||||
|
||||
it('preserves original data order when no sort specified', () => {
|
||||
const props = createChartProps({ legendSort: null });
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'Sylvester sales',
|
||||
'Arnold sales',
|
||||
'Mark sales',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically ascending when legendSort is "asc"', () => {
|
||||
const props = createChartProps({ legendSort: 'asc' });
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'Arnold sales',
|
||||
'Mark sales',
|
||||
'Sylvester sales',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sorts alphabetically descending when legendSort is "desc"', () => {
|
||||
const props = createChartProps({ legendSort: 'desc' });
|
||||
const result = transformProps(props as EchartsRadarChartProps);
|
||||
|
||||
const legendData = (result.echartOptions.legend as any).data;
|
||||
expect(legendData).toEqual([
|
||||
'Sylvester sales',
|
||||
'Mark sales',
|
||||
'Arnold sales',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,31 +31,31 @@ import {
|
||||
import { EchartsTimeseriesChartProps } from '../../src/types';
|
||||
import transformProps from '../../src/Timeseries/transformProps';
|
||||
|
||||
describe('EchartsTimeseries transformProps', () => {
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo', 'bar'],
|
||||
viz_type: 'my_viz',
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
|
||||
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
const formData: SqlaFormData = {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['foo', 'bar'],
|
||||
viz_type: 'my_viz',
|
||||
};
|
||||
const queriesData = [
|
||||
{
|
||||
data: [
|
||||
{ 'San Francisco': 1, 'New York': 2, __timestamp: 599616000000 },
|
||||
{ 'San Francisco': 3, 'New York': 4, __timestamp: 599916000000 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const chartPropsConfig = {
|
||||
formData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData,
|
||||
theme: supersetTheme,
|
||||
};
|
||||
|
||||
describe('EchartsTimeseries transformProps', () => {
|
||||
it('should transform chart props for viz', () => {
|
||||
const chartProps = new ChartProps(chartPropsConfig);
|
||||
expect(transformProps(chartProps as EchartsTimeseriesChartProps)).toEqual(
|
||||
@@ -625,3 +625,101 @@ describe('Does transformProps transform series correctly', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('legend sorting', () => {
|
||||
const legendSortData = [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
Milton: 40,
|
||||
'San Francisco': 1,
|
||||
'New York': 2,
|
||||
Boston: 1,
|
||||
__timestamp: 599616000000,
|
||||
},
|
||||
{
|
||||
Milton: 20,
|
||||
'San Francisco': 3,
|
||||
'New York': 4,
|
||||
Boston: 1,
|
||||
__timestamp: 599916000000,
|
||||
},
|
||||
{
|
||||
Milton: 60,
|
||||
'San Francisco': 5,
|
||||
'New York': 8,
|
||||
Boston: 6,
|
||||
__timestamp: 600216000000,
|
||||
},
|
||||
{
|
||||
Milton: 10,
|
||||
'San Francisco': 2,
|
||||
'New York': 7,
|
||||
Boston: 2,
|
||||
__timestamp: 600516000000,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const getChartProps = (formData: Partial<SqlaFormData>) =>
|
||||
new ChartProps({
|
||||
...chartPropsConfig,
|
||||
formData: { ...formData },
|
||||
queriesData: legendSortData,
|
||||
});
|
||||
|
||||
it('sort legend by data', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: null,
|
||||
sortSeriesType: 'min',
|
||||
sortSeriesAscending: true,
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'San Francisco',
|
||||
'Boston',
|
||||
'New York',
|
||||
'Milton',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label ascending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'asc',
|
||||
sortSeriesType: 'min',
|
||||
sortSeriesAscending: true,
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'Boston',
|
||||
'Milton',
|
||||
'New York',
|
||||
'San Francisco',
|
||||
]);
|
||||
});
|
||||
|
||||
it('sort legend by label descending', () => {
|
||||
const chartProps = getChartProps({
|
||||
legendSort: 'desc',
|
||||
sortSeriesType: 'min',
|
||||
sortSeriesAscending: true,
|
||||
});
|
||||
const transformed = transformProps(
|
||||
chartProps as EchartsTimeseriesChartProps,
|
||||
);
|
||||
|
||||
expect((transformed.echartOptions.legend as any).data).toEqual([
|
||||
'San Francisco',
|
||||
'New York',
|
||||
'Milton',
|
||||
'Boston',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -80,6 +80,11 @@ import {
|
||||
getDynamicLabelsColors,
|
||||
} from '../../utils/colorScheme';
|
||||
|
||||
export const TOGGLE_NATIVE_FILTERS_BAR = 'TOGGLE_NATIVE_FILTERS_BAR';
|
||||
export function toggleNativeFiltersBar(isOpen) {
|
||||
return { type: TOGGLE_NATIVE_FILTERS_BAR, isOpen };
|
||||
}
|
||||
|
||||
export const SET_UNSAVED_CHANGES = 'SET_UNSAVED_CHANGES';
|
||||
export function setUnsavedChanges(hasUnsavedChanges) {
|
||||
return { type: SET_UNSAVED_CHANGES, payload: { hasUnsavedChanges } };
|
||||
|
||||
@@ -25,7 +25,14 @@ jest.mock('src/dashboard/containers/SliceAdder', () => () => (
|
||||
));
|
||||
|
||||
test('BuilderComponentPane has correct tabs in correct order', () => {
|
||||
render(<BuilderComponentPane topOffset={115} />);
|
||||
render(<BuilderComponentPane topOffset={115} />, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
dashboardState: {
|
||||
nativeFiltersBarOpen: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
const tabs = screen.getAllByRole('tab');
|
||||
expect(tabs).toHaveLength(2);
|
||||
expect(tabs[0]).toHaveTextContent('Charts');
|
||||
|
||||
@@ -19,9 +19,11 @@
|
||||
/* eslint-env browser */
|
||||
import { rgba } from 'emotion-rgba';
|
||||
import Tabs from '@superset-ui/core/components/Tabs';
|
||||
import { t, css, SupersetTheme } from '@superset-ui/core';
|
||||
import { t, css, SupersetTheme, useTheme } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import SliceAdder from 'src/dashboard/containers/SliceAdder';
|
||||
import dashboardComponents from 'src/visualizations/presets/dashboardComponents';
|
||||
import { useMemo } from 'react';
|
||||
import NewColumn from '../gridComponents/new/NewColumn';
|
||||
import NewDivider from '../gridComponents/new/NewDivider';
|
||||
import NewHeader from '../gridComponents/new/NewHeader';
|
||||
@@ -37,81 +39,97 @@ const TABS_KEYS = {
|
||||
LAYOUT_ELEMENTS: 'LAYOUT_ELEMENTS',
|
||||
};
|
||||
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => (
|
||||
<div
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
right: 0;
|
||||
top: ${topOffset}px;
|
||||
height: calc(100vh - ${topOffset}px);
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
`}
|
||||
>
|
||||
const BuilderComponentPane = ({ topOffset = 0 }) => {
|
||||
const theme = useTheme();
|
||||
const nativeFiltersBarOpen = useSelector(
|
||||
(state: any) => state.dashboardState.nativeFiltersBarOpen ?? false,
|
||||
);
|
||||
|
||||
const tabBarStyle = useMemo(
|
||||
() => ({
|
||||
paddingLeft: nativeFiltersBarOpen ? 0 : theme.sizeUnit * 4,
|
||||
}),
|
||||
[nativeFiltersBarOpen, theme.sizeUnit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
data-test="dashboard-builder-sidepane"
|
||||
css={css`
|
||||
position: sticky;
|
||||
right: 0;
|
||||
top: ${topOffset}px;
|
||||
height: calc(100vh - ${topOffset}px);
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
box-shadow: -4px 0 4px 0 ${rgba(theme.colorBorder, 0.1)};
|
||||
background-color: ${theme.colorBgBase};
|
||||
`}
|
||||
>
|
||||
<Tabs
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
id="tabs"
|
||||
<div
|
||||
css={(theme: SupersetTheme) => css`
|
||||
line-height: inherit;
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
|
||||
& .ant-tabs-content-holder {
|
||||
height: 100%;
|
||||
& .ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
width: ${BUILDER_PANE_WIDTH}px;
|
||||
box-shadow: -4px 0 4px 0 ${rgba(theme.colorBorder, 0.1)};
|
||||
background-color: ${theme.colorBgBase};
|
||||
`}
|
||||
items={[
|
||||
{
|
||||
key: TABS_KEYS.CHARTS,
|
||||
label: t('Charts'),
|
||||
children: (
|
||||
<div
|
||||
css={css`
|
||||
height: calc(100vh - ${topOffset * 2}px);
|
||||
`}
|
||||
>
|
||||
<SliceAdder />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TABS_KEYS.LAYOUT_ELEMENTS,
|
||||
label: t('Layout elements'),
|
||||
children: (
|
||||
<>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
{dashboardComponents
|
||||
.getAll()
|
||||
.map(({ key: componentKey, metadata }) => (
|
||||
<NewDynamicComponent
|
||||
metadata={metadata}
|
||||
componentKey={componentKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
>
|
||||
<Tabs
|
||||
data-test="dashboard-builder-component-pane-tabs-navigation"
|
||||
id="tabs"
|
||||
tabBarStyle={tabBarStyle}
|
||||
css={(theme: SupersetTheme) => css`
|
||||
line-height: inherit;
|
||||
margin-top: ${theme.sizeUnit * 2}px;
|
||||
height: 100%;
|
||||
|
||||
& .ant-tabs-content-holder {
|
||||
height: 100%;
|
||||
& .ant-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
`}
|
||||
items={[
|
||||
{
|
||||
key: TABS_KEYS.CHARTS,
|
||||
label: t('Charts'),
|
||||
children: (
|
||||
<div
|
||||
css={css`
|
||||
height: calc(100vh - ${topOffset * 2}px);
|
||||
`}
|
||||
>
|
||||
<SliceAdder />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: TABS_KEYS.LAYOUT_ELEMENTS,
|
||||
label: t('Layout elements'),
|
||||
children: (
|
||||
<>
|
||||
<NewTabs />
|
||||
<NewRow />
|
||||
<NewColumn />
|
||||
<NewHeader />
|
||||
<NewMarkdown />
|
||||
<NewDivider />
|
||||
{dashboardComponents
|
||||
.getAll()
|
||||
.map(({ key: componentKey, metadata }) => (
|
||||
<NewDynamicComponent
|
||||
key={componentKey}
|
||||
metadata={metadata}
|
||||
componentKey={componentKey}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderComponentPane;
|
||||
|
||||
@@ -80,6 +80,7 @@ import DashboardWrapper from './DashboardWrapper';
|
||||
|
||||
// @z-index-above-dashboard-charts + 1 = 11
|
||||
const FiltersPanel = styled.div<{ width: number; hidden: boolean }>`
|
||||
background-color: ${({ theme }) => theme.colorBgContainer};
|
||||
grid-column: 1;
|
||||
grid-row: 1 / span 2;
|
||||
z-index: 11;
|
||||
@@ -275,6 +276,7 @@ const StyledDashboardContent = styled.div<{
|
||||
marginLeft: number;
|
||||
}>`
|
||||
${({ theme, editMode, marginLeft }) => css`
|
||||
background-color: ${theme.colorBgLayout};
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
@@ -291,9 +293,7 @@ const StyledDashboardContent = styled.div<{
|
||||
width: 0;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
margin-top: ${theme.sizeUnit * 4}px;
|
||||
margin-right: ${theme.sizeUnit * 8}px;
|
||||
margin-bottom: ${theme.sizeUnit * 4}px;
|
||||
margin: ${theme.sizeUnit * 4}px;
|
||||
margin-left: ${marginLeft}px;
|
||||
|
||||
${editMode &&
|
||||
@@ -557,13 +557,9 @@ const DashboardBuilder = () => {
|
||||
],
|
||||
);
|
||||
|
||||
const dashboardContentMarginLeft =
|
||||
!dashboardFiltersOpen &&
|
||||
!editMode &&
|
||||
nativeFiltersEnabled &&
|
||||
filterBarOrientation !== FilterBarOrientation.Horizontal
|
||||
? 0
|
||||
: theme.sizeUnit * 8;
|
||||
const dashboardContentMarginLeft = !editMode
|
||||
? theme.sizeUnit * 4
|
||||
: theme.sizeUnit * 8;
|
||||
|
||||
const renderChild = useCallback(
|
||||
adjustedWidth => {
|
||||
|
||||
@@ -70,13 +70,12 @@ type DashboardContainerProps = {
|
||||
topLevelTabs?: LayoutItem;
|
||||
};
|
||||
|
||||
export const renderedChartIdsSelector = createSelector(
|
||||
[(state: RootState) => state.charts],
|
||||
charts =>
|
||||
export const renderedChartIdsSelector: (state: RootState) => number[] =
|
||||
createSelector([(state: RootState) => state.charts], charts =>
|
||||
Object.values(charts)
|
||||
.filter(chart => chart.chartStatus === 'rendered')
|
||||
.map(chart => chart.id),
|
||||
);
|
||||
);
|
||||
|
||||
const useRenderedChartIds = () => {
|
||||
const renderedChartIds = useSelector<RootState, number[]>(
|
||||
@@ -297,6 +296,7 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
allowOverflow
|
||||
onFocus={handleFocus}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ paddingLeft: 0 }}
|
||||
/>
|
||||
);
|
||||
},
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { FC, useEffect, useState } from 'react';
|
||||
import { FC, PropsWithChildren, useEffect, useState } from 'react';
|
||||
|
||||
import { css, styled } from '@superset-ui/core';
|
||||
import { Constants } from '@superset-ui/core/components';
|
||||
@@ -113,9 +113,7 @@ const StyledDiv = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
type Props = {};
|
||||
|
||||
const DashboardWrapper: FC<Props> = ({ children }) => {
|
||||
const DashboardWrapper: FC<PropsWithChildren<{}>> = ({ children }) => {
|
||||
const editMode = useSelector<RootState, boolean>(
|
||||
state => state.dashboardState.editMode,
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { URL_PARAMS } from 'src/constants';
|
||||
import { getUrlParam } from 'src/utils/urlUtils';
|
||||
@@ -26,23 +26,26 @@ import {
|
||||
useFilters,
|
||||
useNativeFiltersDataMask,
|
||||
} from '../nativeFilters/FilterBar/state';
|
||||
import { toggleNativeFiltersBar } from '../../actions/dashboardState';
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export const useNativeFilters = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
|
||||
const showNativeFilters = useSelector<RootState, boolean>(
|
||||
() => getUrlParam(URL_PARAMS.showFilters) ?? true,
|
||||
);
|
||||
const canEdit = useSelector<RootState, boolean>(
|
||||
({ dashboardInfo }) => dashboardInfo.dash_edit_perm,
|
||||
);
|
||||
const dashboardFiltersOpen = useSelector<RootState, boolean>(
|
||||
state => state.dashboardState.nativeFiltersBarOpen ?? false,
|
||||
);
|
||||
|
||||
const filters = useFilters();
|
||||
const filterValues = useMemo(() => Object.values(filters), [filters]);
|
||||
const expandFilters = getUrlParam(URL_PARAMS.expandFilters);
|
||||
const [dashboardFiltersOpen, setDashboardFiltersOpen] = useState(
|
||||
expandFilters ?? !!filterValues.length,
|
||||
);
|
||||
|
||||
const nativeFiltersEnabled =
|
||||
showNativeFilters && (canEdit || (!canEdit && filterValues.length !== 0));
|
||||
@@ -66,9 +69,13 @@ export const useNativeFilters = () => {
|
||||
!nativeFiltersEnabled ||
|
||||
missingInitialFilters.length === 0;
|
||||
|
||||
const toggleDashboardFiltersOpen = useCallback((visible?: boolean) => {
|
||||
setDashboardFiltersOpen(prevState => visible ?? !prevState);
|
||||
}, []);
|
||||
const toggleDashboardFiltersOpen = useCallback(
|
||||
(visible?: boolean) => {
|
||||
const newState = visible ?? !dashboardFiltersOpen;
|
||||
dispatch(toggleNativeFiltersBar(newState));
|
||||
},
|
||||
[dispatch, dashboardFiltersOpen],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -77,11 +84,11 @@ export const useNativeFilters = () => {
|
||||
expandFilters === false ||
|
||||
(filterValues.length === 0 && nativeFiltersEnabled)
|
||||
) {
|
||||
toggleDashboardFiltersOpen(false);
|
||||
dispatch(toggleNativeFiltersBar(false));
|
||||
} else {
|
||||
toggleDashboardFiltersOpen(true);
|
||||
dispatch(toggleNativeFiltersBar(true));
|
||||
}
|
||||
}, [filterValues.length]);
|
||||
}, [dispatch, filterValues.length, expandFilters, nativeFiltersEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showDashboard) {
|
||||
|
||||
@@ -39,26 +39,26 @@ import { URL_PARAMS } from 'src/constants';
|
||||
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
|
||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||
|
||||
import SliceHeader from '../SliceHeader';
|
||||
import MissingChart from '../MissingChart';
|
||||
import SliceHeader from '../../SliceHeader';
|
||||
import MissingChart from '../../MissingChart';
|
||||
import {
|
||||
addDangerToast,
|
||||
addSuccessToast,
|
||||
} from '../../../components/MessageToasts/actions';
|
||||
} from '../../../../components/MessageToasts/actions';
|
||||
import {
|
||||
setFocusedFilterField,
|
||||
toggleExpandSlice,
|
||||
unsetFocusedFilterField,
|
||||
} from '../../actions/dashboardState';
|
||||
import { changeFilter } from '../../actions/dashboardFilters';
|
||||
import { refreshChart } from '../../../components/Chart/chartAction';
|
||||
import { logEvent } from '../../../logger/actions';
|
||||
} from '../../../actions/dashboardState';
|
||||
import { changeFilter } from '../../../actions/dashboardFilters';
|
||||
import { refreshChart } from '../../../../components/Chart/chartAction';
|
||||
import { logEvent } from '../../../../logger/actions';
|
||||
import {
|
||||
getActiveFilters,
|
||||
getAppliedFilterValues,
|
||||
} from '../../util/activeDashboardFilters';
|
||||
import getFormDataWithExtraFilters from '../../util/charts/getFormDataWithExtraFilters';
|
||||
import { PLACEHOLDER_DATASOURCE } from '../../constants';
|
||||
} from '../../../util/activeDashboardFilters';
|
||||
import getFormDataWithExtraFilters from '../../../util/charts/getFormDataWithExtraFilters';
|
||||
import { PLACEHOLDER_DATASOURCE } from '../../../constants';
|
||||
|
||||
const propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
@@ -20,13 +20,13 @@ import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import * as redux from 'redux';
|
||||
|
||||
import Chart from 'src/dashboard/components/gridComponents/Chart';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { sliceEntitiesForChart as sliceEntities } from 'spec/fixtures/mockSliceEntities';
|
||||
import mockDatasource from 'spec/fixtures/mockDatasource';
|
||||
import chartQueries, {
|
||||
sliceId as queryId,
|
||||
} from 'spec/fixtures/mockChartQueries';
|
||||
import Chart from './Chart';
|
||||
|
||||
const props = {
|
||||
id: queryId,
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Chart from './Chart';
|
||||
|
||||
export default Chart;
|
||||
@@ -35,9 +35,13 @@ import { nativeFiltersInfo } from 'src/dashboard/fixtures/mockNativeFilters';
|
||||
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
import { SET_DIRECT_PATH } from 'src/dashboard/actions/dashboardState';
|
||||
import { CHART_TYPE, COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes';
|
||||
import {
|
||||
CHART_TYPE,
|
||||
COLUMN_TYPE,
|
||||
ROW_TYPE,
|
||||
} from '../../../util/componentTypes';
|
||||
import ChartHolder, { CHART_MARGIN } from './ChartHolder';
|
||||
import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../util/constants';
|
||||
import { GRID_BASE_UNIT, GRID_GUTTER_SIZE } from '../../../util/constants';
|
||||
|
||||
const DEFAULT_HEADER_HEIGHT = 22;
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default } from './ChartHolder';
|
||||
@@ -19,12 +19,12 @@
|
||||
import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
|
||||
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
||||
import Column from 'src/dashboard/components/gridComponents/Column';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
|
||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
import Column from './Column';
|
||||
|
||||
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
Draggable: ({ children }) => (
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Column from './Column';
|
||||
|
||||
export default Column;
|
||||
@@ -20,10 +20,10 @@ import { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { css, styled } from '@superset-ui/core';
|
||||
|
||||
import { Draggable } from '../dnd/DragDroppable';
|
||||
import HoverMenu from '../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../DeleteComponentButton';
|
||||
import { componentShape } from '../../util/propShapes';
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import HoverMenu from '../../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||
import { componentShape } from '../../../util/propShapes';
|
||||
|
||||
const propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -18,13 +18,13 @@
|
||||
*/
|
||||
import sinon from 'sinon';
|
||||
|
||||
import Divider from 'src/dashboard/components/gridComponents/Divider';
|
||||
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
|
||||
import {
|
||||
DIVIDER_TYPE,
|
||||
DASHBOARD_GRID_TYPE,
|
||||
} from 'src/dashboard/util/componentTypes';
|
||||
import { screen, render, userEvent } from 'spec/helpers/testing-library';
|
||||
import Divider from './Divider';
|
||||
|
||||
describe('Divider', () => {
|
||||
const props = {
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Divider from './Divider';
|
||||
|
||||
export default Divider;
|
||||
@@ -0,0 +1,329 @@
|
||||
/**
|
||||
* 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, fireEvent } from 'spec/helpers/testing-library';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from 'src/dashboard/util/componentTypes';
|
||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import DynamicComponent from './DynamicComponent';
|
||||
|
||||
// Mock the dashboard components registry
|
||||
const mockComponent = () => (
|
||||
<div data-test="mock-dynamic-component">Test Component</div>
|
||||
);
|
||||
jest.mock('src/visualizations/presets/dashboardComponents', () => ({
|
||||
get: jest.fn(() => ({ Component: mockComponent })),
|
||||
}));
|
||||
|
||||
// Mock other dependencies
|
||||
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
Draggable: jest.fn(({ children, editMode }) => {
|
||||
const mockElement = { tagName: 'DIV', dataset: {} };
|
||||
const mockDragSourceRef = { current: mockElement };
|
||||
return (
|
||||
<div data-test="mock-draggable">
|
||||
{children({ dragSourceRef: editMode ? mockDragSourceRef : null })}
|
||||
</div>
|
||||
);
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/components/menu/WithPopoverMenu', () =>
|
||||
jest.fn(({ children, menuItems, editMode }) => (
|
||||
<div data-test="mock-popover-menu">
|
||||
{editMode &&
|
||||
menuItems &&
|
||||
menuItems.map((item: React.ReactNode, index: number) => (
|
||||
<div key={index} data-test="menu-item">
|
||||
{item}
|
||||
</div>
|
||||
))}
|
||||
{children}
|
||||
</div>
|
||||
)),
|
||||
);
|
||||
|
||||
jest.mock('src/dashboard/components/resizable/ResizableContainer', () =>
|
||||
jest.fn(({ children }) => (
|
||||
<div data-test="mock-resizable-container">{children}</div>
|
||||
)),
|
||||
);
|
||||
|
||||
jest.mock('src/dashboard/components/menu/HoverMenu', () =>
|
||||
jest.fn(({ children }) => <div data-test="mock-hover-menu">{children}</div>),
|
||||
);
|
||||
|
||||
jest.mock('src/dashboard/components/DeleteComponentButton', () =>
|
||||
jest.fn(({ onDelete }) => (
|
||||
<button type="button" data-test="mock-delete-button" onClick={onDelete}>
|
||||
Delete
|
||||
</button>
|
||||
)),
|
||||
);
|
||||
|
||||
jest.mock('src/dashboard/components/menu/BackgroundStyleDropdown', () =>
|
||||
jest.fn(({ onChange, value }) => (
|
||||
<select
|
||||
data-test="mock-background-dropdown"
|
||||
value={value}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
<option value="BACKGROUND_TRANSPARENT">Transparent</option>
|
||||
<option value="BACKGROUND_WHITE">White</option>
|
||||
</select>
|
||||
)),
|
||||
);
|
||||
|
||||
const createProps = (overrides = {}) => ({
|
||||
component: {
|
||||
id: 'DYNAMIC_COMPONENT_1',
|
||||
meta: {
|
||||
componentKey: 'test-component',
|
||||
width: 6,
|
||||
height: 4,
|
||||
background: BACKGROUND_TRANSPARENT,
|
||||
},
|
||||
componentKey: 'test-component',
|
||||
},
|
||||
parentComponent: {
|
||||
id: 'ROW_1',
|
||||
type: ROW_TYPE,
|
||||
meta: {
|
||||
width: 12,
|
||||
},
|
||||
},
|
||||
index: 0,
|
||||
depth: 1,
|
||||
handleComponentDrop: jest.fn(),
|
||||
editMode: false,
|
||||
columnWidth: 100,
|
||||
availableColumnCount: 12,
|
||||
onResizeStart: jest.fn(),
|
||||
onResizeStop: jest.fn(),
|
||||
onResize: jest.fn(),
|
||||
deleteComponent: jest.fn(),
|
||||
updateComponents: jest.fn(),
|
||||
parentId: 'ROW_1',
|
||||
id: 'DYNAMIC_COMPONENT_1',
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const renderWithRedux = (component: React.ReactElement) =>
|
||||
render(component, {
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
nativeFilters: { filters: {} },
|
||||
dataMask: {},
|
||||
},
|
||||
});
|
||||
|
||||
describe('DynamicComponent', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render the component with basic structure', () => {
|
||||
const props = createProps();
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
expect(screen.getByTestId('mock-draggable')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-popover-menu')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('dashboard-component-chart-holder'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-dynamic-component')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with proper CSS classes and data attributes', () => {
|
||||
const props = createProps();
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
const componentElement = screen.getByTestId('dashboard-test-component');
|
||||
expect(componentElement).toHaveClass('dashboard-component');
|
||||
expect(componentElement).toHaveClass('dashboard-test-component');
|
||||
expect(componentElement).toHaveAttribute('id', 'DYNAMIC_COMPONENT_1');
|
||||
});
|
||||
|
||||
test('should render HoverMenu and DeleteComponentButton in edit mode', () => {
|
||||
const props = createProps({ editMode: true });
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
expect(screen.getByTestId('mock-hover-menu')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('mock-delete-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not render HoverMenu and DeleteComponentButton when not in edit mode', () => {
|
||||
const props = createProps({ editMode: false });
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
expect(screen.queryByTestId('mock-hover-menu')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('mock-delete-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call deleteComponent when delete button is clicked', () => {
|
||||
const props = createProps({ editMode: true });
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('mock-delete-button'));
|
||||
expect(props.deleteComponent).toHaveBeenCalledWith(
|
||||
'DYNAMIC_COMPONENT_1',
|
||||
'ROW_1',
|
||||
);
|
||||
});
|
||||
|
||||
test('should call updateComponents when background is changed', () => {
|
||||
const props = createProps({ editMode: true });
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
const backgroundDropdown = screen.getByTestId('mock-background-dropdown');
|
||||
fireEvent.change(backgroundDropdown, {
|
||||
target: { value: 'BACKGROUND_WHITE' },
|
||||
});
|
||||
|
||||
expect(props.updateComponents).toHaveBeenCalledWith({
|
||||
DYNAMIC_COMPONENT_1: {
|
||||
...props.component,
|
||||
meta: {
|
||||
...props.component.meta,
|
||||
background: 'BACKGROUND_WHITE',
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test('should calculate width multiple from component meta when parent is not COLUMN_TYPE', () => {
|
||||
const props = createProps({
|
||||
component: {
|
||||
...createProps().component,
|
||||
meta: { ...createProps().component.meta, width: 8 },
|
||||
},
|
||||
parentComponent: {
|
||||
...createProps().parentComponent,
|
||||
type: ROW_TYPE,
|
||||
},
|
||||
});
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
// Component should render successfully with width from component.meta.width
|
||||
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should calculate width multiple from parent meta when parent is COLUMN_TYPE', () => {
|
||||
const props = createProps({
|
||||
parentComponent: {
|
||||
id: 'COLUMN_1',
|
||||
type: COLUMN_TYPE,
|
||||
meta: {
|
||||
width: 6,
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
// Component should render successfully with width from parentComponent.meta.width
|
||||
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should use default width when no width is specified', () => {
|
||||
const props = createProps({
|
||||
component: {
|
||||
...createProps().component,
|
||||
meta: {
|
||||
...createProps().component.meta,
|
||||
width: undefined,
|
||||
},
|
||||
},
|
||||
parentComponent: {
|
||||
...createProps().parentComponent,
|
||||
type: ROW_TYPE,
|
||||
meta: {},
|
||||
},
|
||||
});
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
// Component should render successfully with default width (GRID_MIN_COLUMN_COUNT)
|
||||
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render background style correctly', () => {
|
||||
const props = createProps({
|
||||
editMode: true, // Need edit mode for menu items to render
|
||||
component: {
|
||||
...createProps().component,
|
||||
meta: {
|
||||
...createProps().component.meta,
|
||||
background: 'BACKGROUND_WHITE',
|
||||
},
|
||||
},
|
||||
});
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
// Background dropdown should have the correct value
|
||||
const backgroundDropdown = screen.getByTestId('mock-background-dropdown');
|
||||
expect(backgroundDropdown).toHaveValue('BACKGROUND_WHITE');
|
||||
});
|
||||
|
||||
test('should pass dashboard data from Redux store to dynamic component', () => {
|
||||
const props = createProps();
|
||||
const initialState = {
|
||||
nativeFilters: { filters: { filter1: {} } },
|
||||
dataMask: { mask1: {} },
|
||||
};
|
||||
|
||||
render(<DynamicComponent {...props} />, {
|
||||
useRedux: true,
|
||||
initialState,
|
||||
});
|
||||
|
||||
// Component should render - either the mock component or loading state
|
||||
const container = screen.getByTestId('dashboard-component-chart-holder');
|
||||
expect(container).toBeInTheDocument();
|
||||
// Check that either the component loaded or is loading
|
||||
expect(
|
||||
screen.queryByTestId('mock-dynamic-component') ||
|
||||
screen.queryByText('Loading...'),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
test('should handle resize callbacks', () => {
|
||||
const props = createProps();
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
// Resize callbacks should be passed to ResizableContainer
|
||||
expect(screen.getByTestId('mock-resizable-container')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render with proper data-test attribute based on componentKey', () => {
|
||||
const props = createProps({
|
||||
component: {
|
||||
...createProps().component,
|
||||
meta: {
|
||||
...createProps().component.meta,
|
||||
componentKey: 'custom-component',
|
||||
},
|
||||
componentKey: 'custom-component',
|
||||
},
|
||||
});
|
||||
renderWithRedux(<DynamicComponent {...props} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('dashboard-custom-component'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -22,40 +22,40 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import cx from 'classnames';
|
||||
import { shallowEqual, useSelector } from 'react-redux';
|
||||
import { ResizeCallback, ResizeStartCallback } from 're-resizable';
|
||||
import { Draggable } from '../dnd/DragDroppable';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from '../../util/componentTypes';
|
||||
import WithPopoverMenu from '../menu/WithPopoverMenu';
|
||||
import ResizableContainer from '../resizable/ResizableContainer';
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import { COLUMN_TYPE, ROW_TYPE } from '../../../util/componentTypes';
|
||||
import WithPopoverMenu from '../../menu/WithPopoverMenu';
|
||||
import ResizableContainer from '../../resizable/ResizableContainer';
|
||||
import {
|
||||
BACKGROUND_TRANSPARENT,
|
||||
GRID_BASE_UNIT,
|
||||
GRID_MIN_COLUMN_COUNT,
|
||||
} from '../../util/constants';
|
||||
import HoverMenu from '../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../DeleteComponentButton';
|
||||
import BackgroundStyleDropdown from '../menu/BackgroundStyleDropdown';
|
||||
import dashboardComponents from '../../../visualizations/presets/dashboardComponents';
|
||||
import { RootState } from '../../types';
|
||||
} from '../../../util/constants';
|
||||
import HoverMenu from '../../menu/HoverMenu';
|
||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||
import BackgroundStyleDropdown from '../../menu/BackgroundStyleDropdown';
|
||||
import dashboardComponents from '../../../../visualizations/presets/dashboardComponents';
|
||||
import { RootState } from '../../../types';
|
||||
|
||||
type FilterSummaryType = {
|
||||
type DynamicComponentProps = {
|
||||
component: JsonObject;
|
||||
parentComponent: JsonObject;
|
||||
index: number;
|
||||
depth: number;
|
||||
handleComponentDrop: (...args: any[]) => any;
|
||||
handleComponentDrop: (dropResult: unknown) => void;
|
||||
editMode: boolean;
|
||||
columnWidth: number;
|
||||
availableColumnCount: number;
|
||||
onResizeStart: ResizeStartCallback;
|
||||
onResizeStop: ResizeCallback;
|
||||
onResize: ResizeCallback;
|
||||
deleteComponent: Function;
|
||||
updateComponents: Function;
|
||||
parentId: number;
|
||||
id: number;
|
||||
deleteComponent: (id: string, parentId: string) => void;
|
||||
updateComponents: (updates: Record<string, JsonObject>) => void;
|
||||
parentId: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
const DynamicComponent: FC<FilterSummaryType> = ({
|
||||
const DynamicComponent: FC<DynamicComponentProps> = ({
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default } from './DynamicComponent';
|
||||
@@ -22,7 +22,6 @@ import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import Header from 'src/dashboard/components/gridComponents/Header';
|
||||
import newComponentFactory from 'src/dashboard/util/newComponentFactory';
|
||||
import {
|
||||
HEADER_TYPE,
|
||||
@@ -30,6 +29,7 @@ import {
|
||||
} from 'src/dashboard/util/componentTypes';
|
||||
|
||||
import { mockStoreWithTabs } from 'spec/fixtures/mockStore';
|
||||
import Header from './Header';
|
||||
|
||||
describe('Header', () => {
|
||||
const props = {
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Header from './Header';
|
||||
|
||||
export default Header;
|
||||
@@ -18,9 +18,9 @@
|
||||
*/
|
||||
import { Provider } from 'react-redux';
|
||||
import { act, render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
import MarkdownConnected from 'src/dashboard/components/gridComponents/Markdown';
|
||||
import { mockStore } from 'spec/fixtures/mockStore';
|
||||
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import MarkdownConnected from './Markdown';
|
||||
|
||||
describe('Markdown', () => {
|
||||
const props = {
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Markdown from './Markdown';
|
||||
|
||||
export default Markdown;
|
||||
@@ -53,7 +53,7 @@ import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import { isEmbedded } from 'src/dashboard/util/isEmbedded';
|
||||
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
||||
import { isCurrentUserBot } from 'src/utils/isBot';
|
||||
import { useDebouncedEffect } from '../../../explore/exploreUtils';
|
||||
import { useDebouncedEffect } from '../../../../explore/exploreUtils';
|
||||
|
||||
const propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -20,12 +20,12 @@ import { fireEvent, render } from 'spec/helpers/testing-library';
|
||||
|
||||
import BackgroundStyleDropdown from 'src/dashboard/components/menu/BackgroundStyleDropdown';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
import Row from 'src/dashboard/components/gridComponents/Row';
|
||||
import { DASHBOARD_GRID_ID } from 'src/dashboard/util/constants';
|
||||
|
||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||
import { dashboardLayout as mockLayout } from 'spec/fixtures/mockDashboardLayout';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
import Row from './Row';
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Row from './Row';
|
||||
|
||||
export default Row;
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
|
||||
|
||||
import { Provider } from 'react-redux';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import Tab, { RENDER_TAB } from 'src/dashboard/components/gridComponents/Tab';
|
||||
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
|
||||
import { getMockStore } from 'spec/fixtures/mockStore';
|
||||
|
||||
// TODO: rewrite to RTL
|
||||
describe('Tabs', () => {
|
||||
const props = {
|
||||
id: 'TAB_ID',
|
||||
parentId: 'TABS_ID',
|
||||
component: dashboardLayoutWithTabs.present.TAB_ID,
|
||||
parentComponent: dashboardLayoutWithTabs.present.TABS_ID,
|
||||
index: 0,
|
||||
depth: 1,
|
||||
editMode: false,
|
||||
renderType: RENDER_TAB,
|
||||
filters: {},
|
||||
dashboardId: 123,
|
||||
setDirectPathToChild: jest.fn(),
|
||||
onDropOnTab() {},
|
||||
onDeleteTab() {},
|
||||
availableColumnCount: 12,
|
||||
columnWidth: 50,
|
||||
onResizeStart() {},
|
||||
onResize() {},
|
||||
onResizeStop() {},
|
||||
createComponent() {},
|
||||
handleComponentDrop() {},
|
||||
onChangeTab() {},
|
||||
deleteComponent() {},
|
||||
updateComponents() {},
|
||||
dropToChild: false,
|
||||
maxChildrenHeight: 100,
|
||||
shouldDropToChild: () => false, // Add this prop
|
||||
};
|
||||
|
||||
function setup(overrideProps = {}) {
|
||||
return render(
|
||||
<Provider
|
||||
store={getMockStore({
|
||||
dashboardLayout: dashboardLayoutWithTabs,
|
||||
})}
|
||||
>
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Tab {...props} {...overrideProps} />
|
||||
</DndProvider>
|
||||
</Provider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('renderType=RENDER_TAB', () => {
|
||||
it('should render a DragDroppable', () => {
|
||||
setup();
|
||||
expect(screen.getByTestId('dragdroppable-object')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render an EditableTitle with meta.text', () => {
|
||||
setup();
|
||||
const titleElement = screen.getByTestId('editable-title');
|
||||
expect(titleElement).toBeInTheDocument();
|
||||
expect(titleElement).toHaveTextContent(
|
||||
props.component.meta.defaultText || '',
|
||||
);
|
||||
});
|
||||
|
||||
it('should call updateComponents when EditableTitle changes', async () => {
|
||||
const updateComponents = jest.fn();
|
||||
setup({
|
||||
editMode: true,
|
||||
updateComponents,
|
||||
component: {
|
||||
...dashboardLayoutWithTabs.present.TAB_ID,
|
||||
meta: {
|
||||
text: 'Original Title',
|
||||
defaultText: 'Original Title', // Add defaultText to match component
|
||||
},
|
||||
},
|
||||
isFocused: true,
|
||||
});
|
||||
|
||||
const titleElement = screen.getByTestId('editable-title');
|
||||
fireEvent.click(titleElement);
|
||||
|
||||
const titleInput = await screen.findByTestId(
|
||||
'textarea-editable-title-input',
|
||||
);
|
||||
fireEvent.change(titleInput, { target: { value: 'New title' } });
|
||||
fireEvent.blur(titleInput);
|
||||
|
||||
expect(updateComponents).toHaveBeenCalledWith({
|
||||
TAB_ID: {
|
||||
...dashboardLayoutWithTabs.present.TAB_ID,
|
||||
meta: {
|
||||
...dashboardLayoutWithTabs.present.TAB_ID.meta,
|
||||
text: 'New title',
|
||||
defaultText: 'Original Title', // Keep the original defaultText
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('renderType=RENDER_TAB_CONTENT', () => {
|
||||
it('should render DashboardComponents', () => {
|
||||
setup({
|
||||
renderType: 'RENDER_TAB_CONTENT',
|
||||
component: {
|
||||
...dashboardLayoutWithTabs.present.TAB_ID,
|
||||
children: ['ROW_ID'],
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByTestId('dashboard-component-chart-holder'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -29,7 +29,7 @@ import { EditableTitle } from '@superset-ui/core/components';
|
||||
import { setEditMode } from 'src/dashboard/actions/dashboardState';
|
||||
|
||||
import Tab from './Tab';
|
||||
import Markdown from './Markdown';
|
||||
import Markdown from '../Markdown';
|
||||
|
||||
jest.mock('src/dashboard/containers/DashboardComponent', () =>
|
||||
jest.fn(() => <div data-test="DashboardComponent" />),
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import Tab from './Tab';
|
||||
|
||||
export default Tab;
|
||||
export { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
|
||||
@@ -1,203 +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 'spec/helpers/testing-library';
|
||||
import fetchMock from 'fetch-mock';
|
||||
import Tabs from 'src/dashboard/components/gridComponents/Tabs';
|
||||
import { DASHBOARD_ROOT_ID } from 'src/dashboard/util/constants';
|
||||
import emptyDashboardLayout from 'src/dashboard/fixtures/emptyDashboardLayout';
|
||||
import { dashboardLayoutWithTabs } from 'spec/fixtures/mockDashboardLayout';
|
||||
import { nativeFilters } from 'spec/fixtures/mockNativeFilters';
|
||||
import { initialState } from 'src/SqlLab/fixtures';
|
||||
|
||||
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
Draggable: ({ children }) => (
|
||||
<div data-test="mock-draggable">{children({})}</div>
|
||||
),
|
||||
Droppable: ({ children }) => (
|
||||
<div data-test="mock-droppable">{children({})}</div>
|
||||
),
|
||||
}));
|
||||
jest.mock('src/dashboard/containers/DashboardComponent', () => ({ id }) => (
|
||||
<div data-test="mock-dashboard-component">{id}</div>
|
||||
));
|
||||
|
||||
jest.mock(
|
||||
'src/dashboard/components/DeleteComponentButton',
|
||||
() =>
|
||||
({ onDelete }) => (
|
||||
<button
|
||||
type="button"
|
||||
data-test="mock-delete-component-button"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
);
|
||||
|
||||
fetchMock.post('glob:*/r/shortener/', {});
|
||||
|
||||
const props = {
|
||||
id: 'TABS_ID',
|
||||
parentId: DASHBOARD_ROOT_ID,
|
||||
component: dashboardLayoutWithTabs.present.TABS_ID,
|
||||
parentComponent: dashboardLayoutWithTabs.present[DASHBOARD_ROOT_ID],
|
||||
index: 0,
|
||||
depth: 1,
|
||||
renderTabContent: true,
|
||||
editMode: false,
|
||||
availableColumnCount: 12,
|
||||
columnWidth: 50,
|
||||
dashboardId: 1,
|
||||
onResizeStart() {},
|
||||
onResize() {},
|
||||
onResizeStop() {},
|
||||
createComponent() {},
|
||||
handleComponentDrop() {},
|
||||
onChangeTab() {},
|
||||
deleteComponent() {},
|
||||
updateComponents() {},
|
||||
logEvent() {},
|
||||
dashboardLayout: emptyDashboardLayout,
|
||||
nativeFilters: nativeFilters.filters,
|
||||
};
|
||||
|
||||
function setup(overrideProps, overrideState = {}) {
|
||||
return render(<Tabs {...props} {...overrideProps} />, {
|
||||
useDnd: true,
|
||||
useRouter: true,
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...initialState,
|
||||
dashboardLayout: dashboardLayoutWithTabs,
|
||||
dashboardFilters: {},
|
||||
...overrideState,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
test('should render a Draggable', () => {
|
||||
// test just Tabs with no children Draggable
|
||||
const { getByTestId } = setup({
|
||||
component: { ...props.component, children: [] },
|
||||
});
|
||||
expect(getByTestId('mock-draggable')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render non-editable tabs', () => {
|
||||
const { getAllByRole, container } = setup();
|
||||
expect(getAllByRole('tab')[0]).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-tabs-nav-add')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render a tab pane for each child', () => {
|
||||
const { getAllByRole } = setup();
|
||||
expect(getAllByRole('tab')).toHaveLength(props.component.children.length);
|
||||
});
|
||||
|
||||
test('should render editable tabs in editMode', () => {
|
||||
const { getAllByRole, container } = setup({ editMode: true });
|
||||
expect(getAllByRole('tab')[0]).toBeInTheDocument();
|
||||
expect(container.querySelector('.ant-tabs-nav-add')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render a DashboardComponent for each child', () => {
|
||||
// note: this does not test Tab content
|
||||
const { getAllByTestId } = setup({ renderTabContent: false });
|
||||
expect(getAllByTestId('mock-dashboard-component')).toHaveLength(
|
||||
props.component.children.length,
|
||||
);
|
||||
});
|
||||
|
||||
test('should call createComponent if the (+) tab is clicked', () => {
|
||||
const createComponent = jest.fn();
|
||||
const { getAllByRole } = setup({ editMode: true, createComponent });
|
||||
const addButtons = getAllByRole('button', { name: 'Add tab' });
|
||||
fireEvent.click(addButtons[0]);
|
||||
expect(createComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should call onChangeTab when a tab is clicked', () => {
|
||||
const onChangeTab = jest.fn();
|
||||
const { getByRole } = setup({ editMode: true, onChangeTab });
|
||||
const newTab = getByRole('tab', { selected: false });
|
||||
fireEvent.click(newTab);
|
||||
expect(onChangeTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should not call onChangeTab when anchor link is clicked', () => {
|
||||
const onChangeTab = jest.fn();
|
||||
const { getByRole } = setup({ editMode: true, onChangeTab });
|
||||
const currentTab = getByRole('tab', { selected: true });
|
||||
fireEvent.click(currentTab);
|
||||
|
||||
expect(onChangeTab).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('should render a HoverMenu in editMode', () => {
|
||||
const { container } = setup({ editMode: true });
|
||||
expect(container.querySelector('.hover-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render a DeleteComponentButton in editMode', () => {
|
||||
const { getByTestId } = setup({ editMode: true });
|
||||
expect(getByTestId('mock-delete-component-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should call deleteComponent when deleted', () => {
|
||||
const deleteComponent = jest.fn();
|
||||
const { getByTestId } = setup({ editMode: true, deleteComponent });
|
||||
fireEvent.click(getByTestId('mock-delete-component-button'));
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should direct display direct-link tab', () => {
|
||||
// display child in directPathToChild list
|
||||
const directPathToChild =
|
||||
dashboardLayoutWithTabs.present.ROW_ID2.parents.slice();
|
||||
const { getByRole } = setup({}, { dashboardState: { directPathToChild } });
|
||||
expect(getByRole('tab', { selected: true })).toHaveTextContent('TAB_ID2');
|
||||
});
|
||||
|
||||
test('should render Modal when clicked remove tab button', () => {
|
||||
const deleteComponent = jest.fn();
|
||||
const { container, getByText, queryByText } = setup({
|
||||
editMode: true,
|
||||
deleteComponent,
|
||||
});
|
||||
|
||||
// Initially no modal should be visible
|
||||
expect(queryByText('Delete dashboard tab?')).not.toBeInTheDocument();
|
||||
|
||||
// Click the remove tab button
|
||||
fireEvent.click(container.querySelector('.ant-tabs-tab-remove'));
|
||||
|
||||
// Modal should now be visible
|
||||
expect(getByText('Delete dashboard tab?')).toBeInTheDocument();
|
||||
expect(deleteComponent).toHaveBeenCalledTimes(0);
|
||||
});
|
||||
|
||||
test('should set new tab key if dashboardId was changed', () => {
|
||||
const { getByRole } = setup({
|
||||
...props,
|
||||
dashboardId: 2,
|
||||
component: dashboardLayoutWithTabs.present.TAB_ID,
|
||||
});
|
||||
expect(getByRole('tab', { selected: true })).toHaveTextContent('ROW_ID');
|
||||
});
|
||||
@@ -18,25 +18,22 @@
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useState, memo } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { styled, t, usePrevious, css } from '@superset-ui/core';
|
||||
import { t, usePrevious, useTheme, styled } from '@superset-ui/core';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { LineEditableTabs } from '@superset-ui/core/components/Tabs';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from 'src/logger/LogUtils';
|
||||
import { Modal } from '@superset-ui/core/components';
|
||||
import { DROP_LEFT, DROP_RIGHT } from 'src/dashboard/util/getDropPosition';
|
||||
import { Draggable } from '../dnd/DragDroppable';
|
||||
import DragHandle from '../dnd/DragHandle';
|
||||
import DashboardComponent from '../../containers/DashboardComponent';
|
||||
import DeleteComponentButton from '../DeleteComponentButton';
|
||||
import HoverMenu from '../menu/HoverMenu';
|
||||
import findTabIndexByComponentId from '../../util/findTabIndexByComponentId';
|
||||
import getDirectPathToTabIndex from '../../util/getDirectPathToTabIndex';
|
||||
import getLeafComponentIdFromPath from '../../util/getLeafComponentIdFromPath';
|
||||
import { componentShape } from '../../util/propShapes';
|
||||
import { NEW_TAB_ID } from '../../util/constants';
|
||||
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
|
||||
import { TABS_TYPE, TAB_TYPE } from '../../util/componentTypes';
|
||||
import { Draggable } from '../../dnd/DragDroppable';
|
||||
import DashboardComponent from '../../../containers/DashboardComponent';
|
||||
import findTabIndexByComponentId from '../../../util/findTabIndexByComponentId';
|
||||
import getDirectPathToTabIndex from '../../../util/getDirectPathToTabIndex';
|
||||
import getLeafComponentIdFromPath from '../../../util/getLeafComponentIdFromPath';
|
||||
import { componentShape } from '../../../util/propShapes';
|
||||
import { NEW_TAB_ID } from '../../../util/constants';
|
||||
import { RENDER_TAB, RENDER_TAB_CONTENT } from '../Tab';
|
||||
import { TABS_TYPE, TAB_TYPE } from '../../../util/componentTypes';
|
||||
import TabsRenderer from '../TabsRenderer';
|
||||
|
||||
const propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -76,34 +73,6 @@ const defaultProps = {
|
||||
onResizeStop() {},
|
||||
};
|
||||
|
||||
const StyledTabsContainer = styled.div`
|
||||
${({ theme }) => css`
|
||||
width: 100%;
|
||||
background-color: ${theme.colorBgBase};
|
||||
|
||||
.dashboard-component-tabs-content {
|
||||
min-height: ${theme.sizeUnit * 12}px;
|
||||
margin-top: ${theme.sizeUnit / 4}px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.ant-tabs {
|
||||
overflow: visible;
|
||||
|
||||
.ant-tabs-nav-wrap {
|
||||
min-height: ${theme.sizeUnit * 12.5}px;
|
||||
}
|
||||
|
||||
.ant-tabs-content-holder {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
|
||||
div .ant-tabs-tab-btn {
|
||||
text-transform: none;
|
||||
}
|
||||
`}
|
||||
`;
|
||||
const DropIndicator = styled.div`
|
||||
border: 2px solid ${({ theme }) => theme.colorPrimary};
|
||||
width: 5px;
|
||||
@@ -124,11 +93,16 @@ const CloseIconWithDropIndicator = props => (
|
||||
);
|
||||
|
||||
const Tabs = props => {
|
||||
const theme = useTheme();
|
||||
|
||||
const nativeFilters = useSelector(state => state.nativeFilters);
|
||||
const activeTabs = useSelector(state => state.dashboardState.activeTabs);
|
||||
const directPathToChild = useSelector(
|
||||
state => state.dashboardState.directPathToChild,
|
||||
);
|
||||
const nativeFiltersBarOpen = useSelector(
|
||||
state => state.dashboardState.nativeFiltersBarOpen ?? false,
|
||||
);
|
||||
|
||||
const { tabIndex: initTabIndex, activeKey: initActiveKey } = useMemo(() => {
|
||||
let tabIndex = Math.max(
|
||||
@@ -378,6 +352,13 @@ const Tabs = props => {
|
||||
|
||||
const { children: tabIds } = tabsComponent;
|
||||
|
||||
const tabBarPaddingLeft =
|
||||
renderTabContent === false
|
||||
? nativeFiltersBarOpen
|
||||
? 0
|
||||
: theme.sizeUnit * 4
|
||||
: 0;
|
||||
|
||||
const showDropIndicators = useCallback(
|
||||
currentDropTabIndex =>
|
||||
currentDropTabIndex === dragOverTabIndex && {
|
||||
@@ -392,16 +373,21 @@ const Tabs = props => {
|
||||
[draggingTabId],
|
||||
);
|
||||
|
||||
let tabsToHighlight;
|
||||
const highlightedFilterId =
|
||||
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
|
||||
if (highlightedFilterId) {
|
||||
tabsToHighlight = nativeFilters.filters[highlightedFilterId]?.tabsInScope;
|
||||
}
|
||||
// Extract tab highlighting logic into a hook
|
||||
const useTabHighlighting = useCallback(() => {
|
||||
const highlightedFilterId =
|
||||
nativeFilters?.focusedFilterId || nativeFilters?.hoveredFilterId;
|
||||
return highlightedFilterId
|
||||
? nativeFilters.filters[highlightedFilterId]?.tabsInScope
|
||||
: undefined;
|
||||
}, [nativeFilters]);
|
||||
|
||||
const renderChild = useCallback(
|
||||
({ dragSourceRef: tabsDragSourceRef }) => {
|
||||
const tabItems = tabIds.map((tabId, tabIndex) => ({
|
||||
const tabsToHighlight = useTabHighlighting();
|
||||
|
||||
// Extract tab items creation logic into a memoized value (not a hook inside hook)
|
||||
const tabItems = useMemo(
|
||||
() =>
|
||||
tabIds.map((tabId, tabIndex) => ({
|
||||
key: tabId,
|
||||
label: removeDraggedTab(tabId) ? (
|
||||
<></>
|
||||
@@ -456,51 +442,20 @@ const Tabs = props => {
|
||||
}
|
||||
/>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<StyledTabsContainer
|
||||
className="dashboard-component dashboard-component-tabs"
|
||||
data-test="dashboard-component-tabs"
|
||||
>
|
||||
{editMode && renderHoverMenu && (
|
||||
<HoverMenu innerRef={tabsDragSourceRef} position="left">
|
||||
<DragHandle position="left" />
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
|
||||
<LineEditableTabs
|
||||
id={tabsComponent.id}
|
||||
activeKey={activeKey}
|
||||
onChange={key => {
|
||||
handleClickTab(tabIds.indexOf(key));
|
||||
}}
|
||||
onEdit={handleEdit}
|
||||
data-test="nav-list"
|
||||
type={editMode ? 'editable-card' : 'card'}
|
||||
items={tabItems} // Pass the dynamically generated items array
|
||||
/>
|
||||
</StyledTabsContainer>
|
||||
);
|
||||
},
|
||||
})),
|
||||
[
|
||||
editMode,
|
||||
renderHoverMenu,
|
||||
handleDeleteComponent,
|
||||
tabsComponent.id,
|
||||
activeKey,
|
||||
handleEdit,
|
||||
tabIds,
|
||||
handleClickTab,
|
||||
removeDraggedTab,
|
||||
showDropIndicators,
|
||||
tabsComponent.id,
|
||||
depth,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
handleDropOnTab,
|
||||
handleGetDropPosition,
|
||||
handleDragggingTab,
|
||||
handleClickTab,
|
||||
activeKey,
|
||||
tabsToHighlight,
|
||||
renderTabContent,
|
||||
onResizeStart,
|
||||
@@ -511,6 +466,36 @@ const Tabs = props => {
|
||||
],
|
||||
);
|
||||
|
||||
const renderChild = useCallback(
|
||||
({ dragSourceRef: tabsDragSourceRef }) => (
|
||||
<TabsRenderer
|
||||
tabItems={tabItems}
|
||||
editMode={editMode}
|
||||
renderHoverMenu={renderHoverMenu}
|
||||
tabsDragSourceRef={tabsDragSourceRef}
|
||||
handleDeleteComponent={handleDeleteComponent}
|
||||
tabsComponent={tabsComponent}
|
||||
activeKey={activeKey}
|
||||
tabIds={tabIds}
|
||||
handleClickTab={handleClickTab}
|
||||
handleEdit={handleEdit}
|
||||
tabBarPaddingLeft={tabBarPaddingLeft}
|
||||
/>
|
||||
),
|
||||
[
|
||||
tabItems,
|
||||
editMode,
|
||||
renderHoverMenu,
|
||||
handleDeleteComponent,
|
||||
tabsComponent,
|
||||
activeKey,
|
||||
tabIds,
|
||||
handleClickTab,
|
||||
handleEdit,
|
||||
tabBarPaddingLeft,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Draggable
|
||||
@@ -59,9 +59,10 @@ jest.mock('src/dashboard/util/getLeafComponentIdFromPath', () => jest.fn());
|
||||
|
||||
jest.mock('src/dashboard/components/dnd/DragDroppable', () => ({
|
||||
Draggable: jest.fn(props => {
|
||||
const mockElement = { tagName: 'DIV', dataset: {} };
|
||||
const childProps = props.editMode
|
||||
? {
|
||||
dragSourceRef: props.dragSourceRef,
|
||||
dragSourceRef: { current: mockElement },
|
||||
dropIndicatorProps: props.dropIndicatorProps,
|
||||
}
|
||||
: {};
|
||||
@@ -135,6 +136,36 @@ test('Should render editMode:true', () => {
|
||||
expect(DeleteComponentButton).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('Should render HoverMenu in editMode', () => {
|
||||
const props = createProps();
|
||||
const { container } = render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
// HoverMenu is rendered inside TabsRenderer when editMode is true
|
||||
expect(container.querySelector('.hover-menu')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should not render HoverMenu when not in editMode', () => {
|
||||
const props = createProps();
|
||||
props.editMode = false;
|
||||
const { container } = render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should not render HoverMenu when renderHoverMenu is false', () => {
|
||||
const props = createProps();
|
||||
props.renderHoverMenu = false;
|
||||
const { container } = render(<Tabs {...props} />, {
|
||||
useRedux: true,
|
||||
useDnd: true,
|
||||
});
|
||||
expect(container.querySelector('.hover-menu')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Should render editMode:false', () => {
|
||||
const props = createProps();
|
||||
props.editMode = false;
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 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 Tabs from './Tabs';
|
||||
|
||||
export default Tabs;
|
||||
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* 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, screen } from 'spec/helpers/testing-library';
|
||||
import TabsRenderer, { TabItem, TabsRendererProps } from './TabsRenderer';
|
||||
|
||||
const mockTabItems: TabItem[] = [
|
||||
{
|
||||
key: 'tab-1',
|
||||
label: <div>Tab 1</div>,
|
||||
closeIcon: <div>×</div>,
|
||||
children: <div>Tab 1 Content</div>,
|
||||
},
|
||||
{
|
||||
key: 'tab-2',
|
||||
label: <div>Tab 2</div>,
|
||||
closeIcon: <div>×</div>,
|
||||
children: <div>Tab 2 Content</div>,
|
||||
},
|
||||
];
|
||||
|
||||
const mockProps: TabsRendererProps = {
|
||||
tabItems: mockTabItems,
|
||||
editMode: false,
|
||||
renderHoverMenu: true,
|
||||
tabsDragSourceRef: undefined,
|
||||
handleDeleteComponent: jest.fn(),
|
||||
tabsComponent: { id: 'test-tabs-id' },
|
||||
activeKey: 'tab-1',
|
||||
tabIds: ['tab-1', 'tab-2'],
|
||||
handleClickTab: jest.fn(),
|
||||
handleEdit: jest.fn(),
|
||||
tabBarPaddingLeft: 16,
|
||||
};
|
||||
|
||||
describe('TabsRenderer', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders tabs container with correct test attributes', () => {
|
||||
render(<TabsRenderer {...mockProps} />);
|
||||
|
||||
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
|
||||
|
||||
expect(tabsContainer).toBeInTheDocument();
|
||||
expect(tabsContainer).toHaveClass('dashboard-component-tabs');
|
||||
});
|
||||
|
||||
test('renders LineEditableTabs with correct props', () => {
|
||||
render(<TabsRenderer {...mockProps} />);
|
||||
|
||||
const editableTabs = screen.getByTestId('nav-list');
|
||||
expect(editableTabs).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('applies correct tab bar padding', () => {
|
||||
const { rerender } = render(<TabsRenderer {...mockProps} />);
|
||||
|
||||
let editableTabs = screen.getByTestId('nav-list');
|
||||
expect(editableTabs).toBeInTheDocument();
|
||||
|
||||
rerender(<TabsRenderer {...mockProps} tabBarPaddingLeft={0} />);
|
||||
editableTabs = screen.getByTestId('nav-list');
|
||||
|
||||
expect(editableTabs).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls handleClickTab when tab is clicked', () => {
|
||||
const handleClickTabMock = jest.fn();
|
||||
const propsWithTab2Active = {
|
||||
...mockProps,
|
||||
activeKey: 'tab-2',
|
||||
handleClickTab: handleClickTabMock,
|
||||
};
|
||||
render(<TabsRenderer {...propsWithTab2Active} />);
|
||||
|
||||
const tabElement = screen.getByText('Tab 1').closest('[role="tab"]');
|
||||
expect(tabElement).not.toBeNull();
|
||||
|
||||
fireEvent.click(tabElement!);
|
||||
|
||||
expect(handleClickTabMock).toHaveBeenCalledWith(0);
|
||||
expect(handleClickTabMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('shows hover menu in edit mode', () => {
|
||||
const mockRef = { current: null };
|
||||
const editModeProps: TabsRendererProps = {
|
||||
...mockProps,
|
||||
editMode: true,
|
||||
renderHoverMenu: true,
|
||||
tabsDragSourceRef: mockRef,
|
||||
};
|
||||
|
||||
render(<TabsRenderer {...editModeProps} />);
|
||||
|
||||
const hoverMenu = document.querySelector('.hover-menu');
|
||||
|
||||
expect(hoverMenu).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides hover menu when not in edit mode', () => {
|
||||
const viewModeProps: TabsRendererProps = {
|
||||
...mockProps,
|
||||
editMode: false,
|
||||
renderHoverMenu: true,
|
||||
};
|
||||
|
||||
render(<TabsRenderer {...viewModeProps} />);
|
||||
|
||||
const hoverMenu = document.querySelector('.hover-menu');
|
||||
|
||||
expect(hoverMenu).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hides hover menu when renderHoverMenu is false', () => {
|
||||
const mockRef = { current: null };
|
||||
const noHoverMenuProps: TabsRendererProps = {
|
||||
...mockProps,
|
||||
editMode: true,
|
||||
renderHoverMenu: false,
|
||||
tabsDragSourceRef: mockRef,
|
||||
};
|
||||
|
||||
render(<TabsRenderer {...noHoverMenuProps} />);
|
||||
|
||||
const hoverMenu = document.querySelector('.hover-menu');
|
||||
|
||||
expect(hoverMenu).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders with correct tab type based on edit mode', () => {
|
||||
const { rerender } = render(
|
||||
<TabsRenderer {...mockProps} editMode={false} />,
|
||||
);
|
||||
|
||||
let editableTabs = screen.getByTestId('nav-list');
|
||||
expect(editableTabs).toBeInTheDocument();
|
||||
|
||||
rerender(<TabsRenderer {...mockProps} editMode />);
|
||||
|
||||
editableTabs = screen.getByTestId('nav-list');
|
||||
|
||||
expect(editableTabs).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('handles default props correctly', () => {
|
||||
const minimalProps: TabsRendererProps = {
|
||||
tabItems: mockProps.tabItems,
|
||||
editMode: false,
|
||||
handleDeleteComponent: mockProps.handleDeleteComponent,
|
||||
tabsComponent: mockProps.tabsComponent,
|
||||
activeKey: mockProps.activeKey,
|
||||
tabIds: mockProps.tabIds,
|
||||
handleClickTab: mockProps.handleClickTab,
|
||||
handleEdit: mockProps.handleEdit,
|
||||
};
|
||||
|
||||
render(<TabsRenderer {...minimalProps} />);
|
||||
|
||||
const tabsContainer = screen.getByTestId('dashboard-component-tabs');
|
||||
|
||||
expect(tabsContainer).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onEdit when edit action is triggered', () => {
|
||||
const handleEditMock = jest.fn();
|
||||
const editableProps = {
|
||||
...mockProps,
|
||||
editMode: true,
|
||||
handleEdit: handleEditMock,
|
||||
};
|
||||
|
||||
render(<TabsRenderer {...editableProps} />);
|
||||
|
||||
expect(screen.getByTestId('nav-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders tab content correctly', () => {
|
||||
render(<TabsRenderer {...mockProps} />);
|
||||
|
||||
expect(screen.getByText('Tab 1 Content')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Tab 2 Content')).not.toBeInTheDocument(); // Not active
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { memo, ReactElement, RefObject } from 'react';
|
||||
import { styled } from '@superset-ui/core';
|
||||
import {
|
||||
LineEditableTabs,
|
||||
TabsProps as AntdTabsProps,
|
||||
} from '@superset-ui/core/components/Tabs';
|
||||
import HoverMenu from '../../menu/HoverMenu';
|
||||
import DragHandle from '../../dnd/DragHandle';
|
||||
import DeleteComponentButton from '../../DeleteComponentButton';
|
||||
|
||||
const StyledTabsContainer = styled.div`
|
||||
width: 100%;
|
||||
background-color: ${({ theme }) => theme.colorBgContainer};
|
||||
|
||||
& .dashboard-component-tabs-content {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
& > .hover-menu:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&.dragdroppable-row .dashboard-component-tabs-content {
|
||||
height: calc(100% - 47px);
|
||||
}
|
||||
`;
|
||||
|
||||
export interface TabItem {
|
||||
key: string;
|
||||
label: ReactElement;
|
||||
closeIcon: ReactElement;
|
||||
children: ReactElement;
|
||||
}
|
||||
|
||||
export interface TabsComponent {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface TabsRendererProps {
|
||||
tabItems: TabItem[];
|
||||
editMode: boolean;
|
||||
renderHoverMenu?: boolean;
|
||||
tabsDragSourceRef?: RefObject<HTMLDivElement>;
|
||||
handleDeleteComponent: () => void;
|
||||
tabsComponent: TabsComponent;
|
||||
activeKey: string;
|
||||
tabIds: string[];
|
||||
handleClickTab: (index: number) => void;
|
||||
handleEdit: AntdTabsProps['onEdit'];
|
||||
tabBarPaddingLeft?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabsRenderer component handles the rendering of dashboard tabs
|
||||
* Extracted from the main Tabs component for better separation of concerns
|
||||
*/
|
||||
const TabsRenderer = memo<TabsRendererProps>(
|
||||
({
|
||||
tabItems,
|
||||
editMode,
|
||||
renderHoverMenu = true,
|
||||
tabsDragSourceRef,
|
||||
handleDeleteComponent,
|
||||
tabsComponent,
|
||||
activeKey,
|
||||
tabIds,
|
||||
handleClickTab,
|
||||
handleEdit,
|
||||
tabBarPaddingLeft = 0,
|
||||
}) => (
|
||||
<StyledTabsContainer
|
||||
className="dashboard-component dashboard-component-tabs"
|
||||
data-test="dashboard-component-tabs"
|
||||
>
|
||||
{editMode && renderHoverMenu && tabsDragSourceRef && (
|
||||
<HoverMenu innerRef={tabsDragSourceRef} position="left">
|
||||
<DragHandle position="left" />
|
||||
<DeleteComponentButton onDelete={handleDeleteComponent} />
|
||||
</HoverMenu>
|
||||
)}
|
||||
|
||||
<LineEditableTabs
|
||||
id={tabsComponent.id}
|
||||
activeKey={activeKey}
|
||||
onChange={key => {
|
||||
if (typeof key === 'string') {
|
||||
const tabIndex = tabIds.indexOf(key);
|
||||
if (tabIndex !== -1) handleClickTab(tabIndex);
|
||||
}
|
||||
}}
|
||||
onEdit={handleEdit}
|
||||
data-test="nav-list"
|
||||
type={editMode ? 'editable-card' : 'card'}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ paddingLeft: tabBarPaddingLeft }}
|
||||
/>
|
||||
</StyledTabsContainer>
|
||||
),
|
||||
);
|
||||
|
||||
TabsRenderer.displayName = 'TabsRenderer';
|
||||
|
||||
export default TabsRenderer;
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default } from './TabsRenderer';
|
||||
export type { TabsRendererProps, TabItem, TabsComponent } from './TabsRenderer';
|
||||
@@ -38,16 +38,6 @@ import Tab from './Tab';
|
||||
import Tabs from './Tabs';
|
||||
import DynamicComponent from './DynamicComponent';
|
||||
|
||||
export { default as ChartHolder } from './ChartHolder';
|
||||
export { default as Markdown } from './Markdown';
|
||||
export { default as Column } from './Column';
|
||||
export { default as Divider } from './Divider';
|
||||
export { default as Header } from './Header';
|
||||
export { default as Row } from './Row';
|
||||
export { default as Tab } from './Tab';
|
||||
export { default as Tabs } from './Tabs';
|
||||
export { default as DynamicComponent } from './DynamicComponent';
|
||||
|
||||
export const componentLookup = {
|
||||
[CHART_TYPE]: ChartHolder,
|
||||
[MARKDOWN_TYPE]: Markdown,
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||
import { last } from 'lodash';
|
||||
import contentDisposition from 'content-disposition';
|
||||
import {
|
||||
logging,
|
||||
t,
|
||||
@@ -98,12 +99,31 @@ export const useDownloadScreenshot = (
|
||||
headers: { Accept: 'application/pdf, image/png' },
|
||||
parseMethod: 'raw',
|
||||
})
|
||||
.then((response: Response) => response.blob())
|
||||
.then(blob => {
|
||||
.then((response: Response) => {
|
||||
const disposition = response.headers.get('Content-Disposition');
|
||||
let fileName = `screenshot.${format}`; // default filename
|
||||
|
||||
if (disposition) {
|
||||
try {
|
||||
const parsed = contentDisposition.parse(disposition);
|
||||
if (parsed?.parameters?.filename) {
|
||||
fileName = parsed.parameters.filename;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
'Failed to parse Content-Disposition header:',
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return response.blob().then(blob => ({ blob, fileName }));
|
||||
})
|
||||
.then(({ blob, fileName }) => {
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `screenshot.${format}`;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
SET_DASHBOARD_LABELS_COLORMAP_SYNCED,
|
||||
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCABLE,
|
||||
SET_DASHBOARD_SHARED_LABELS_COLORS_SYNCED,
|
||||
TOGGLE_NATIVE_FILTERS_BAR,
|
||||
} from '../actions/dashboardState';
|
||||
import { HYDRATE_DASHBOARD } from '../actions/hydrate';
|
||||
|
||||
@@ -267,6 +268,12 @@ export default function dashboardStateReducer(state = {}, action) {
|
||||
datasetsStatus: action.status,
|
||||
};
|
||||
},
|
||||
[TOGGLE_NATIVE_FILTERS_BAR]() {
|
||||
return {
|
||||
...state,
|
||||
nativeFiltersBarOpen: action.isOpen,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
if (action.type in actionHandlers) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
SET_UNSAVED_CHANGES,
|
||||
TOGGLE_EXPAND_SLICE,
|
||||
TOGGLE_FAVE_STAR,
|
||||
TOGGLE_NATIVE_FILTERS_BAR,
|
||||
UNSET_FOCUSED_FILTER_FIELD,
|
||||
} from 'src/dashboard/actions/dashboardState';
|
||||
|
||||
@@ -197,4 +198,20 @@ describe('dashboardState reducer', () => {
|
||||
column: 'column_2',
|
||||
});
|
||||
});
|
||||
|
||||
it('should toggle native filters bar', () => {
|
||||
expect(
|
||||
dashboardStateReducer(
|
||||
{ nativeFiltersBarOpen: false },
|
||||
{ type: TOGGLE_NATIVE_FILTERS_BAR, isOpen: true },
|
||||
),
|
||||
).toEqual({ nativeFiltersBarOpen: true });
|
||||
|
||||
expect(
|
||||
dashboardStateReducer(
|
||||
{ nativeFiltersBarOpen: true },
|
||||
{ type: TOGGLE_NATIVE_FILTERS_BAR, isOpen: false },
|
||||
),
|
||||
).toEqual({ nativeFiltersBarOpen: false });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,10 +20,39 @@ import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
import dashboardStateReducer from './dashboardState';
|
||||
import { setActiveTab, setActiveTabs } from '../actions/dashboardState';
|
||||
import { DashboardState } from '../types';
|
||||
|
||||
// Type the reducer function properly since it's imported from JS
|
||||
type DashboardStateReducer = (
|
||||
state: Partial<DashboardState> | undefined,
|
||||
action: any,
|
||||
) => Partial<DashboardState>;
|
||||
const typedDashboardStateReducer =
|
||||
dashboardStateReducer as DashboardStateReducer;
|
||||
|
||||
const middlewares = [thunk];
|
||||
const mockStore = configureMockStore(middlewares);
|
||||
|
||||
// Helper function to create mock dashboard state with proper types
|
||||
const createMockDashboardState = (
|
||||
overrides: Partial<DashboardState> = {},
|
||||
): DashboardState => ({
|
||||
editMode: false,
|
||||
isPublished: false,
|
||||
directPathToChild: [],
|
||||
activeTabs: [],
|
||||
fullSizeChartId: null,
|
||||
isRefreshing: false,
|
||||
isFiltersRefreshing: false,
|
||||
hasUnsavedChanges: false,
|
||||
dashboardIsSaving: false,
|
||||
colorScheme: '',
|
||||
sliceIds: [],
|
||||
directPathLastUpdated: 0,
|
||||
nativeFiltersBarOpen: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe('DashboardState reducer', () => {
|
||||
describe('SET_ACTIVE_TAB', () => {
|
||||
it('switches a single tab', () => {
|
||||
@@ -34,16 +63,28 @@ describe('DashboardState reducer', () => {
|
||||
const request = setActiveTab('tab1');
|
||||
const thunkAction = request(store.dispatch, store.getState);
|
||||
|
||||
expect(dashboardStateReducer({ activeTabs: [] }, thunkAction)).toEqual({
|
||||
activeTabs: ['tab1'],
|
||||
inactiveTabs: [],
|
||||
});
|
||||
expect(
|
||||
typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: [] }),
|
||||
thunkAction,
|
||||
),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: ['tab1'],
|
||||
inactiveTabs: [],
|
||||
}),
|
||||
);
|
||||
|
||||
const request2 = setActiveTab('tab2', 'tab1');
|
||||
const thunkAction2 = request2(store.dispatch, store.getState);
|
||||
expect(
|
||||
dashboardStateReducer({ activeTabs: ['tab1'] }, thunkAction2),
|
||||
).toEqual({ activeTabs: ['tab2'], inactiveTabs: [] });
|
||||
typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: ['tab1'] }),
|
||||
thunkAction2,
|
||||
),
|
||||
).toEqual(
|
||||
expect.objectContaining({ activeTabs: ['tab2'], inactiveTabs: [] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('switches a multi-depth tab', () => {
|
||||
@@ -63,75 +104,90 @@ describe('DashboardState reducer', () => {
|
||||
});
|
||||
let request = setActiveTab('TAB-B', 'TAB-A');
|
||||
let thunkAction = request(store.dispatch, store.getState);
|
||||
let result = dashboardStateReducer(
|
||||
{ activeTabs: ['TAB-1', 'TAB-A', 'TAB-__a'] },
|
||||
let result = typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: ['TAB-1', 'TAB-A', 'TAB-__a'] }),
|
||||
thunkAction,
|
||||
);
|
||||
expect(result).toEqual({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
|
||||
inactiveTabs: ['TAB-__a'],
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
|
||||
inactiveTabs: ['TAB-__a'],
|
||||
}),
|
||||
);
|
||||
request = setActiveTab('TAB-2', 'TAB-1');
|
||||
thunkAction = request(store.dispatch, () => ({
|
||||
...(store.getState() ?? {}),
|
||||
dashboardState: result,
|
||||
}));
|
||||
result = dashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual({
|
||||
activeTabs: ['TAB-2'],
|
||||
inactiveTabs: expect.arrayContaining(['TAB-B', 'TAB-__a']),
|
||||
});
|
||||
result = typedDashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: ['TAB-2'],
|
||||
inactiveTabs: expect.arrayContaining(['TAB-B', 'TAB-__a']),
|
||||
}),
|
||||
);
|
||||
request = setActiveTab('TAB-1', 'TAB-2');
|
||||
thunkAction = request(store.dispatch, () => ({
|
||||
...(store.getState() ?? {}),
|
||||
dashboardState: result,
|
||||
}));
|
||||
result = dashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
|
||||
inactiveTabs: ['TAB-__a'],
|
||||
});
|
||||
result = typedDashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-B']),
|
||||
inactiveTabs: ['TAB-__a'],
|
||||
}),
|
||||
);
|
||||
request = setActiveTab('TAB-A', 'TAB-B');
|
||||
thunkAction = request(store.dispatch, () => ({
|
||||
...(store.getState() ?? {}),
|
||||
dashboardState: result,
|
||||
}));
|
||||
result = dashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
|
||||
inactiveTabs: [],
|
||||
});
|
||||
result = typedDashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
|
||||
inactiveTabs: [],
|
||||
}),
|
||||
);
|
||||
request = setActiveTab('TAB-2', 'TAB-1');
|
||||
thunkAction = request(store.dispatch, () => ({
|
||||
...(store.getState() ?? {}),
|
||||
dashboardState: result,
|
||||
}));
|
||||
result = dashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual({
|
||||
activeTabs: expect.arrayContaining(['TAB-2']),
|
||||
inactiveTabs: ['TAB-A', 'TAB-__a'],
|
||||
});
|
||||
result = typedDashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: expect.arrayContaining(['TAB-2']),
|
||||
inactiveTabs: ['TAB-A', 'TAB-__a'],
|
||||
}),
|
||||
);
|
||||
request = setActiveTab('TAB-1', 'TAB-2');
|
||||
thunkAction = request(store.dispatch, () => ({
|
||||
...(store.getState() ?? {}),
|
||||
dashboardState: result,
|
||||
}));
|
||||
result = dashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
|
||||
inactiveTabs: [],
|
||||
});
|
||||
result = typedDashboardStateReducer(result, thunkAction);
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
activeTabs: expect.arrayContaining(['TAB-1', 'TAB-A', 'TAB-__a']),
|
||||
inactiveTabs: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
it('SET_ACTIVE_TABS', () => {
|
||||
expect(
|
||||
dashboardStateReducer({ activeTabs: [] }, setActiveTabs(['tab1'])),
|
||||
).toEqual({ activeTabs: ['tab1'] });
|
||||
typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: [] }),
|
||||
setActiveTabs(['tab1']),
|
||||
),
|
||||
).toEqual(expect.objectContaining({ activeTabs: ['tab1'] }));
|
||||
expect(
|
||||
dashboardStateReducer(
|
||||
{ activeTabs: ['tab1', 'tab2'] },
|
||||
typedDashboardStateReducer(
|
||||
createMockDashboardState({ activeTabs: ['tab1', 'tab2'] }),
|
||||
setActiveTabs(['tab3', 'tab4']),
|
||||
),
|
||||
).toEqual({ activeTabs: ['tab3', 'tab4'] });
|
||||
).toEqual(expect.objectContaining({ activeTabs: ['tab3', 'tab4'] }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,7 @@ export type DashboardState = {
|
||||
colorScheme: string;
|
||||
sliceIds: number[];
|
||||
directPathLastUpdated: number;
|
||||
nativeFiltersBarOpen?: boolean;
|
||||
css?: string;
|
||||
focusedFilterField?: {
|
||||
chartId: number;
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { t, SupersetTheme, useTheme } from '@superset-ui/core';
|
||||
import { t, SupersetTheme, useTheme, css } from '@superset-ui/core';
|
||||
import { Tooltip } from '@superset-ui/core/components';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { AlertState } from '../types';
|
||||
@@ -32,7 +32,7 @@ function getStatusColor(
|
||||
case AlertState.Error:
|
||||
return theme.colorErrorText;
|
||||
case AlertState.Success:
|
||||
return isReportEnabled ? theme.colorSuccessText : theme.colorErrorText;
|
||||
return theme.colorSuccessText;
|
||||
case AlertState.Noop:
|
||||
return theme.colorSuccessText;
|
||||
case AlertState.Grace:
|
||||
@@ -57,9 +57,7 @@ export default function AlertStatusIcon({
|
||||
};
|
||||
switch (state) {
|
||||
case AlertState.Success:
|
||||
lastStateConfig.icon = isReportEnabled
|
||||
? Icons.CheckOutlined
|
||||
: Icons.WarningOutlined;
|
||||
lastStateConfig.icon = Icons.CheckOutlined;
|
||||
lastStateConfig.label = isReportEnabled
|
||||
? t('Report sent')
|
||||
: t('Alert triggered, notification sent');
|
||||
@@ -95,16 +93,30 @@ export default function AlertStatusIcon({
|
||||
lastStateConfig.status = AlertState.Noop;
|
||||
}
|
||||
const Icon = lastStateConfig.icon;
|
||||
const isRunningIcon = state === AlertState.Working;
|
||||
return (
|
||||
<Tooltip title={lastStateConfig.label} placement="bottomLeft">
|
||||
<Icon
|
||||
iconSize="m"
|
||||
iconColor={getStatusColor(
|
||||
lastStateConfig.status,
|
||||
isReportEnabled,
|
||||
theme,
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
css={
|
||||
isRunningIcon
|
||||
? css`
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transform: scale(1.8);
|
||||
`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
iconSize="m"
|
||||
iconColor={getStatusColor(
|
||||
lastStateConfig.status,
|
||||
isReportEnabled,
|
||||
theme,
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -571,12 +571,7 @@ const RightMenu = ({
|
||||
if (!navbarRight.user_is_anonymous && showActionDropdown) {
|
||||
items.push({
|
||||
key: 'new-dropdown',
|
||||
label: (
|
||||
<Icons.PlusOutlined
|
||||
iconColor={theme.colorPrimary}
|
||||
data-test="new-dropdown-icon"
|
||||
/>
|
||||
),
|
||||
label: <Icons.PlusOutlined data-test="new-dropdown-icon" />,
|
||||
className: 'submenu-with-caret',
|
||||
icon: <Icons.CaretDownOutlined iconSize="xs" />,
|
||||
children: buildNewDropdownItems(),
|
||||
|
||||
@@ -132,6 +132,9 @@ function RoleListEditModal({
|
||||
mapResult: (user: UserObject) => ({
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
first_name: user.first_name,
|
||||
last_name: user.last_name,
|
||||
email: user.email,
|
||||
}),
|
||||
});
|
||||
}, [user_ids]);
|
||||
|
||||
187
superset-websocket/package-lock.json
generated
187
superset-websocket/package-lock.json
generated
@@ -29,7 +29,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.26.0",
|
||||
"@typescript-eslint/parser": "^8.33.0",
|
||||
"@typescript-eslint/parser": "^8.42.0",
|
||||
"eslint": "^9.34.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
@@ -2069,16 +2069,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz",
|
||||
"integrity": "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz",
|
||||
"integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.33.0",
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/typescript-estree": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0",
|
||||
"@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"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2090,18 +2090,18 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^8.57.0 || ^9.0.0",
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.0.tgz",
|
||||
"integrity": "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz",
|
||||
"integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0"
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2112,9 +2112,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz",
|
||||
"integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
|
||||
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2126,16 +2126,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.0.tgz",
|
||||
"integrity": "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz",
|
||||
"integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.33.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.33.0",
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0",
|
||||
"@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",
|
||||
@@ -2151,18 +2151,18 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.0.tgz",
|
||||
"integrity": "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz",
|
||||
"integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"eslint-visitor-keys": "^4.2.0"
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -2173,9 +2173,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2183,9 +2183,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/eslint-visitor-keys": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
@@ -2225,14 +2225,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.0.tgz",
|
||||
"integrity": "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz",
|
||||
"integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.33.0",
|
||||
"@typescript-eslint/types": "^8.33.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.42.0",
|
||||
"@typescript-eslint/types": "^8.42.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -2241,12 +2241,15 @@
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz",
|
||||
"integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
|
||||
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2275,9 +2278,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz",
|
||||
"integrity": "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz",
|
||||
"integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2288,7 +2291,7 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <5.9.0"
|
||||
"typescript": ">=4.8.4 <6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
@@ -8662,44 +8665,44 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/parser": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz",
|
||||
"integrity": "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.42.0.tgz",
|
||||
"integrity": "sha512-r1XG74QgShUgXph1BYseJ+KZd17bKQib/yF3SR+demvytiRXrwd12Blnz5eYGm8tXaeRdd4x88MlfwldHoudGg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/scope-manager": "8.33.0",
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/typescript-estree": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0",
|
||||
"@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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.0.tgz",
|
||||
"integrity": "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.42.0.tgz",
|
||||
"integrity": "sha512-51+x9o78NBAVgQzOPd17DkNTnIzJ8T/O2dmMBLoK9qbY0Gm52XJcdJcCl18ExBMiHo6jPMErUQWUv5RLE51zJw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0"
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"@typescript-eslint/visitor-keys": "8.42.0"
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz",
|
||||
"integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
|
||||
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
|
||||
"dev": true
|
||||
},
|
||||
"@typescript-eslint/typescript-estree": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.0.tgz",
|
||||
"integrity": "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.42.0.tgz",
|
||||
"integrity": "sha512-ku/uYtT4QXY8sl9EDJETD27o3Ewdi72hcXg1ah/kkUgBvAYHLwj2ofswFFNXS+FL5G+AGkxBtvGt8pFBHKlHsQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/project-service": "8.33.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.33.0",
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"@typescript-eslint/visitor-keys": "8.33.0",
|
||||
"@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",
|
||||
@@ -8709,28 +8712,28 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/visitor-keys": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.0.tgz",
|
||||
"integrity": "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.42.0.tgz",
|
||||
"integrity": "sha512-3WbiuzoEowaEn8RSnhJBrxSwX8ULYE9CXaPepS2C2W3NSA5NNIvBaslpBSBElPq0UGr0xVJlXFWOAKIkyylydQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/types": "8.33.0",
|
||||
"eslint-visitor-keys": "^4.2.0"
|
||||
"@typescript-eslint/types": "8.42.0",
|
||||
"eslint-visitor-keys": "^4.2.1"
|
||||
}
|
||||
},
|
||||
"brace-expansion": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
|
||||
"integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
|
||||
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"balanced-match": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"eslint-visitor-keys": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz",
|
||||
"integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==",
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz",
|
||||
"integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==",
|
||||
"dev": true
|
||||
},
|
||||
"minimatch": {
|
||||
@@ -8752,20 +8755,20 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/project-service": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.0.tgz",
|
||||
"integrity": "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.42.0.tgz",
|
||||
"integrity": "sha512-vfVpLHAhbPjilrabtOSNcUDmBboQNrJUiNAGoImkZKnMjs2TIcWG33s4Ds0wY3/50aZmTMqJa6PiwkwezaAklg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.33.0",
|
||||
"@typescript-eslint/types": "^8.33.0",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.42.0",
|
||||
"@typescript-eslint/types": "^8.42.0",
|
||||
"debug": "^4.3.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz",
|
||||
"integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.42.0.tgz",
|
||||
"integrity": "sha512-LdtAWMiFmbRLNP7JNeY0SqEtJvGMYSzfiWBSmx+VSZ1CH+1zyl8Mmw1TT39OrtsRvIYShjJWzTDMPWZJCpwBlw==",
|
||||
"dev": true
|
||||
}
|
||||
}
|
||||
@@ -8781,9 +8784,9 @@
|
||||
}
|
||||
},
|
||||
"@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.33.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz",
|
||||
"integrity": "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==",
|
||||
"version": "8.42.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.42.0.tgz",
|
||||
"integrity": "sha512-kHeFUOdwAJfUmYKjR3CLgZSglGHjbNTi1H8sTYRYV2xX6eNz4RyJ2LIgsDLKf8Yi0/GL1WZAC/DgZBeBft8QAQ==",
|
||||
"dev": true,
|
||||
"requires": {}
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user