Compare commits

..

1 Commits

Author SHA1 Message Date
Maxime Beauchemin
59ddd52789 fix(sqllab): Set explicit Content-Type headers to prevent HTTP 406 errors
Fixes #36072 where SQL Lab queries with WHERE clauses failed with
"Database error: Not acceptable" in Superset v4.1+.

Root cause: Flask 2.3+ (upgraded in v4.1.0) has stricter content
negotiation that could return HTTP 406 when Content-Type headers
aren't explicitly set, particularly with ENABLE_PROXY_FIX or certain
Accept header configurations.

Changes:
- Add explicit Content-Type headers to /api/v1/sqllab/execute/ and
  /api/v1/sqllab/results/ endpoints
- Improve error handling with try-except blocks for result fetching
  and JSON serialization
- Add targeted integration test for WHERE clause queries

The fix ensures Flask 2.3+ doesn't attempt content negotiation that
could fail, while maintaining backward compatibility.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-13 10:47:44 -08:00
239 changed files with 3080 additions and 21452 deletions

1
.gitignore vendored
View File

@@ -33,7 +33,6 @@ cover
.env
.envrc
.idea
.roo
.mypy_cache
.python-version
.tox

View File

@@ -53,7 +53,7 @@ extension-pkg-whitelist=pyarrow
[MESSAGES CONTROL]
disable=all
enable=json-import,disallowed-sql-import,consider-using-transaction
enable=disallowed-json-import,disallowed-sql-import,consider-using-transaction
[REPORTS]

View File

@@ -18,7 +18,7 @@
######################################################################
# Node stage to deal with static asset construction
######################################################################
ARG PY_VER=3.11.14-slim-trixie
ARG PY_VER=3.11.13-slim-trixie
# If BUILDPLATFORM is null, set it to 'amd64' (or leave as is otherwise).
ARG BUILDPLATFORM=${BUILDPLATFORM:-amd64}

View File

@@ -23,107 +23,6 @@ This file documents any backwards-incompatible changes in Superset and
assists people when migrating to a new version.
## Next
### MCP Service
The MCP (Model Context Protocol) service enables AI assistants and automation tools to interact programmatically with Superset.
#### New Features
- MCP service infrastructure with FastMCP framework
- Tools for dashboards, charts, datasets, SQL Lab, and instance metadata
- Optional dependency: install with `pip install apache-superset[fastmcp]`
- Runs as separate process from Superset web server
- JWT-based authentication for production deployments
#### New Configuration Options
**Development** (single-user, local testing):
```python
# superset_config.py
MCP_DEV_USERNAME = "admin" # User for MCP authentication
MCP_SERVICE_HOST = "localhost"
MCP_SERVICE_PORT = 5008
```
**Production** (JWT-based, multi-user):
```python
# superset_config.py
MCP_AUTH_ENABLED = True
MCP_JWT_ISSUER = "https://your-auth-provider.com"
MCP_JWT_AUDIENCE = "superset-mcp"
MCP_JWT_ALGORITHM = "RS256" # or "HS256" for shared secrets
# Option 1: Use JWKS endpoint (recommended for RS256)
MCP_JWKS_URI = "https://auth.example.com/.well-known/jwks.json"
# Option 2: Use static public key (RS256)
MCP_JWT_PUBLIC_KEY = "-----BEGIN PUBLIC KEY-----..."
# Option 3: Use shared secret (HS256)
MCP_JWT_ALGORITHM = "HS256"
MCP_JWT_SECRET = "your-shared-secret-key"
# Optional overrides
MCP_SERVICE_HOST = "0.0.0.0"
MCP_SERVICE_PORT = 5008
MCP_SESSION_CONFIG = {
"SESSION_COOKIE_SECURE": True,
"SESSION_COOKIE_HTTPONLY": True,
"SESSION_COOKIE_SAMESITE": "Strict",
}
```
#### Running the MCP Service
```bash
# Development
superset mcp run --port 5008 --debug
# Production
superset mcp run --port 5008
# With factory config
superset mcp run --port 5008 --use-factory-config
```
#### Deployment Considerations
The MCP service runs as a **separate process** from the Superset web server.
**Important**:
- Requires same Python environment and configuration as Superset
- Shares database connections with main Superset app
- Can be scaled independently from web server
- Requires `fastmcp` package (optional dependency)
**Installation**:
```bash
# Install with MCP support
pip install apache-superset[fastmcp]
# Or add to requirements.txt
apache-superset[fastmcp]>=X.Y.Z
```
**Process Management**:
Use systemd, supervisord, or Kubernetes to manage the MCP service process.
See `superset/mcp_service/PRODUCTION.md` for deployment guides.
**Security**:
- Development: Uses `MCP_DEV_USERNAME` for single-user access
- Production: **MUST** configure JWT authentication
- See `superset/mcp_service/SECURITY.md` for details
#### Documentation
- Architecture: `superset/mcp_service/ARCHITECTURE.md`
- Security: `superset/mcp_service/SECURITY.md`
- Production: `superset/mcp_service/PRODUCTION.md`
- Developer Guide: `superset/mcp_service/CLAUDE.md`
- Quick Start: `superset/mcp_service/README.md`
---
- [33055](https://github.com/apache/superset/pull/33055): Upgrades Flask-AppBuilder to 5.0.0. The AUTH_OID authentication type has been deprecated and is no longer available as an option in Flask-AppBuilder. OpenID (OID) is considered a deprecated authentication protocol - if you are using AUTH_OID, you will need to migrate to an alternative authentication method such as OAuth, LDAP, or database authentication before upgrading.
- [35062](https://github.com/apache/superset/pull/35062): Changed the function signature of `setupExtensions` to `setupCodeOverrides` with options as arguments.
- [34871](https://github.com/apache/superset/pull/34871): Fixed Jest test hanging issue from Ant Design v5 upgrade. MessageChannel is now mocked in test environment to prevent rc-overflow from causing Jest to hang. Test environment only - no production impact.

View File

@@ -80,7 +80,7 @@ case "${1}" in
;;
app)
echo "Starting web app (using development server)..."
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0 --exclude-patterns "*/node_modules/*:*/.venv/*:*/build/*:*/__pycache__/*"
flask run -p $PORT --reload --debugger --without-threads --host=0.0.0.0
;;
app-gunicorn)
echo "Starting web app..."

View File

@@ -26,8 +26,6 @@ under the License.
Extensions interact with Superset through well-defined, versioned APIs provided by the `@apache-superset/core` (frontend) and `apache-superset-core` (backend) packages. These APIs are designed to be stable, discoverable, and consistent for both built-in and external extensions.
**Note**: The `superset_core.api` module provides abstract classes that are replaced with concrete implementations via dependency injection when Superset initializes. This allows extensions to use the same interfaces as the host application.
**Frontend APIs** (via `@apache-superset/core)`:
The frontend extension APIs in Superset are organized into logical namespaces such as `authentication`, `commands`, `extensions`, `sqlLab`, and others. Each namespace groups related functionality, making it easy for extension authors to discover and use the APIs relevant to their needs. For example, the `sqlLab` namespace provides events and methods specific to SQL Lab, allowing extensions to react to user actions and interact with the SQL Lab environment:
@@ -92,38 +90,31 @@ Backend APIs follow a similar pattern, providing access to Superset's models, se
Extension endpoints are registered under a dedicated `/extensions` namespace to avoid conflicting with built-in endpoints and also because they don't share the same version constraints. By grouping all extension endpoints under `/extensions`, Superset establishes a clear boundary between core and extension functionality, making it easier to manage, document, and secure both types of APIs.
``` python
from superset_core.api.models import Database, get_session
from superset_core.api.daos import DatabaseDAO
from superset_core.api.rest_api import add_extension_api
from superset_core.api import rest_api, models, query
from .api import DatasetReferencesAPI
# Register a new extension REST API
add_extension_api(DatasetReferencesAPI)
rest_api.add_extension_api(DatasetReferencesAPI)
# Fetch Superset entities via the DAO to apply base filters that filter out entities
# that the user doesn't have access to
databases = DatabaseDAO.find_all()
# ..or apply simple filters on top of base filters
databases = DatabaseDAO.filter_by(uuid=database.uuid)
# Access Superset models with simple queries that filter out entities that
# the user doesn't have access to
databases = models.get_databases(id=database_id)
if not databases:
raise Exception("Database not found")
return self.response_404()
return databases[0]
database = databases[0]
# Perform complex queries using SQLAlchemy Query, also filtering out
# inaccessible entities
session = get_session()
databases_query = session.query(Database).filter(
Database.database_name.ilike("%abc%")
)
return DatabaseDAO.query(databases_query)
# Perform complex queries using SQLAlchemy BaseQuery, also filtering
# out inaccessible entities
session = models.get_session()
db_model = models.get_database_model())
database_query = session.query(db_model.database_name.ilike("%abc%")
databases_containing_abc = models.get_databases(query)
# Bypass security model for highly custom use cases
session = get_session()
all_databases_containing_abc = session.query(Database).filter(
Database.database_name.ilike("%abc%")
).all()
session = models.get_session()
db_model = models.get_database_model())
all_databases_containg_abc = session.query(db_model.database_name.ilike("%abc%").all()
```
In the future, we plan to expand the backend APIs to support configuring security models, database engines, SQL Alchemy dialects, etc.

View File

@@ -128,7 +128,7 @@ The CLI generated a basic `backend/src/hello_world/entrypoint.py`. We'll create
```python
from flask import Response
from flask_appbuilder.api import expose, protect, safe
from superset_core.api.rest_api import RestApi
from superset_core.api.types.rest_api import RestApi
class HelloWorldAPI(RestApi):

View File

@@ -1,101 +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.
-->
# pkg_resources Deprecation and Migration Guide
## Background
As of setuptools 81.0.0 (scheduled for removal around 2025-11-30), the `pkg_resources` API is deprecated and will be removed. This affects several packages in the Python ecosystem.
## Current Status
### Superset Codebase ✅
The Superset codebase has already migrated away from `pkg_resources` to the modern `importlib.metadata` API:
- `superset/db_engine_specs/__init__.py:36` - Uses `from importlib.metadata import entry_points`
- All entry point loading uses the modern API
### Production Dependencies ⚠️
Some third-party dependencies may still use `pkg_resources`:
- **`clients` package** (Preset-specific): Uses `pkg_resources` in `const.py`
- Error path: `/usr/local/lib/python3.10/site-packages/clients/const.py:1`
## Migration Path
### Short-term Solution (Current)
Pin setuptools to version 80.x to prevent breaking changes:
```python
# requirements/base.in
setuptools<81
```
This prevents the removal of `pkg_resources` while dependent packages are updated.
### Long-term Solution
Update all dependencies to use `importlib.metadata` instead of `pkg_resources`:
#### Migration Example
**Old (deprecated):**
```python
import pkg_resources
version = pkg_resources.get_distribution("package_name").version
entry_points = pkg_resources.iter_entry_points("group_name")
```
**New (recommended):**
```python
from importlib.metadata import version, entry_points
pkg_version = version("package_name")
eps = entry_points(group="group_name")
```
## Action Items
### For Preset Team
1. **Update `clients` package** to use `importlib.metadata` instead of `pkg_resources`
2. **Review other internal packages** for `pkg_resources` usage
3. **Test with setuptools >= 81.0.0** once all packages are migrated
4. **Monitor Datadog logs** for similar deprecation warnings
### For Superset Maintainers
1. ✅ Already using `importlib.metadata`
2. Monitor third-party dependencies for updates
3. Update setuptools pin once ecosystem is ready
## Timeline
- **2025-11-30**: Expected removal of `pkg_resources` from setuptools
- **Before then**: All dependencies must migrate to `importlib.metadata`
## References
- [setuptools pkg_resources deprecation notice](https://setuptools.pypa.io/en/latest/pkg_resources.html)
- [importlib.metadata documentation](https://docs.python.org/3/library/importlib.metadata.html)
- [Migration guide](https://setuptools.pypa.io/en/latest/deprecated/pkg_resources.html)
## Monitoring
Track this issue in production using Datadog:
- Warning pattern: `pkg_resources is deprecated as an API`
- Component: `@component:app`
- Environment: `environment:production`

View File

@@ -487,7 +487,7 @@ const config: Config = {
'data-project-name': 'Apache Superset',
'data-project-color': '#FFFFFF',
'data-project-logo':
'https://superset.apache.org/img/superset-logo-icon-only.png',
'https://images.seeklogo.com/logo-png/50/2/superset-icon-logo-png_seeklogo-500354.png',
'data-modal-override-open-id': 'ask-ai-input',
'data-modal-override-open-class': 'search-input',
'data-modal-disclaimer':

View File

@@ -49,8 +49,8 @@
"@storybook/preview-api": "^8.6.11",
"@storybook/theming": "^8.6.11",
"@superset-ui/core": "^0.20.4",
"antd": "^5.29.1",
"caniuse-lite": "^1.0.30001756",
"antd": "^5.28.0",
"caniuse-lite": "^1.0.30001754",
"docusaurus-plugin-less": "^2.0.2",
"json-bigint": "^1.0.0",
"less": "^4.4.2",
@@ -70,19 +70,19 @@
"devDependencies": {
"@docusaurus/module-type-aliases": "^3.9.1",
"@docusaurus/tsconfig": "^3.9.2",
"@eslint/js": "^9.39.1",
"@eslint/js": "^9.39.0",
"@types/react": "^19.1.8",
"@typescript-eslint/eslint-plugin": "^8.37.0",
"@typescript-eslint/parser": "^8.46.4",
"eslint": "^9.39.1",
"@typescript-eslint/parser": "^8.46.0",
"eslint": "^9.39.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.3",
"eslint-plugin-react": "^7.37.5",
"globals": "^16.5.0",
"prettier": "^3.6.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.47.0",
"webpack": "^5.103.0"
"typescript-eslint": "^8.46.2",
"webpack": "^5.102.1"
},
"browserslist": {
"production": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -1160,7 +1160,12 @@
dependencies:
core-js-pure "^3.43.0"
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
"@babel/runtime@^7.1.2", "@babel/runtime@^7.10.1", "@babel/runtime@^7.10.3", "@babel/runtime@^7.10.4", "@babel/runtime@^7.11.1", "@babel/runtime@^7.11.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.16.7", "@babel/runtime@^7.18.0", "@babel/runtime@^7.18.3", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.7", "@babel/runtime@^7.21.0", "@babel/runtime@^7.22.5", "@babel/runtime@^7.23.2", "@babel/runtime@^7.23.6", "@babel/runtime@^7.23.9", "@babel/runtime@^7.24.4", "@babel/runtime@^7.24.7", "@babel/runtime@^7.24.8", "@babel/runtime@^7.25.6", "@babel/runtime@^7.25.7", "@babel/runtime@^7.25.9", "@babel/runtime@^7.26.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.7.2":
version "7.28.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a"
integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==
"@babel/runtime@^7.28.4":
version "7.28.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.4.tgz#a70226016fabe25c5783b2f22d3e1c9bc5ca3326"
integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==
@@ -2466,10 +2471,10 @@
minimatch "^3.1.2"
strip-json-comments "^3.1.1"
"@eslint/js@9.39.1", "@eslint/js@^9.39.1":
version "9.39.1"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.1.tgz#0dd59c3a9f40e3f1882975c321470969243e0164"
integrity sha512-S26Stp4zCy88tH94QbBv3XCuzRQiZ9yXofEILmglYTh/Ug/a9/umqvgFtYBAo3Lp0nsI/5/qH1CCrbdK3AP1Tw==
"@eslint/js@9.39.0", "@eslint/js@^9.39.0":
version "9.39.0"
resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.0.tgz#e1955cefd1d79e80a9557274e9aa9bd3f641be01"
integrity sha512-BIhe0sW91JGPiaF1mOuPy5v8NflqfjIcDNpC+LbW9f609WVRX1rArrhi6Z2ymvrAry9jw+5POTj4t2t62o8Bmw==
"@eslint/object-schema@^2.1.7":
version "2.1.7"
@@ -4192,79 +4197,79 @@
dependencies:
"@types/yargs-parser" "*"
"@typescript-eslint/eslint-plugin@8.47.0", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.47.0.tgz#c53edeec13a79483f4ca79c298d5231b02e9dc17"
integrity sha512-fe0rz9WJQ5t2iaLfdbDc9T80GJy0AeO453q8C3YCilnGozvOyCG5t+EZtg7j7D88+c3FipfP/x+wzGnh1xp8ZA==
"@typescript-eslint/eslint-plugin@8.46.2", "@typescript-eslint/eslint-plugin@^8.37.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz#dc4ab93ee3d7e6c8e38820a0d6c7c93c7183e2dc"
integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==
dependencies:
"@eslint-community/regexpp" "^4.10.0"
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/type-utils" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/type-utils" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
graphemer "^1.4.0"
ignore "^7.0.0"
natural-compare "^1.4.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/parser@8.47.0", "@typescript-eslint/parser@^8.46.4":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.47.0.tgz#51b14ab2be2057ec0f57073b9ff3a9c078b0a964"
integrity sha512-lJi3PfxVmo0AkEY93ecfN+r8SofEqZNGByvHAI3GBLrvt1Cw6H5k1IM02nSzu0RfUafr2EvFSw0wAsZgubNplQ==
"@typescript-eslint/parser@8.46.2", "@typescript-eslint/parser@^8.46.0":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.46.2.tgz#dd938d45d581ac8ffa9d8a418a50282b306f7ebf"
integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==
dependencies:
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
"@typescript-eslint/project-service@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.47.0.tgz#b8afc65e0527568018af911b702dcfbfdca16471"
integrity sha512-2X4BX8hUeB5JcA1TQJ7GjcgulXQ+5UkNb0DL8gHsHUHdFoiCTJoYLTpib3LtSDPZsRET5ygN4qqIWrHyYIKERA==
"@typescript-eslint/project-service@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.46.2.tgz#ab2f02a0de4da6a7eeb885af5e059be57819d608"
integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.47.0"
"@typescript-eslint/types" "^8.47.0"
"@typescript-eslint/tsconfig-utils" "^8.46.2"
"@typescript-eslint/types" "^8.46.2"
debug "^4.3.4"
"@typescript-eslint/scope-manager@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.47.0.tgz#d1c36a973a5499fed3a99e2e6a66aec5c9b1e542"
integrity sha512-a0TTJk4HXMkfpFkL9/WaGTNuv7JWfFTQFJd6zS9dVAjKsojmv9HT55xzbEpnZoY+VUb+YXLMp+ihMLz/UlZfDg==
"@typescript-eslint/scope-manager@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz#7d37df2493c404450589acb3b5d0c69cc0670a88"
integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
"@typescript-eslint/tsconfig-utils@8.47.0", "@typescript-eslint/tsconfig-utils@^8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.47.0.tgz#4f178b62813538759e0989dd081c5474fad39b84"
integrity sha512-ybUAvjy4ZCL11uryalkKxuT3w3sXJAuWhOoGS3T/Wu+iUu1tGJmk5ytSY8gbdACNARmcYEB0COksD2j6hfGK2g==
"@typescript-eslint/tsconfig-utils@8.46.2", "@typescript-eslint/tsconfig-utils@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz#d110451cb93bbd189865206ea37ef677c196828c"
integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==
"@typescript-eslint/type-utils@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.47.0.tgz#b9b0141d99bd5bece3811d7eee68a002597ffa55"
integrity sha512-QC9RiCmZ2HmIdCEvhd1aJELBlD93ErziOXXlHEZyuBo3tBiAZieya0HLIxp+DoDWlsQqDawyKuNEhORyku+P8A==
"@typescript-eslint/type-utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz#802d027864e6fb752e65425ed09f3e089fb4d384"
integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
debug "^4.3.4"
ts-api-utils "^2.1.0"
"@typescript-eslint/types@8.47.0", "@typescript-eslint/types@^8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.47.0.tgz#c7fc9b6642d03505f447a8392934b9d1850de5af"
integrity sha512-nHAE6bMKsizhA2uuYZbEbmp5z2UpffNrPEqiKIeN7VsV6UY/roxanWfoRrf6x/k9+Obf+GQdkm0nPU+vnMXo9A==
"@typescript-eslint/types@8.46.2", "@typescript-eslint/types@^8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.46.2.tgz#2bad7348511b31e6e42579820e62b73145635763"
integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==
"@typescript-eslint/typescript-estree@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.47.0.tgz#86416dad58db76c4b3bd6a899b1381f9c388489a"
integrity sha512-k6ti9UepJf5NpzCjH31hQNLHQWupTRPhZ+KFF8WtTuTpy7uHPfeg2NM7cP27aCGajoEplxJDFVCEm9TGPYyiVg==
"@typescript-eslint/typescript-estree@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz#ab547a27e4222bb6a3281cb7e98705272e2c7d08"
integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==
dependencies:
"@typescript-eslint/project-service" "8.47.0"
"@typescript-eslint/tsconfig-utils" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/visitor-keys" "8.47.0"
"@typescript-eslint/project-service" "8.46.2"
"@typescript-eslint/tsconfig-utils" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/visitor-keys" "8.46.2"
debug "^4.3.4"
fast-glob "^3.3.2"
is-glob "^4.0.3"
@@ -4272,22 +4277,22 @@
semver "^7.6.0"
ts-api-utils "^2.1.0"
"@typescript-eslint/utils@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.47.0.tgz#d6c30690431dbfdab98fc027202af12e77c91419"
integrity sha512-g7XrNf25iL4TJOiPqatNuaChyqt49a/onq5YsJ9+hXeugK+41LVg7AxikMfM02PC6jbNtZLCJj6AUcQXJS/jGQ==
"@typescript-eslint/utils@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.46.2.tgz#b313d33d67f9918583af205bd7bcebf20f231732"
integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==
dependencies:
"@eslint-community/eslint-utils" "^4.7.0"
"@typescript-eslint/scope-manager" "8.47.0"
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/scope-manager" "8.46.2"
"@typescript-eslint/types" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/visitor-keys@8.47.0":
version "8.47.0"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.47.0.tgz#35f36ed60a170dfc9d4d738e78387e217f24c29f"
integrity sha512-SIV3/6eftCy1bNzCQoPmbWsRLujS8t5iDIZ4spZOBHqrM+yfX2ogg8Tt3PDTAVKw3sSCiUgg30uOAvK2r9zGjQ==
"@typescript-eslint/visitor-keys@8.46.2":
version "8.46.2"
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz#803fa298948c39acf810af21bdce6f8babfa9738"
integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==
dependencies:
"@typescript-eslint/types" "8.47.0"
"@typescript-eslint/types" "8.46.2"
eslint-visitor-keys "^4.2.1"
"@ungap/structured-clone@^1.0.0":
@@ -4602,10 +4607,10 @@ ansi-styles@^6.1.0:
resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5"
integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==
antd@^5.29.1:
version "5.29.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.29.1.tgz#e124b1aaa709a534816c42d558da02a917b995cc"
integrity sha512-TTFVbpKbyL6cPfEoKq6Ya3BIjTUr7uDW9+7Z+1oysRv1gpcN7kQ4luH8r/+rXXwz4n6BIz1iBJ1ezKCdsdNW0w==
antd@^5.28.0:
version "5.28.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-5.28.0.tgz#fb5dfc0a2ba5a90ee053c813d71f16e6b66ac994"
integrity sha512-AmCvyhWGHzlDQ6sfnGBBrFm/8sLPbBI8d/NDBsecliKqrTZUMr07TAQldo43iowwKzvgKxxuRoUHaBaYcBMdQA==
dependencies:
"@ant-design/colors" "^7.2.1"
"@ant-design/cssinjs" "^1.23.0"
@@ -5161,10 +5166,10 @@ caniuse-api@^3.0.0:
lodash.memoize "^4.1.2"
lodash.uniq "^4.5.0"
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001756:
version "1.0.30001756"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001756.tgz#fe80104631102f88e58cad8aa203a2c3e5ec9ebd"
integrity sha512-4HnCNKbMLkLdhJz3TToeVWHSnfJvPaq6vu/eRP0Ahub/07n484XHhBF5AJoSGHdVrS8tKFauUQz8Bp9P7LVx7A==
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001746, caniuse-lite@^1.0.30001754:
version "1.0.30001754"
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001754.tgz#7758299d9a72cce4e6b038788a15b12b44002759"
integrity sha512-x6OeBXueoAceOmotzx3PO4Zpt4rzpeIFsSr6AAePTZxSkXiYDUmpypEl7e2+8NCd9bD7bXjqyef8CJYPC1jfxg==
ccount@^2.0.0:
version "2.0.1"
@@ -6833,10 +6838,10 @@ eslint-visitor-keys@^4.2.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1"
integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==
eslint@^9.39.1:
version "9.39.1"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.1.tgz#be8bf7c6de77dcc4252b5a8dcb31c2efff74a6e5"
integrity sha512-BhHmn2yNOFA9H9JmmIVKJmd288g9hrVRDkdoIgRCRuSySRUHH7r/DI6aAXW9T1WwUuY3DFgrcaqB+deURBLR5g==
eslint@^9.39.0:
version "9.39.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.0.tgz#33c90ddf62b64e1e3f83b689934b336f21b5f0e5"
integrity sha512-iy2GE3MHrYTL5lrCtMZ0X1KLEKKUjmK0kzwcnefhR66txcEmXZD2YWgR5GNdcEwkNx3a0siYkSvl0vIC+Svjmg==
dependencies:
"@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.1"
@@ -6844,7 +6849,7 @@ eslint@^9.39.1:
"@eslint/config-helpers" "^0.4.2"
"@eslint/core" "^0.17.0"
"@eslint/eslintrc" "^3.3.1"
"@eslint/js" "9.39.1"
"@eslint/js" "9.39.0"
"@eslint/plugin-kit" "^0.4.1"
"@humanfs/node" "^0.16.6"
"@humanwhocodes/module-importer" "^1.0.1"
@@ -8716,10 +8721,10 @@ lines-and-columns@^1.1.6:
resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632"
integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==
loader-runner@^4.3.1:
version "4.3.1"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3"
integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==
loader-runner@^4.2.0:
version "4.3.0"
resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1"
integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==
loader-utils@^2.0.0:
version "2.0.4"
@@ -13358,15 +13363,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
typescript-eslint@^8.47.0:
version "8.47.0"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.47.0.tgz#bb8fcf4f2c69ffcd5d088f7f30cd52936ff05cbc"
integrity sha512-Lwe8i2XQ3WoMjua/r1PHrCTpkubPYJCAfOurtn+mtTzqB6jNd+14n9UN1bJ4s3F49x9ixAm0FLflB/JzQ57M8Q==
typescript-eslint@^8.46.2:
version "8.46.2"
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.46.2.tgz#da1adec683ba93a1b6c3850a4efb0922ffbc627d"
integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==
dependencies:
"@typescript-eslint/eslint-plugin" "8.47.0"
"@typescript-eslint/parser" "8.47.0"
"@typescript-eslint/typescript-estree" "8.47.0"
"@typescript-eslint/utils" "8.47.0"
"@typescript-eslint/eslint-plugin" "8.46.2"
"@typescript-eslint/parser" "8.46.2"
"@typescript-eslint/typescript-estree" "8.46.2"
"@typescript-eslint/utils" "8.46.2"
typescript@~5.9.3:
version "5.9.3"
@@ -13904,10 +13909,10 @@ webpack-virtual-modules@^0.6.2:
resolved "https://registry.yarnpkg.com/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz#057faa9065c8acf48f24cb57ac0e77739ab9a7e8"
integrity sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==
webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
version "5.103.0"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.103.0.tgz#17a7c5a5020d5a3a37c118d002eade5ee2c6f3da"
integrity sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==
webpack@^5.102.1, webpack@^5.88.1, webpack@^5.95.0:
version "5.102.1"
resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.102.1.tgz#1003a3024741a96ba99c37431938bf61aad3d988"
integrity sha512-7h/weGm9d/ywQ6qzJ+Xy+r9n/3qgp/thalBbpOi5i223dPXKi04IBtqPN9nTd+jBc7QKfvDbaBnFipYp4sJAUQ==
dependencies:
"@types/eslint-scope" "^3.7.7"
"@types/estree" "^1.0.8"
@@ -13926,7 +13931,7 @@ webpack@^5.103.0, webpack@^5.88.1, webpack@^5.95.0:
glob-to-regexp "^0.4.1"
graceful-fs "^4.2.11"
json-parse-even-better-errors "^2.3.1"
loader-runner "^4.3.1"
loader-runner "^4.2.0"
mime-types "^2.1.27"
neo-async "^2.6.2"
schema-utils "^4.3.3"

View File

@@ -48,7 +48,7 @@ dependencies = [
"cryptography>=42.0.4, <45.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=5.0.2,<6",
"flask-appbuilder>=5.0.0,<6",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",
@@ -133,7 +133,8 @@ denodo = ["denodo-sqlalchemy~=1.0.6"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
druid = ["pydruid>=0.6.5,<0.7"]
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
# DuckDB 1.x has type system incompatibilities with duckdb-engine.
duckdb = ["duckdb>=0.10.2,<0.11", "duckdb-engine>=0.17.0"]
dynamodb = ["pydynamodb>=0.4.2"]
solr = ["sqlalchemy-solr >= 0.2.0"]
elasticsearch = ["elasticsearch-dbapi>=0.2.9, <0.3.0"]

View File

@@ -36,9 +36,3 @@ marshmallow-sqlalchemy>=1.3.0,<1.4.1
# needed for python 3.12 support
openapi-schema-validator>=0.6.3
# Pin setuptools <81 until all dependencies migrate from pkg_resources to importlib.metadata
# pkg_resources is deprecated and will be removed in setuptools 81+ (around 2025-11-30)
# Known affected packages: Preset's 'clients' package
# See docs/docs/contributing/pkg-resources-migration.md for details
setuptools<81

View File

@@ -116,7 +116,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==5.0.2
flask-appbuilder==5.0.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
@@ -364,8 +364,6 @@ rsa==4.9.1
# via google-auth
selenium==4.32.0
# via apache-superset (pyproject.toml)
setuptools==80.9.0
# via -r requirements/base.in
shillelagh==1.4.3
# via apache-superset (pyproject.toml)
simplejson==3.20.1
@@ -386,7 +384,6 @@ sqlalchemy==1.4.54
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# flask-appbuilder
# flask-sqlalchemy
# marshmallow-sqlalchemy
@@ -395,12 +392,9 @@ sqlalchemy==1.4.54
sqlalchemy-utils==0.38.3
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==27.15.2
# via
# apache-superset (pyproject.toml)
# apache-superset-core
# via apache-superset (pyproject.toml)
sshtunnel==0.4.0
# via apache-superset (pyproject.toml)
tabulate==0.9.0
@@ -415,7 +409,6 @@ typing-extensions==4.15.0
# via
# apache-superset (pyproject.toml)
# alembic
# apache-superset-core
# cattrs
# limits
# pydantic

View File

@@ -48,7 +48,7 @@ attrs==25.3.0
# referencing
# requests-cache
# trio
authlib==1.6.5
authlib==1.6.4
# via fastmcp
babel==2.17.0
# via
@@ -179,7 +179,7 @@ cryptography==44.0.3
# secretstorage
cycler==0.12.1
# via matplotlib
cyclopts==4.2.4
cyclopts==3.24.0
# via fastmcp
db-dtypes==1.3.1
# via pandas-gbq
@@ -211,7 +211,7 @@ docstring-parser==0.17.0
# via cyclopts
docutils==0.22.2
# via rich-rst
duckdb==1.4.2
duckdb==0.10.3
# via
# apache-superset
# duckdb-engine
@@ -228,7 +228,7 @@ et-xmlfile==2.0.0
# openpyxl
exceptiongroup==1.3.0
# via fastmcp
fastmcp==2.13.1
fastmcp==2.13.0.2
# via apache-superset
filelock==3.12.2
# via virtualenv
@@ -249,7 +249,7 @@ flask==2.3.3
# flask-sqlalchemy
# flask-testing
# flask-wtf
flask-appbuilder==5.0.2
flask-appbuilder==5.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -884,7 +884,7 @@ rsa==4.9.1
# google-auth
ruff==0.8.0
# via apache-superset
secretstorage==3.4.1
secretstorage==3.4.0
# via keyring
selenium==4.32.0
# via
@@ -892,9 +892,8 @@ selenium==4.32.0
# apache-superset
semver==3.0.4
# via apache-superset-extensions-cli
setuptools==80.9.0
setuptools==80.7.1
# via
# -c requirements/base-constraint.txt
# nodeenv
# pandas-gbq
# pydata-google-auth
@@ -933,7 +932,6 @@ sqlalchemy==1.4.54
# -c requirements/base-constraint.txt
# alembic
# apache-superset
# apache-superset-core
# duckdb-engine
# flask-appbuilder
# flask-sqlalchemy
@@ -947,13 +945,11 @@ sqlalchemy-utils==0.38.3
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==27.15.2
# via
# -c requirements/base-constraint.txt
# apache-superset
# apache-superset-core
sqloxide==0.1.51
# via apache-superset
sse-starlette==3.0.2
@@ -993,7 +989,6 @@ typing-extensions==4.15.0
# alembic
# anyio
# apache-superset
# apache-superset-core
# cattrs
# exceptiongroup
# limits
@@ -1030,9 +1025,7 @@ urllib3==2.5.0
# requests-cache
# selenium
uvicorn==0.37.0
# via
# fastmcp
# mcp
# via mcp
vine==5.1.0
# via
# -c requirements/base-constraint.txt

View File

@@ -19,23 +19,6 @@
set -e
# If not already running in Docker, run this script inside Docker
if [ -z "$RUNNING_IN_DOCKER" ]; then
# Extract "current" Python version from CI config (single source of truth)
PYTHON_VERSION=$(grep -A 1 'if.*"current"' .github/actions/setup-backend/action.yml | grep 'PYTHON_VERSION=' | sed 's/.*PYTHON_VERSION=\([0-9.]*\).*/\1/')
echo "Running in Docker (Python ${PYTHON_VERSION} on Linux)..."
docker run --rm \
-v "$(pwd)":/app \
-w /app \
-e RUNNING_IN_DOCKER=1 \
python:${PYTHON_VERSION}-slim \
bash -c "pip install uv && ./scripts/uv-pip-compile.sh $*"
exit $?
fi
ADDITIONAL_ARGS="$@"
# Generate the requirements/base.txt file

View File

@@ -30,9 +30,7 @@ with open(PACKAGE_JSON) as package_file:
def get_git_sha() -> str:
try:
output = subprocess.check_output(
["git", "rev-parse", "HEAD"]
) # noqa: S603, S607
output = subprocess.check_output(["git", "rev-parse", "HEAD"]) # noqa: S603, S607
return output.decode().strip()
except Exception: # pylint: disable=broad-except
return ""
@@ -60,9 +58,6 @@ setup(
include_package_data=True,
zip_safe=False,
entry_points={
"superset.semantic_layers": [
"snowflake = superset.semantic_layers.snowflake:SnowflakeSemanticLayer"
],
"console_scripts": ["superset=superset.cli.main:superset"],
# the `postgres` and `postgres+psycopg2://` schemes were removed in SQLAlchemy 1.4 # noqa: E501
# add an alias here to prevent breaking existing databases

View File

@@ -49,7 +49,7 @@ The package is organized into logical modules, each providing specific functiona
from flask import request, Response
from flask_appbuilder.api import expose, permission_name, protect, safe
from superset_core.api import models, query, rest_api
from superset_core.api.rest_api import RestApi
from superset_core.api.types.rest_api import RestApi
class DatasetReferencesAPI(RestApi):
"""Example extension API demonstrating core functionality."""

View File

@@ -42,11 +42,7 @@ classifiers = [
"Topic :: Software Development :: Libraries :: Python Modules",
]
dependencies = [
"flask-appbuilder>=5.0.2,<6",
"sqlalchemy>=1.4.0,<2.0",
"sqlalchemy-utils>=0.38.0",
"sqlglot>=27.15.2, <28",
"typing-extensions>=4.0.0",
"flask-appbuilder>=5.0.0,<6",
]
[project.urls]

View File

@@ -14,7 +14,3 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""
Apache Superset Core - Public API with core functions of Superset
"""

View File

@@ -14,3 +14,11 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from .types.models import CoreModelsApi
from .types.query import CoreQueryApi
from .types.rest_api import CoreRestApi
models: CoreModelsApi
rest_api: CoreRestApi
query: CoreQueryApi

View File

@@ -1,262 +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.
"""
Data Access Object API for superset-core.
Provides dependency-injected DAO classes that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.daos import DatasetDAO, DatabaseDAO
# Use standard BaseDAO methods
datasets = DatasetDAO.find_all()
dataset = DatasetDAO.find_one_or_none(id=123)
DatasetDAO.create(attributes={"name": "New Dataset"})
"""
from abc import ABC, abstractmethod
from typing import Any, ClassVar, Generic, TypeVar
from flask_appbuilder.models.filters import BaseFilter
from sqlalchemy.orm import Query as SQLAQuery
from superset_core.api.models import (
Chart,
CoreModel,
Dashboard,
Database,
Dataset,
KeyValue,
Query,
SavedQuery,
Tag,
User,
)
# Type variable bound to our CoreModel
T = TypeVar("T", bound=CoreModel)
class BaseDAO(Generic[T], ABC):
"""
Abstract base class for DAOs.
This ABC defines the base that all DAOs should implement,
providing consistent CRUD operations across Superset and extensions.
"""
# Due to mypy limitations, we can't have `type[T]` here
model_cls: ClassVar[type[Any] | None]
base_filter: ClassVar[BaseFilter | None]
id_column_name: ClassVar[str]
uuid_column_name: ClassVar[str]
@classmethod
@abstractmethod
def find_all(cls) -> list[T]:
"""Get all entities that fit the base_filter."""
...
@classmethod
@abstractmethod
def find_one_or_none(cls, **filter_by: Any) -> T | None:
"""Get the first entity that fits the base_filter."""
...
@classmethod
@abstractmethod
def create(
cls,
item: T | None = None,
attributes: dict[str, Any] | None = None,
) -> T:
"""Create an object from the specified item and/or attributes."""
...
@classmethod
@abstractmethod
def update(
cls,
item: T | None = None,
attributes: dict[str, Any] | None = None,
) -> T:
"""Update an object from the specified item and/or attributes."""
...
@classmethod
@abstractmethod
def delete(cls, items: list[T]) -> None:
"""Delete the specified items."""
...
@classmethod
@abstractmethod
def query(cls, query: SQLAQuery) -> list[T]:
"""Execute query with base_filter applied."""
...
@classmethod
@abstractmethod
def filter_by(cls, **filter_by: Any) -> list[T]:
"""Get all entries that fit the base_filter."""
...
class DatasetDAO(BaseDAO[Dataset]):
"""
Abstract Dataset DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class DatabaseDAO(BaseDAO[Database]):
"""
Abstract Database DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class ChartDAO(BaseDAO[Chart]):
"""
Abstract Chart DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class DashboardDAO(BaseDAO[Dashboard]):
"""
Abstract Dashboard DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
uuid_column_name = "uuid"
class UserDAO(BaseDAO[User]):
"""
Abstract User DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class QueryDAO(BaseDAO[Query]):
"""
Abstract Query DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class SavedQueryDAO(BaseDAO[SavedQuery]):
"""
Abstract SavedQuery DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class TagDAO(BaseDAO[Tag]):
"""
Abstract Tag DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
class KeyValueDAO(BaseDAO[KeyValue]):
"""
Abstract KeyValue DAO interface.
Host implementations will replace this class during initialization
with a concrete implementation providing actual functionality.
"""
# Class variables that will be set by host implementation
model_cls = None
base_filter = None
id_column_name = "id"
__all__ = [
"BaseDAO",
"DatasetDAO",
"DatabaseDAO",
"ChartDAO",
"DashboardDAO",
"UserDAO",
"QueryDAO",
"SavedQueryDAO",
"TagDAO",
"KeyValueDAO",
]

View File

@@ -1,295 +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.
"""
Model API for superset-core.
Provides model classes that will be replaced by host implementations
during initialization for extension developers to use.
Usage:
from superset_core.api.models import Dataset, Database, get_session
# Use as regular model classes
dataset = Dataset(name="My Dataset")
db = Database(database_name="My DB")
session = get_session()
"""
from datetime import datetime
from typing import Any
from uuid import UUID
from flask_appbuilder import Model
from sqlalchemy.orm import scoped_session
class CoreModel(Model):
"""
Abstract base class that extends Flask-AppBuilder's Model.
This base class provides the interface contract for all Superset models.
The host package provides concrete implementations.
"""
__abstract__ = True
class Database(CoreModel):
"""
Abstract class for Database models.
This abstract class defines the contract that database models should implement,
providing consistent database connectivity and metadata operations.
"""
__abstract__ = True
id: int
verbose_name: str
database_name: str | None
@property
def name(self) -> str:
raise NotImplementedError
@property
def backend(self) -> str:
raise NotImplementedError
@property
def data(self) -> dict[str, Any]:
raise NotImplementedError
class Dataset(CoreModel):
"""
Abstract class for Dataset models.
This abstract class defines the contract that dataset models should implement,
providing consistent data source operations and metadata.
It provides the public API for Datasets implemented by the host application.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
table_name: str | None
main_dttm_col: str | None
database_id: int | None
schema: str | None
catalog: str | None
sql: str | None # For virtual datasets
description: str | None
default_endpoint: str | None
is_featured: bool
filter_select_enabled: bool
offset: int
cache_timeout: int
params: str
perm: str | None
schema_perm: str | None
catalog_perm: str | None
is_managed_externally: bool
external_url: str | None
fetch_values_predicate: str | None
is_sqllab_view: bool
template_params: str | None
extra: str | None # JSON string
normalize_columns: bool
always_filter_main_dttm: bool
folders: str | None # JSON string
class Chart(CoreModel):
"""
Abstract Chart/Slice model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
slice_name: str | None
datasource_id: int | None
datasource_type: str | None
datasource_name: str | None
viz_type: str | None
params: str | None
query_context: str | None
description: str | None
cache_timeout: int
certified_by: str | None
certification_details: str | None
is_managed_externally: bool
external_url: str | None
class Dashboard(CoreModel):
"""
Abstract Dashboard model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
dashboard_title: str | None
position_json: str | None
description: str | None
css: str | None
json_metadata: str | None
slug: str | None
published: bool
certified_by: str | None
certification_details: str | None
is_managed_externally: bool
external_url: str | None
class User(CoreModel):
"""
Abstract User model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
username: str | None
email: str | None
first_name: str | None
last_name: str | None
active: bool
class Query(CoreModel):
"""
Abstract Query model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
client_id: str | None
database_id: int | None
sql: str | None
status: str | None
user_id: int | None
progress: int
error_message: str | None
class SavedQuery(CoreModel):
"""
Abstract SavedQuery model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
uuid: UUID | None
label: str | None
sql: str | None
database_id: int | None
description: str | None
user_id: int | None
class Tag(CoreModel):
"""
Abstract Tag model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
# Type hints for expected attributes (no actual field definitions)
id: int
name: str | None
type: str | None
class KeyValue(CoreModel):
"""
Abstract KeyValue model interface.
Host implementations will replace this class during initialization
with concrete implementation providing actual functionality.
"""
__abstract__ = True
id: int
uuid: UUID | None
resource: str | None
value: str | None # Encoded value
expires_on: datetime | None
created_by_fk: int | None
changed_by_fk: int | None
def get_session() -> scoped_session:
"""
Retrieve the SQLAlchemy session to directly interface with the
Superset models.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:returns: The SQLAlchemy scoped session instance.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = [
"Dataset",
"Database",
"Chart",
"Dashboard",
"User",
"Query",
"SavedQuery",
"Tag",
"KeyValue",
"CoreModel",
"get_session",
]

View File

@@ -1,51 +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.
"""
Query API for superset-core.
Provides dependency-injected query utility functions that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.query import get_sqlglot_dialect
dialect = get_sqlglot_dialect(database)
"""
from typing import TYPE_CHECKING
from sqlglot import Dialects
if TYPE_CHECKING:
from superset_core.api.models import Database
def get_sqlglot_dialect(database: "Database") -> Dialects:
"""
Get the SQLGlot dialect for the specified database.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param database: The database instance to get the dialect for.
:returns: The SQLGlot dialect enum corresponding to the database.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["get_sqlglot_dialect"]

View File

@@ -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.
"""
REST API functions for superset-core.
Provides dependency-injected REST API utility functions that will be replaced by
host implementations during initialization.
Usage:
from superset_core.api.rest_api import add_api, add_extension_api
add_api(MyCustomAPI)
add_extension_api(MyExtensionAPI)
"""
from flask_appbuilder.api import BaseApi
class RestApi(BaseApi):
"""
Base REST API class for Superset with browser login support.
This class extends Flask-AppBuilder's BaseApi and enables browser-based
authentication by default.
"""
allow_browser_login = True
def add_api(api: type[RestApi]) -> None:
"""
Add a REST API to the Superset API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param api: A REST API instance.
:returns: None.
"""
raise NotImplementedError("Function will be replaced during initialization")
def add_extension_api(api: type[RestApi]) -> None:
"""
Add an extension REST API to the Superset API.
Host implementations will replace this function during initialization
with a concrete implementation providing actual functionality.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
"""
raise NotImplementedError("Function will be replaced during initialization")
__all__ = ["RestApi", "add_api", "add_extension_api"]

View File

@@ -0,0 +1,90 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any, Type
from flask_sqlalchemy import BaseQuery
from sqlalchemy.orm import scoped_session
class CoreModelsApi(ABC):
"""
Abstract interface for accessing Superset data models.
This class defines the contract for retrieving SQLAlchemy sessions
and model instances for datasets and databases within Superset.
"""
@staticmethod
@abstractmethod
def get_session() -> scoped_session:
"""
Retrieve the SQLAlchemy session to directly interface with the
Superset models.
:returns: The SQLAlchemy scoped session instance.
"""
...
@staticmethod
@abstractmethod
def get_dataset_model() -> Type[Any]:
"""
Retrieve the Dataset (SqlaTable) SQLAlchemy model.
:returns: The Dataset SQLAlchemy model class.
"""
...
@staticmethod
@abstractmethod
def get_database_model() -> Type[Any]:
"""
Retrieve the Database SQLAlchemy model.
:returns: The Database SQLAlchemy model class.
"""
...
@staticmethod
@abstractmethod
def get_datasets(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
"""
Retrieve Dataset (SqlaTable) entities.
:param query: A query with the Dataset model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter datasets using SQLAlchemy's
`filter_by()`.
:returns: SqlaTable entities.
"""
...
@staticmethod
@abstractmethod
def get_databases(query: BaseQuery | None = None, **kwargs: Any) -> list[Any]:
"""
Retrieve Database entities.
:param query: A query with the Database model as the primary entity for complex
queries.
:param kwargs: Optional keyword arguments to filter databases using SQLAlchemy's
`filter_by()`.
:returns: Database entities.
"""
...

View File

@@ -14,3 +14,28 @@
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
from abc import ABC, abstractmethod
from typing import Any
from sqlglot import Dialects
class CoreQueryApi(ABC):
"""
Abstract interface for query-related operations.
This class defines the contract for database query operations,
including dialect handling and query processing.
"""
@staticmethod
@abstractmethod
def get_sqlglot_dialect(database: Any) -> Dialects:
"""
Get the SQLGlot dialect for the specified database.
:param database: The database instance to get the dialect for.
:returns: The SQLGlot dialect enum corresponding to the database.
"""
...

View File

@@ -0,0 +1,64 @@
# 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.
from abc import ABC, abstractmethod
from typing import Type
from flask_appbuilder.api import BaseApi
class RestApi(BaseApi):
"""
Base REST API class for Superset with browser login support.
This class extends Flask-AppBuilder's BaseApi and enables browser-based
authentication by default.
"""
allow_browser_login = True
class CoreRestApi(ABC):
"""
Abstract interface for managing REST APIs in Superset.
This class defines the contract for adding and managing REST APIs,
including both core APIs and extension APIs.
"""
@staticmethod
@abstractmethod
def add_api(api: Type[RestApi]) -> None:
"""
Add a REST API to the Superset API.
:param api: A REST API instance.
:returns: None.
"""
...
@staticmethod
@abstractmethod
def add_extension_api(api: Type[RestApi]) -> None:
"""
Add an extension REST API to the Superset API.
:param api: An extension REST API instance. These are placed under
the /extensions resource.
:returns: None.
"""
...

View File

@@ -6696,9 +6696,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"dependencies": {
"argparse": "^1.0.7",
@@ -12972,9 +12972,9 @@
"dev": true
},
"js-yaml": {
"version": "3.14.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz",
"integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==",
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"requires": {
"argparse": "^1.0.7",

View File

@@ -81,8 +81,6 @@ export type ObserveDataMaskCallbackFn = (
nativeFiltersChanged: boolean;
},
) => void;
export type ThemeMode = 'default' | 'dark' | 'system';
export type EmbeddedDashboard = {
getScrollSize: () => Promise<Size>;
unmount: () => void;
@@ -94,7 +92,6 @@ export type EmbeddedDashboard = {
getDataMask: () => Promise<Record<string, any>>;
getChartStates: () => Promise<Record<string, any>>;
setThemeConfig: (themeConfig: Record<string, any>) => void;
setThemeMode: (mode: ThemeMode) => void;
};
/**
@@ -268,18 +265,6 @@ export async function embedDashboard({
}
};
const setThemeMode = (mode: ThemeMode): void => {
try {
ourPort.emit('setThemeMode', { mode });
log(`Theme mode set to: ${mode}`);
} catch (error) {
log(
'Error sending theme mode. Ensure the iframe side implements the "setThemeMode" method.',
);
throw error;
}
};
return {
getScrollSize,
unmount,
@@ -288,7 +273,6 @@ export async function embedDashboard({
observeDataMask,
getDataMask,
getChartStates,
setThemeConfig,
setThemeMode,
setThemeConfig
};
}

View File

@@ -15,9 +15,6 @@
],
"dependencies": {
"@apache-superset/core": "file:packages/superset-core",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
@@ -4294,16 +4291,16 @@
}
},
"node_modules/@dnd-kit/sortable": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.3.0",
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
@@ -29403,20 +29400,6 @@
"url": "https://opencollective.com/geostyler"
}
},
"node_modules/geostyler/node_modules/@dnd-kit/sortable": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-8.0.0.tgz",
"integrity": "sha512-U3jk5ebVXe1Lr7c2wU7SBZjcWdQP+j7peHJfCspnA81enlu88Mgd7CC8Q+pub9ubP7eKVETzJW+IBAhsqbSu/g==",
"license": "MIT",
"dependencies": {
"@dnd-kit/utilities": "^3.2.2",
"tslib": "^2.0.0"
},
"peerDependencies": {
"@dnd-kit/core": "^6.1.0",
"react": ">=16.8.0"
}
},
"node_modules/geostyler/node_modules/geostyler-style": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/geostyler-style/-/geostyler-style-8.1.0.tgz",
@@ -66264,6 +66247,17 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"packages/superset-ui-demo/node_modules/core-js": {
"version": "3.39.0",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.39.0.tgz",
"integrity": "sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"packages/superset-ui-demo/node_modules/cosmiconfig": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz",

View File

@@ -94,9 +94,6 @@
],
"dependencies": {
"@apache-superset/core": "file:packages/superset-core",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",

View File

@@ -342,31 +342,6 @@ const x_axis_time_format: SharedControlConfig<
option.label.includes(search) || option.value.includes(search),
};
const x_axis_number_format: SharedControlConfig<
'SelectControl',
SelectDefaultOption
> = {
type: 'SelectControl',
freeForm: true,
label: t('X Axis Number Format'),
renderTrigger: true,
default: DEFAULT_NUMBER_FORMAT,
choices: D3_FORMAT_OPTIONS,
description: D3_FORMAT_DOCS,
tokenSeparators: ['\n', '\t', ';'],
filterOption: ({ data: option }, search) =>
option.label.includes(search) || option.value.includes(search),
mapStateToProps: state => {
const isPercentage =
state.controls?.comparison_type?.value === ComparisonType.Percentage;
return {
choices: isPercentage
? D3_FORMAT_OPTIONS.filter(option => option[0].includes('%'))
: D3_FORMAT_OPTIONS,
};
},
};
const color_scheme: SharedControlConfig<'ColorSchemeControl'> = {
type: 'ColorSchemeControl',
label: t('Color Scheme'),
@@ -481,7 +456,6 @@ const sharedControls: Record<string, SharedControlConfig<any>> = {
size: dndSizeControl,
y_axis_format,
x_axis_time_format,
x_axis_number_format,
adhoc_filters: dndAdhocFilterControl,
color_scheme,
time_shift_color,

View File

@@ -38,24 +38,20 @@ export const getOpacity = (
minOpacity = MIN_OPACITY_BOUNDED,
maxOpacity = MAX_OPACITY,
) => {
if (extremeValue === cutoffPoint || typeof value !== 'number') {
if (
extremeValue === cutoffPoint ||
typeof cutoffPoint !== 'number' ||
typeof extremeValue !== 'number' ||
typeof value !== 'number'
) {
return maxOpacity;
}
const numCutoffPoint =
typeof cutoffPoint === 'string' ? parseFloat(cutoffPoint) : cutoffPoint;
const numExtremeValue =
typeof extremeValue === 'string' ? parseFloat(extremeValue) : extremeValue;
if (isNaN(numCutoffPoint) || isNaN(numExtremeValue)) {
return maxOpacity;
}
return Math.min(
maxOpacity,
round(
Math.abs(
((maxOpacity - minOpacity) / (numExtremeValue - numCutoffPoint)) *
(value - numCutoffPoint),
((maxOpacity - minOpacity) / (extremeValue - cutoffPoint)) *
(value - cutoffPoint),
) + minOpacity,
2,
),

View File

@@ -35,500 +35,487 @@ const countValues = mockData.map(row => row.count);
const strData = [{ name: 'Brian' }, { name: 'Carlos' }, { name: 'Diana' }];
const strValues = strData.map(row => row.name);
test('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
describe('round', () => {
it('round', () => {
expect(round(1)).toEqual(1);
expect(round(1, 2)).toEqual(1);
expect(round(0.6)).toEqual(1);
expect(round(0.6, 1)).toEqual(0.6);
expect(round(0.64999, 2)).toEqual(0.65);
});
});
test('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
expect(getOpacity('100', 100, 100)).toEqual(1);
expect(getOpacity('75', 50, 100)).toEqual(1);
expect(getOpacity('50', '100', '100')).toEqual(1);
expect(getOpacity('50', '75', '100')).toEqual(1);
expect(getOpacity('50', NaN, '100')).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, 100)).toEqual(1);
expect(getOpacity('50', '75', NaN)).toEqual(1);
expect(getOpacity('50', NaN, NaN)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(100, 50, 100)).toEqual(1);
expect(getOpacity(75, '50', 100)).toEqual(0.53);
expect(getOpacity(75, 50, '100')).toEqual(0.53);
expect(getOpacity(75, '50', '100')).toEqual(0.53);
expect(getOpacity(50, NaN, NaN)).toEqual(1);
expect(getOpacity(50, NaN, 100)).toEqual(1);
expect(getOpacity(50, NaN, '100')).toEqual(1);
expect(getOpacity(50, '75', NaN)).toEqual(1);
expect(getOpacity(50, 75, NaN)).toEqual(1);
describe('getOpacity', () => {
it('getOpacity', () => {
expect(getOpacity(100, 100, 100)).toEqual(1);
expect(getOpacity(75, 50, 100)).toEqual(0.53);
expect(getOpacity(75, 100, 50)).toEqual(0.53);
expect(getOpacity(100, 100, 50)).toEqual(0.05);
expect(getOpacity(100, 100, 100, 0, 0.8)).toEqual(0.8);
expect(getOpacity(100, 100, 50, 0, 1)).toEqual(0);
expect(getOpacity(999, 100, 50, 0, 1)).toEqual(1);
expect(getOpacity(100, 100, 50, 0.99, 1)).toEqual(0.99);
expect(getOpacity(99, 100, 50, 0, 1)).toEqual(0.02);
});
});
test('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
describe('getColorFunction()', () => {
it('getColorFunction GREATER_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
it('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
it('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
it('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
it('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
it('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
it('getColorFunction BeginsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BeginsWith,
targetValue: 'C',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Brian')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction EndsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
it('getColorFunction Containing', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction NotContaining', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
it('getColorFunction Equal', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 'Diana',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Diana')).toEqual('#FF0000FF');
});
it('getColorFunction None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('#FF0000FF');
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
});
test('getColorFunction LESS_THAN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessThan,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(100)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterOrEqual,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(0)).toBeUndefined();
});
test('getColorFunction LESS_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.LessOrEqual,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF0000FF');
expect(colorFunction(100)).toEqual('#FF00000D');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction NOT_EQUAL', () => {
let colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 60,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(60)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(50)).toEqual('#FF00004A');
colorFunction = getColorFunction(
{
operator: Comparator.NotEqual,
targetValue: 90,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(90)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF00004A');
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getColorFunction BETWEEN', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF000087');
});
test('getColorFunction BETWEEN_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(150)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_EQUAL without opacity', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
false,
);
expect(colorFunction(25)).toBeUndefined();
expect(colorFunction(50)).toEqual('#FF0000');
expect(colorFunction(75)).toEqual('#FF0000');
expect(colorFunction(100)).toEqual('#FF0000');
expect(colorFunction(125)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_LEFT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrLeftEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toEqual('#FF00000D');
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN_OR_RIGHT_EQUAL', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BetweenOrRightEqual,
targetValueLeft: 50,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toEqual('#FF0000FF');
});
test('getColorFunction GREATER_THAN with target value undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value left undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: undefined,
targetValueRight: 100,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BETWEEN with target value right undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Between,
targetValueLeft: 50,
targetValueRight: undefined,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction unsupported operator', () => {
const colorFunction = getColorFunction(
{
// @ts-ignore
operator: 'unsupported operator',
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with operator None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(20)).toEqual(undefined);
expect(colorFunction(50)).toEqual('#FF000000');
expect(colorFunction(75)).toEqual('#FF000080');
expect(colorFunction(100)).toEqual('#FF0000FF');
expect(colorFunction(120)).toEqual(undefined);
});
test('getColorFunction with operator undefined', () => {
const colorFunction = getColorFunction(
{
operator: undefined,
targetValue: 150,
colorScheme: '#FF0000',
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction with colorScheme undefined', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: undefined,
column: 'count',
},
countValues,
);
expect(colorFunction(50)).toBeUndefined();
expect(colorFunction(100)).toBeUndefined();
});
test('getColorFunction BeginsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.BeginsWith,
targetValue: 'C',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Brian')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction EndsWith', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
test('getColorFunction Containing', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction NotContaining', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toBeUndefined();
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
});
test('getColorFunction Equal', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.Equal,
targetValue: 'Diana',
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Carlos')).toBeUndefined();
expect(colorFunction('Diana')).toEqual('#FF0000FF');
});
test('getColorFunction None', () => {
const colorFunction = getColorFunction(
{
operator: Comparator.None,
colorScheme: '#FF0000',
column: 'name',
},
strValues,
);
expect(colorFunction('Diana')).toEqual('#FF0000FF');
expect(colorFunction('Carlos')).toEqual('#FF0000FF');
expect(colorFunction('Brian')).toEqual('#FF0000FF');
});
test('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
test('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
test('correct column string config', () => {
const columnConfigString = [
{
operator: Comparator.BeginsWith,
targetValue: 'D',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
];
const colorFormatters = getColorFormatters(columnConfigString, strData);
expect(colorFormatters.length).toEqual(4);
expect(colorFormatters[0].column).toEqual('name');
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('name');
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
expect(colorFormatters[2].column).toEqual('name');
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
expect(colorFormatters[3].column).toEqual('name');
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
describe('getColorFormatters()', () => {
it('correct column config', () => {
const columnConfig = [
{
operator: Comparator.GreaterThan,
targetValue: 50,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.LessThan,
targetValue: 300,
colorScheme: '#FF0000',
column: 'sum',
},
{
operator: Comparator.Between,
targetValueLeft: 75,
targetValueRight: 125,
colorScheme: '#FF0000',
column: 'count',
},
{
operator: Comparator.GreaterThan,
targetValue: 150,
colorScheme: '#FF0000',
column: undefined,
},
];
const colorFormatters = getColorFormatters(columnConfig, mockData);
expect(colorFormatters.length).toEqual(3);
expect(colorFormatters[0].column).toEqual('count');
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('sum');
expect(colorFormatters[1].getColorFromValue(200)).toEqual('#FF0000FF');
expect(colorFormatters[1].getColorFromValue(400)).toBeUndefined();
expect(colorFormatters[2].column).toEqual('count');
expect(colorFormatters[2].getColorFromValue(100)).toEqual('#FF000087');
});
it('undefined column config', () => {
const colorFormatters = getColorFormatters(undefined, mockData);
expect(colorFormatters.length).toEqual(0);
});
it('correct column string config', () => {
const columnConfigString = [
{
operator: Comparator.BeginsWith,
targetValue: 'D',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.EndsWith,
targetValue: 'n',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.Containing,
targetValue: 'o',
colorScheme: '#FF0000',
column: 'name',
},
{
operator: Comparator.NotContaining,
targetValue: 'i',
colorScheme: '#FF0000',
column: 'name',
},
];
const colorFormatters = getColorFormatters(columnConfigString, strData);
expect(colorFormatters.length).toEqual(4);
expect(colorFormatters[0].column).toEqual('name');
expect(colorFormatters[0].getColorFromValue('Diana')).toEqual('#FF0000FF');
expect(colorFormatters[1].column).toEqual('name');
expect(colorFormatters[1].getColorFromValue('Brian')).toEqual('#FF0000FF');
expect(colorFormatters[2].column).toEqual('name');
expect(colorFormatters[2].getColorFromValue('Carlos')).toEqual('#FF0000FF');
expect(colorFormatters[3].column).toEqual('name');
expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF');
});
});

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createRef } from 'react';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import type AceEditor from 'react-ace';
import {
AsyncAceEditor,
SQLEditor,
@@ -101,253 +99,3 @@ test('renders a custom placeholder', () => {
expect(screen.getByRole('paragraph')).toBeInTheDocument();
});
test('registers afterExec event listener for command handling', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Verify the commands object has the 'on' method (confirms event listener capability)
expect(editorInstance.commands).toHaveProperty('on');
expect(typeof editorInstance.commands.on).toBe('function');
});
test('moves autocomplete popup to parent container when triggered', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the editor container
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with insertstring command using _emit
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'insertstring' },
args: ['SELECT'],
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('moves autocomplete popup on startAutocomplete command event', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(mockAutocompletePopup);
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
// Manually trigger the afterExec event with startAutocomplete command
type CommandManagerWithEmit = typeof editorInstance.commands & {
_emit: (event: string, data: unknown) => void;
};
(editorInstance.commands as CommandManagerWithEmit)._emit('afterExec', {
command: { name: 'startAutocomplete' },
});
await waitFor(() => {
// Check that the popup has the data attribute set
expect(mockAutocompletePopup.dataset.aceAutocomplete).toBe('true');
// Check that the popup is in the parent container
expect(parentContainer?.contains(mockAutocompletePopup)).toBe(true);
});
});
test('does not move autocomplete popup on unrelated commands', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup in the body
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
const originalParent = mockAutocompletePopup.parentElement;
// Simulate an unrelated command (e.g., 'selectall')
editorInstance.commands.exec('selectall', editorInstance, {});
// Wait a bit to ensure no movement happens
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should remain in its original location
expect(mockAutocompletePopup.parentElement).toBe(originalParent);
// Cleanup
document.body.removeChild(mockAutocompletePopup);
});
test('revalidates cached autocomplete popup when detached from DOM', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create first autocomplete popup
const firstPopup = document.createElement('div');
firstPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(firstPopup);
// Trigger command to cache the first popup
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await waitFor(() => {
expect(firstPopup.dataset.aceAutocomplete).toBe('true');
});
// Remove the first popup from DOM (simulating ACE editor replacing it)
firstPopup.remove();
// Create a new autocomplete popup
const secondPopup = document.createElement('div');
secondPopup.className = 'ace_autocomplete';
editorInstance.container?.appendChild(secondPopup);
// Trigger command again - should find and move the new popup
editorInstance.commands.exec('insertstring', editorInstance, ' ');
await waitFor(() => {
expect(secondPopup.dataset.aceAutocomplete).toBe('true');
const parentContainer =
editorInstance.container?.closest('#ace-editor') ??
editorInstance.container?.parentElement;
expect(parentContainer?.contains(secondPopup)).toBe(true);
});
});
test('cleans up event listeners on unmount', async () => {
const ref = createRef<AceEditor>();
const { container, unmount } = render(
<SQLEditor ref={ref as React.Ref<never>} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Spy on the commands.off method
const offSpy = jest.spyOn(editorInstance.commands, 'off');
// Unmount the component
unmount();
// Verify that the event listener was removed
expect(offSpy).toHaveBeenCalledWith('afterExec', expect.any(Function));
offSpy.mockRestore();
});
test('does not move autocomplete popup if target container is document.body', async () => {
const ref = createRef<AceEditor>();
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
const editorInstance = ref.current?.editor;
expect(editorInstance).toBeDefined();
if (!editorInstance) return;
// Create a mock autocomplete popup
const mockAutocompletePopup = document.createElement('div');
mockAutocompletePopup.className = 'ace_autocomplete';
document.body.appendChild(mockAutocompletePopup);
// Mock the closest method to return null (simulating no #ace-editor parent)
const originalClosest = editorInstance.container?.closest;
if (editorInstance.container) {
editorInstance.container.closest = jest.fn(() => null);
}
// Mock parentElement to be document.body
Object.defineProperty(editorInstance.container, 'parentElement', {
value: document.body,
configurable: true,
});
const initialParent = mockAutocompletePopup.parentElement;
// Trigger command
editorInstance.commands.exec('insertstring', editorInstance, 'SELECT');
await new Promise(resolve => {
setTimeout(resolve, 100);
});
// The popup should NOT be moved because target container is document.body
expect(mockAutocompletePopup.parentElement).toBe(initialParent);
// Cleanup
if (editorInstance.container && originalClosest) {
editorInstance.container.closest = originalClosest;
}
document.body.removeChild(mockAutocompletePopup);
});

View File

@@ -26,7 +26,6 @@ import type {
} from 'brace';
import type AceEditor from 'react-ace';
import type { IAceEditorProps } from 'react-ace';
import type { Ace } from 'ace-builds';
import {
AsyncEsmComponent,
@@ -208,68 +207,6 @@ export function AsyncAceEditor(
}
}, [keywords, setCompleters]);
// Move autocomplete popup to the nearest parent container with data-ace-container
useEffect(() => {
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
?.editor;
if (!editorInstance) return;
const editorContainer = editorInstance.container;
if (!editorContainer) return;
// Cache DOM elements to avoid repeated queries on every command execution
let cachedAutocompletePopup: HTMLElement | null = null;
let cachedTargetContainer: Element | null = null;
const moveAutocompleteToContainer = () => {
// Revalidate cached popup if missing or detached from DOM
if (
!cachedAutocompletePopup ||
!document.body.contains(cachedAutocompletePopup)
) {
cachedAutocompletePopup =
editorContainer.querySelector<HTMLElement>(
'.ace_autocomplete',
) ?? document.querySelector<HTMLElement>('.ace_autocomplete');
}
// Revalidate cached container if missing or detached
if (
!cachedTargetContainer ||
!document.body.contains(cachedTargetContainer)
) {
cachedTargetContainer =
editorContainer.closest('#ace-editor') ??
editorContainer.parentElement;
}
if (
cachedAutocompletePopup &&
cachedTargetContainer &&
cachedTargetContainer !== document.body
) {
cachedTargetContainer.appendChild(cachedAutocompletePopup);
cachedAutocompletePopup.dataset.aceAutocomplete = 'true';
}
};
const handleAfterExec = (e: Ace.Operation) => {
const name: string | undefined = e?.command?.name;
if (name === 'insertstring' || name === 'startAutocomplete') {
moveAutocompleteToContainer();
}
};
const { commands } = editorInstance;
commands.on('afterExec', handleAfterExec);
return () => {
commands.off('afterExec', handleAfterExec);
cachedAutocompletePopup = null;
cachedTargetContainer = null;
};
}, [ref]);
return (
<>
<Global
@@ -351,24 +288,14 @@ export function AsyncAceEditor(
border: 1px solid ${token.colorBorderSecondary};
box-shadow: ${token.boxShadow};
border-radius: ${token.borderRadius}px;
padding: ${token.paddingXS}px ${token.paddingXS}px;
}
.ace_tooltip.ace_doc-tooltip {
display: flex !important;
}
&&& .tooltip-detail {
display: flex;
justify-content: center;
flex-direction: row;
gap: ${token.paddingXXS}px;
align-items: center;
& .tooltip-detail {
background-color: ${token.colorBgContainer};
white-space: pre-wrap;
word-break: break-all;
min-width: ${token.sizeXXL * 5}px;
max-width: ${token.sizeXXL * 10}px;
font-size: ${token.fontSize}px;
& .tooltip-detail-head {
background-color: ${token.colorBgElevated};
@@ -391,9 +318,7 @@ export function AsyncAceEditor(
& .tooltip-detail-head,
& .tooltip-detail-body {
background-color: ${token.colorBgLayout};
padding: 0px ${token.paddingXXS}px;
border: 1px ${token.colorSplit} solid;
padding: ${token.padding}px ${token.paddingLG}px;
}
& .tooltip-detail-footer {

View File

@@ -86,7 +86,6 @@ export function EditableTitle({
renderLink,
maxWidth,
autoSize = true,
onEditingChange,
...rest
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(editing);
@@ -132,8 +131,7 @@ export function EditableTitle({
textArea.scrollTop = textArea.scrollHeight;
}
}
onEditingChange?.(isEditing);
}, [isEditing, onEditingChange]);
}, [isEditing]);
function handleClick() {
if (!canEdit || isEditing) return;

View File

@@ -33,5 +33,4 @@ export interface EditableTitleProps {
renderLink?: (title: string) => React.ReactNode;
maxWidth?: number;
autoSize?: boolean;
onEditingChange?: (isEditing: boolean) => void;
}

View File

@@ -1,24 +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 { Progress as AntdProgress } from 'antd';
import type { ProgressProps as AntdProgressProps } from 'antd';
export type ProgressProps = AntdProgressProps;
export const Progress = AntdProgress;

View File

@@ -20,25 +20,25 @@
import { fireEvent, render } from '@superset-ui/core/spec';
import Tabs, { EditableTabs, LineEditableTabs } from './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('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} />);
@@ -284,7 +284,6 @@ describe('Tabs', () => {
describe('Styling Integration', () => {
it('should accept and apply custom CSS classes', () => {
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} className="custom-tabs-class" />,
);
@@ -296,7 +295,6 @@ describe('Tabs', () => {
it('should accept and apply custom styles', () => {
const customStyle = { minHeight: '200px' };
const { container } = render(
// eslint-disable-next-line react/forbid-component-props
<Tabs items={defaultItems} style={customStyle} />,
);
@@ -306,72 +304,3 @@ describe('Tabs', () => {
});
});
});
test('fullHeight prop renders component hierarchy correctly', () => {
const { container } = render(<Tabs items={defaultItems} fullHeight />);
const tabsElement = container.querySelector('.ant-tabs');
const contentHolder = container.querySelector('.ant-tabs-content-holder');
const content = container.querySelector('.ant-tabs-content');
const tabPane = container.querySelector('.ant-tabs-tabpane');
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
expect(content).toBeInTheDocument();
expect(tabPane).toBeInTheDocument();
expect(tabsElement?.contains(contentHolder as Node)).toBe(true);
expect(contentHolder?.contains(content as Node)).toBe(true);
expect(content?.contains(tabPane as Node)).toBe(true);
});
test('fullHeight prop maintains structure when content updates', () => {
const { container, rerender } = render(
<Tabs items={defaultItems} fullHeight />,
);
const initialTabsElement = container.querySelector('.ant-tabs');
const newItems = [
...defaultItems,
{
key: '4',
label: 'Tab 4',
children: <div data-testid="tab4-content">New tab content</div>,
},
];
rerender(<Tabs items={newItems} fullHeight />);
const updatedTabsElement = container.querySelector('.ant-tabs');
const updatedContentHolder = container.querySelector(
'.ant-tabs-content-holder',
);
expect(updatedTabsElement).toBeInTheDocument();
expect(updatedContentHolder).toBeInTheDocument();
expect(initialTabsElement).toBe(updatedTabsElement);
});
test('fullHeight prop works with allowOverflow to handle tall content', () => {
const { container } = render(
<Tabs items={defaultItems} fullHeight allowOverflow />,
);
const tabsElement = container.querySelector('.ant-tabs') as HTMLElement;
const contentHolder = container.querySelector(
'.ant-tabs-content-holder',
) as HTMLElement;
expect(tabsElement).toBeInTheDocument();
expect(contentHolder).toBeInTheDocument();
// Verify overflow handling is not restricted
const holderStyles = window.getComputedStyle(contentHolder);
expect(holderStyles.overflow).not.toBe('hidden');
});
test('fullHeight prop handles empty items array', () => {
const { container } = render(<Tabs items={[]} fullHeight />);
expect(container.querySelector('.ant-tabs')).toBeInTheDocument();
});

View File

@@ -25,14 +25,12 @@ import type { SerializedStyles } from '@emotion/react';
export interface TabsProps extends AntdTabsProps {
allowOverflow?: boolean;
fullHeight?: boolean;
contentStyle?: SerializedStyles;
}
const StyledTabs = ({
animated = false,
allowOverflow = true,
fullHeight = false,
tabBarStyle,
contentStyle,
...props
@@ -48,17 +46,9 @@ const StyledTabs = ({
tabBarStyle={mergedStyle}
css={theme => css`
overflow: ${allowOverflow ? 'visible' : 'hidden'};
${fullHeight && 'height: 100%;'}
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
}
.ant-tabs-content {
${fullHeight && 'height: 100%;'}
}
.ant-tabs-tabpane {
${fullHeight && 'height: 100%;'}
${contentStyle}
}
.ant-tabs-tab {

View File

@@ -145,8 +145,6 @@ export {
} from './ListViewCard';
export { Loading, type LoadingProps } from './Loading';
export { Progress, type ProgressProps } from './Progress';
export { Skeleton, type SkeletonProps } from './Skeleton';
export { Switch, type SwitchProps } from './Switch';

View File

@@ -19,27 +19,16 @@
import { DatasourceType } from './types/Datasource';
const DATASOURCE_TYPE_MAP: Record<string, DatasourceType> = {
table: DatasourceType.Table,
query: DatasourceType.Query,
dataset: DatasourceType.Dataset,
sl_table: DatasourceType.SlTable,
saved_query: DatasourceType.SavedQuery,
semantic_view: DatasourceType.SemanticView,
};
export default class DatasourceKey {
readonly id: number | string;
readonly id: number;
readonly type: DatasourceType;
constructor(key: string) {
const [idStr, typeStr] = key.split('__');
// Only parse as integer if the entire string is numeric
// (parseInt would incorrectly parse "85d3139f..." as 85)
const isNumeric = /^\d+$/.test(idStr);
this.id = isNumeric ? parseInt(idStr, 10) : idStr;
this.type = DATASOURCE_TYPE_MAP[typeStr] ?? DatasourceType.Table;
this.id = parseInt(idStr, 10);
this.type = DatasourceType.Table; // default to SqlaTable model
this.type = typeStr === 'query' ? DatasourceType.Query : this.type;
}
public toString() {

View File

@@ -26,7 +26,6 @@ export enum DatasourceType {
Dataset = 'dataset',
SlTable = 'sl_table',
SavedQuery = 'saved_query',
SemanticView = 'semantic_view',
}
export interface Currency {
@@ -38,7 +37,7 @@ export interface Currency {
* Datasource metadata.
*/
export interface Datasource {
id: number | string;
id: number;
name: string;
type: DatasourceType;
columns: Column[];

View File

@@ -156,7 +156,7 @@ export interface QueryObject
export interface QueryContext {
datasource: {
id: number | string;
id: number;
type: DatasourceType;
};
/** Force refresh of all queries */

View File

@@ -31,7 +31,6 @@ const propTypes = {
data: PropTypes.arrayOf(
PropTypes.shape({
country: PropTypes.string,
code: PropTypes.string,
latitude: PropTypes.number,
longitude: PropTypes.number,
name: PropTypes.string,
@@ -117,7 +116,7 @@ function WorldMap(element, props) {
const selected = Object.values(filterState.selectedValues || {});
const key = source.id || source.country;
const country =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
if (!country) {
return undefined;
@@ -171,7 +170,7 @@ function WorldMap(element, props) {
pointerEvent.preventDefault();
const key = source.id || source.country;
const val =
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.code;
countryFieldtype === 'name' ? mapData[key]?.name : mapData[key]?.country;
let drillToDetailFilters;
let drillByFilters;
if (val) {

View File

@@ -1,311 +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,
waitFor,
cleanup,
} from '../../../../spec/helpers/testing-library';
import { AxisType } from '@superset-ui/core';
import type { EChartsCoreOption } from 'echarts/core';
import type { ReactNode } from 'react';
import {
LegendOrientation,
LegendType,
type EchartsHandler,
type EchartsProps,
} from '../types';
import EchartsTimeseries from './EchartsTimeseries';
import {
EchartsTimeseriesSeriesType,
OrientationType,
type EchartsTimeseriesFormData,
type TimeseriesChartTransformedProps,
} from './types';
const mockEchart = jest.fn();
jest.mock('../components/Echart', () => {
const { forwardRef } = jest.requireActual<typeof import('react')>('react');
const MockEchart = forwardRef<EchartsHandler | null, EchartsProps>(
(props, ref) => {
mockEchart(props);
void ref;
return null;
},
);
MockEchart.displayName = 'MockEchart';
return {
__esModule: true,
default: MockEchart,
};
});
jest.mock('../components/ExtraControls', () => ({
ExtraControls: ({ children }: { children?: ReactNode }) => (
<div data-testid="extra-controls">{children}</div>
),
}));
const originalResizeObserver = globalThis.ResizeObserver;
const offsetHeightDescriptor = Object.getOwnPropertyDescriptor(
HTMLElement.prototype,
'offsetHeight',
);
let mockOffsetHeight = 0;
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetHeight', {
configurable: true,
get() {
return mockOffsetHeight;
},
});
});
afterAll(() => {
if (offsetHeightDescriptor) {
Object.defineProperty(
HTMLElement.prototype,
'offsetHeight',
offsetHeightDescriptor,
);
} else {
delete (HTMLElement.prototype as { offsetHeight?: number }).offsetHeight;
}
});
afterEach(() => {
cleanup();
mockEchart.mockReset();
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
originalResizeObserver;
});
const defaultFormData: EchartsTimeseriesFormData & {
vizType: string;
dateFormat: string;
numberFormat: string;
granularitySqla?: string;
} = {
annotationLayers: [],
area: false,
colorScheme: undefined,
timeShiftColor: false,
contributionMode: undefined,
forecastEnabled: false,
forecastPeriods: 0,
forecastInterval: 0,
forecastSeasonalityDaily: null,
forecastSeasonalityWeekly: null,
forecastSeasonalityYearly: null,
logAxis: false,
markerEnabled: false,
markerSize: 1,
metrics: [],
minorSplitLine: false,
minorTicks: false,
opacity: 1,
orderDesc: false,
rowLimit: 0,
seriesType: EchartsTimeseriesSeriesType.Line,
stack: null,
stackDimension: '',
timeCompare: [],
tooltipTimeFormat: undefined,
showTooltipTotal: false,
showTooltipPercentage: false,
truncateXAxis: false,
truncateYAxis: false,
yAxisFormat: undefined,
xAxisForceCategorical: false,
xAxisTimeFormat: undefined,
timeGrainSqla: undefined,
forceMaxInterval: false,
xAxisBounds: [null, null],
yAxisBounds: [null, null],
zoomable: false,
richTooltip: false,
xAxisLabelRotation: 0,
xAxisLabelInterval: 0,
showValue: false,
onlyTotal: false,
showExtraControls: true,
percentageThreshold: 0,
orientation: OrientationType.Vertical,
datasource: '1__table',
viz_type: 'echarts_timeseries',
legendMargin: 0,
legendOrientation: LegendOrientation.Top,
legendType: LegendType.Plain,
showLegend: false,
legendSort: null,
xAxisTitle: '',
xAxisTitleMargin: 0,
yAxisTitle: '',
yAxisTitleMargin: 0,
yAxisTitlePosition: '',
time_range: 'No filter',
granularity: undefined,
granularity_sqla: undefined,
sql: '',
url_params: {},
custom_params: {},
extra_form_data: {},
adhoc_filters: [],
order_desc: false,
row_limit: 0,
row_offset: 0,
time_grain_sqla: undefined,
vizType: 'echarts_timeseries',
dateFormat: 'smart_date',
numberFormat: 'SMART_NUMBER',
};
const defaultProps: TimeseriesChartTransformedProps = {
echartOptions: {} as EChartsCoreOption,
formData: defaultFormData,
height: 400,
width: 800,
onContextMenu: jest.fn(),
setDataMask: jest.fn(),
onLegendStateChanged: jest.fn(),
refs: {},
emitCrossFilters: false,
coltypeMapping: {},
onLegendScroll: jest.fn(),
groupby: [],
labelMap: {},
setControlValue: jest.fn(),
selectedValues: {},
legendData: [],
xValueFormatter: String,
xAxis: {
label: 'x',
type: AxisType.Time,
},
onFocusedSeries: jest.fn(),
};
function getLatestHeight() {
const lastCall = mockEchart.mock.calls.at(-1);
expect(lastCall).toBeDefined();
const [props] = lastCall as [EchartsProps];
return props.height;
}
test('observes extra control height changes when ResizeObserver is available', async () => {
const disconnectSpy = jest.fn();
const observeSpy = jest.fn();
class MockResizeObserver implements ResizeObserver {
private static latestInstance: MockResizeObserver | null = null;
private readonly callback: ResizeObserverCallback;
constructor(callback: ResizeObserverCallback) {
this.callback = callback;
MockResizeObserver.latestInstance = this;
}
observe = (target: Element) => {
observeSpy(target);
};
unobserve(_target: Element): void {
void _target;
}
disconnect = () => {
disconnectSpy();
};
trigger(entries: ResizeObserverEntry[] = []) {
this.callback(entries, this);
}
static getLatestInstance() {
return this.latestInstance;
}
}
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
MockResizeObserver as unknown as typeof ResizeObserver;
mockOffsetHeight = 42;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(observeSpy).toHaveBeenCalledWith(expect.any(HTMLElement));
mockOffsetHeight = 24;
MockResizeObserver.getLatestInstance()?.trigger();
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(disconnectSpy).not.toHaveBeenCalled();
expect(MockResizeObserver.getLatestInstance()).not.toBeNull();
unmount();
expect(disconnectSpy).toHaveBeenCalled();
});
test('falls back to window resize listener when ResizeObserver is unavailable', async () => {
(globalThis as { ResizeObserver?: typeof ResizeObserver }).ResizeObserver =
undefined;
const addEventListenerSpy = jest.spyOn(window, 'addEventListener');
const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener');
mockOffsetHeight = 30;
const { unmount } = render(<EchartsTimeseries {...defaultProps} />);
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
expect(addEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
mockOffsetHeight = 10;
window.dispatchEvent(new Event('resize'));
await waitFor(() => {
expect(getLatestHeight()).toBe(defaultProps.height - mockOffsetHeight);
});
unmount();
expect(removeEventListenerSpy).toHaveBeenCalledWith(
'resize',
expect.any(Function),
);
addEventListenerSpy.mockRestore();
removeEventListenerSpy.mockRestore();
});

View File

@@ -67,32 +67,8 @@ export default function EchartsTimeseries({
const extraControlRef = useRef<HTMLDivElement>(null);
const [extraControlHeight, setExtraControlHeight] = useState(0);
useEffect(() => {
const element = extraControlRef.current;
if (!element) {
setExtraControlHeight(0);
return;
}
const updateHeight = () => {
setExtraControlHeight(element.offsetHeight || 0);
};
updateHeight();
if (typeof ResizeObserver === 'function') {
const resizeObserver = new ResizeObserver(() => {
updateHeight();
});
resizeObserver.observe(element);
return () => {
resizeObserver.disconnect();
};
}
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
};
const updatedHeight = extraControlRef.current?.offsetHeight || 0;
setExtraControlHeight(updatedHeight);
}, [formData.showExtraControls]);
const hasDimensions = ensureIsArray(groupby).length > 0;

View File

@@ -112,53 +112,6 @@ const config: ControlPanelConfig = {
...sharedControls.x_axis_time_format,
default: 'smart_date',
description: `${D3_TIME_FORMAT_DOCS}. ${TIME_SERIES_DESCRIPTION_TEXT}`,
visibility: ({ controls }: ControlPanelsContainerProps) => {
// check if x axis is a time column
const xAxisColumn = controls?.x_axis?.value;
const xAxisOptions = controls?.x_axis?.options;
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
return false;
}
const xAxisType = xAxisOptions.find(
option => option.column_name === xAxisColumn,
)?.type;
return (
typeof xAxisType === 'string' &&
xAxisType.toUpperCase().includes('TIME')
);
},
},
},
{
name: 'x_axis_number_format',
config: {
...sharedControls.x_axis_number_format,
visibility: ({ controls }: ControlPanelsContainerProps) => {
// check if x axis is a floating-point column
const xAxisColumn = controls?.x_axis?.value;
const xAxisOptions = controls?.x_axis?.options;
if (!xAxisColumn || !Array.isArray(xAxisOptions)) {
return false;
}
const xAxisType = xAxisOptions.find(
option => option.column_name === xAxisColumn,
)?.type;
if (typeof xAxisType !== 'string') {
return false;
}
const typeUpper = xAxisType.toUpperCase();
return ['FLOAT', 'DOUBLE', 'REAL', 'NUMERIC', 'DECIMAL'].some(
t => typeUpper.includes(t),
);
},
},
},
],

View File

@@ -72,7 +72,6 @@ export const DEFAULT_FORM_DATA: EchartsTimeseriesFormData = {
stack: false,
tooltipTimeFormat: 'smart_date',
xAxisTimeFormat: 'smart_date',
xAxisNumberFormat: 'SMART_NUMBER',
truncateXAxis: true,
truncateYAxis: false,
yAxisBounds: [null, null],

View File

@@ -189,7 +189,6 @@ export default function transformProps(
xAxisSort,
xAxisSortAsc,
xAxisTimeFormat,
xAxisNumberFormat,
xAxisTitle,
xAxisTitleMargin,
yAxisBounds,
@@ -486,9 +485,7 @@ export default function transformProps(
const xAxisFormatter =
xAxisDataType === GenericDataType.Temporal
? getXAxisFormatter(xAxisTimeFormat)
: xAxisDataType === GenericDataType.Numeric
? getNumberFormatter(xAxisNumberFormat)
: String;
: String;
const {
setDataMask = () => {},

View File

@@ -84,7 +84,6 @@ export type EchartsTimeseriesFormData = QueryFormData & {
yAxisFormat?: string;
xAxisForceCategorical?: boolean;
xAxisTimeFormat?: string;
xAxisNumberFormat?: string;
timeGrainSqla?: TimeGranularity;
forceMaxInterval?: boolean;
xAxisBounds: [number | undefined | null, number | undefined | null];

View File

@@ -42,5 +42,4 @@ export const DEFAULT_FORM_DATA: Partial<EchartsTreeFormData> = {
nodeLabelPosition: 'left',
childLabelPosition: 'bottom',
emphasis: 'descendant',
initialTreeDepth: 2,
};

View File

@@ -279,23 +279,6 @@ const controlPanel: ControlPanelConfig = {
},
},
],
[
{
name: 'initialTreeDepth',
config: {
type: 'NumberControl',
label: t('Initial tree depth'),
min: -1,
step: 1,
max: 10,
default: DEFAULT_FORM_DATA.initialTreeDepth,
renderTrigger: true,
description: t(
'The initial level (depth) of the tree. If set as -1 all nodes are expanded.',
),
},
},
],
],
},
],

View File

@@ -74,7 +74,6 @@ export default function transformProps(
nodeLabelPosition,
childLabelPosition,
emphasis,
initialTreeDepth,
}: EchartsTreeFormData = { ...DEFAULT_FORM_DATA, ...formData };
const metricLabel = getMetricLabel(metric);
@@ -204,7 +203,6 @@ export default function transformProps(
},
select: DEFAULT_TREE_SERIES_OPTION.select,
leaves: { label: { position: childLabelPosition } },
initialTreeDepth,
},
];

View File

@@ -36,7 +36,6 @@ export type EchartsTreeFormData = QueryFormData & {
nodeLabelPosition: 'top' | 'bottom' | 'left' | 'right';
childLabelPosition: 'top' | 'bottom' | 'left' | 'right';
emphasis: 'none' | 'ancestor' | 'descendant';
initialTreeDepth: number;
};
export interface TreeChartDataResponseResult extends ChartDataResponseResult {

View File

@@ -67,7 +67,7 @@ const legendTypeControl: ControlSetItem = {
label: t('Type'),
choices: [
['scroll', t('Scroll')],
['plain', t('List')],
['plain', t('Plain')],
],
default: legendType,
renderTrigger: true,

View File

@@ -482,17 +482,8 @@ export function getLegendProps(
break;
case LegendOrientation.Bottom:
legend.bottom = 0;
if (padding?.left) {
legend.left = padding.left;
}
break;
case LegendOrientation.Top:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;
if (padding?.left) {
legend.left = padding.left;
}
break;
default:
legend.top = 0;
legend.right = zoomable ? TIMESERIES_CONSTANTS.legendTopRightOffset : 0;

View File

@@ -1,156 +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 { ControlPanelsContainerProps } from '@superset-ui/chart-controls/types';
import controlPanel from '../../../src/Timeseries/Regular/Scatter/controlPanel';
const config = controlPanel;
const getControl = (controlName: string) => {
for (const section of config.controlPanelSections) {
if (section && section.controlSetRows) {
for (const row of section.controlSetRows) {
for (const control of row) {
if (
typeof control === 'object' &&
control !== null &&
'name' in control &&
control.name === controlName
) {
return control;
}
}
}
}
}
return null;
};
const mockControls = (
xAxisColumn: string | null,
xAxisType: string | null,
): ControlPanelsContainerProps => {
const options = xAxisType
? [{ column_name: xAxisColumn, type: xAxisType }]
: [];
return {
controls: {
// @ts-ignore
x_axis: {
value: xAxisColumn,
options: options,
},
},
};
};
// tests for x_axis_time_format control
const timeFormatControl: any = getControl('x_axis_time_format');
test('scatter chart control panel should include x_axis_time_format control in the panel', () => {
expect(timeFormatControl).toBeDefined();
});
test('scatter chart control panel should have correct default value for x_axis_time_format', () => {
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config).toBeDefined();
expect(timeFormatControl.config.default).toBe('smart_date');
});
test('scatter chart control panel should have visibility function for x_axis_time_format', () => {
expect(timeFormatControl).toBeDefined();
expect(timeFormatControl.config.visibility).toBeDefined();
expect(typeof timeFormatControl.config.visibility).toBe('function');
// The visibility function exists - the exact logic is tested implicitly through UI behavior
// The important part is that the control has proper visibility configuration
});
const isTimeVisible = (
xAxisColumn: string | null,
xAxisType: string | null,
): boolean => {
const props = mockControls(xAxisColumn, xAxisType);
const visibilityFn = timeFormatControl?.config?.visibility;
return visibilityFn ? visibilityFn(props) : false;
};
test('x_axis_time_format control should be visible for any data types include TIME', () => {
expect(isTimeVisible('time_column', 'TIME')).toBe(true);
expect(isTimeVisible('time_column', 'TIME WITH TIME ZONE')).toBe(true);
expect(isTimeVisible('time_column', 'TIMESTAMP WITH TIME ZONE')).toBe(true);
expect(isTimeVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
true,
);
});
test('x_axis_time_format control should be hidden for data types that do NOT include TIME', () => {
expect(isTimeVisible('null', 'null')).toBe(false);
expect(isTimeVisible(null, null)).toBe(false);
expect(isTimeVisible('float_column', 'FLOAT')).toBe(false);
});
// tests for x_axis_number_format control
const numberFormatControl: any = getControl('x_axis_number_format');
test('scatter chart control panel should include x_axis_number_format control in the panel', () => {
expect(numberFormatControl).toBeDefined();
});
test('scatter chart control panel should have correct default value for x_axis_number_format', () => {
expect(numberFormatControl).toBeDefined();
expect(numberFormatControl.config).toBeDefined();
expect(numberFormatControl.config.default).toBe('SMART_NUMBER');
});
test('scatter chart control panel should have visibility function for x_axis_number_format', () => {
expect(numberFormatControl).toBeDefined();
expect(numberFormatControl.config.visibility).toBeDefined();
expect(typeof numberFormatControl.config.visibility).toBe('function');
// The visibility function exists - the exact logic is tested implicitly through UI behavior
// The important part is that the control has proper visibility configuration
});
const isNumberVisible = (
xAxisColumn: string | null,
xAxisType: string | null,
): boolean => {
const props = mockControls(xAxisColumn, xAxisType);
const visibilityFn = numberFormatControl?.config?.visibility;
return visibilityFn ? visibilityFn(props) : false;
};
test('x_axis_number_format control should be visible for any floating-point data types', () => {
expect(isNumberVisible('float_column', 'FLOAT')).toBe(true);
expect(isNumberVisible('double_column', 'DOUBLE')).toBe(true);
expect(isNumberVisible('real_column', 'REAL')).toBe(true);
expect(isNumberVisible('numeric_column', 'NUMERIC')).toBe(true);
expect(isNumberVisible('decimal_column', 'DECIMAL')).toBe(true);
});
test('x_axis_number_format control should be hidden for any non-floating-point data types', () => {
expect(isNumberVisible('string_column', 'VARCHAR')).toBe(false);
expect(isNumberVisible('null', 'null')).toBe(false);
expect(isNumberVisible(null, null)).toBe(false);
expect(isNumberVisible('time_column', 'TIMESTAMP WITHOUT TIME ZONE')).toBe(
false,
);
});

View File

@@ -1,183 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps, SMART_DATE_ID } from '@superset-ui/core';
import transformProps from '../../../src/Timeseries/transformProps';
import { DEFAULT_FORM_DATA } from '../../../src/Timeseries/constants';
import {
EchartsTimeseriesSeriesType,
EchartsTimeseriesFormData,
EchartsTimeseriesChartProps,
} from '../../../src/Timeseries/types';
import { GenericDataType } from '@apache-superset/core/api/core';
import {
D3_FORMAT_OPTIONS,
D3_TIME_FORMAT_OPTIONS,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/ui';
describe('Scatter Chart X-axis Time Formatting', () => {
const baseFormData: EchartsTimeseriesFormData = {
...DEFAULT_FORM_DATA,
colorScheme: 'supersetColors',
datasource: '1__table',
granularity_sqla: '__timestamp',
metric: ['column 1'],
groupby: [],
viz_type: 'echarts_timeseries_scatter',
seriesType: EchartsTimeseriesSeriesType.Scatter,
};
const timeseriesData = [
{
data: [
{ column_1: 0.72099, __timestamp: 1609459200000 },
{ column_1: 0.77954, __timestamp: 1612137600000 },
{ column_1: 2.83434, __timestamp: 1614556800000 },
],
colnames: ['column_1', '__timestamp'],
coltypes: [GenericDataType.Numeric, GenericDataType.Temporal],
},
];
const baseChartPropsConfig = {
width: 800,
height: 600,
queriesData: timeseriesData,
theme: supersetTheme,
};
test('xAxisTimeFormat has no default formatter', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(xAxis.axisLabel.formatter).toBeUndefined();
});
test.each(D3_TIME_FORMAT_OPTIONS.map(([id]) => id))(
'should handle %s format',
format => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: {
...baseFormData,
xAxisTimeFormat: format,
},
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
if (format === SMART_DATE_ID) {
expect(xAxis.axisLabel.formatter).toBeUndefined();
} else {
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe(format);
}
},
);
});
describe('Scatter Chart X-axis Number Formatting', () => {
const baseFormData: EchartsTimeseriesFormData = {
...DEFAULT_FORM_DATA,
colorScheme: 'supersetColors',
datasource: '1__table',
metric: ['column_1'],
x_axis: 'column_2',
groupby: [],
viz_type: 'echarts_timeseries_scatter',
seriesType: EchartsTimeseriesSeriesType.Scatter,
};
const timeseriesData = [
{
data: [
{ column_1: 0.72099, column_2: 3.01699 },
{ column_1: 0.77954, column_2: 3.44802 },
{ column_1: 2.83434, column_2: 3.58095 },
],
colnames: ['column_1', 'column_2'],
coltypes: [GenericDataType.Numeric, GenericDataType.Numeric],
},
];
const baseChartPropsConfig = {
width: 800,
height: 600,
queriesData: timeseriesData,
theme: supersetTheme,
};
test('should use SMART_NUMBER as default xAxisNumberFormat', () => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: baseFormData,
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe('SMART_NUMBER');
});
test.each(D3_FORMAT_OPTIONS.map(([id]) => id))(
'should handle %s format',
format => {
const chartProps = new ChartProps({
...baseChartPropsConfig,
formData: {
...baseFormData,
xAxisNumberFormat: format,
},
});
const transformedProps = transformProps(
// @ts-ignore
chartProps as EchartsTimeseriesChartProps,
);
expect(transformedProps.echartOptions.xAxis).toHaveProperty('axisLabel');
const xAxis = transformedProps.echartOptions.xAxis as any;
expect(xAxis.axisLabel).toHaveProperty('formatter');
expect(typeof xAxis.axisLabel.formatter).toBe('function');
expect(xAxis.axisLabel.formatter.id).toBe(format);
},
);
});

View File

@@ -166,7 +166,7 @@ function StickyWrap({
const scrollBodyRef = useRef<HTMLDivElement>(null); // main body
const scrollBarSize = getScrollBarSize();
const { bodyHeight, columnWidths, hasVerticalScroll } = sticky;
const { bodyHeight, columnWidths } = sticky;
const needSizer =
!columnWidths ||
sticky.width !== maxWidth ||
@@ -283,18 +283,13 @@ function StickyWrap({
</colgroup>
);
const headerContainerWidth = hasVerticalScroll
? maxWidth - scrollBarSize
: maxWidth;
headerTable = (
<div
key="header"
ref={scrollHeaderRef}
style={{
overflow: 'hidden',
width: headerContainerWidth,
boxSizing: 'border-box',
scrollbarGutter: 'stable',
}}
role="presentation"
>
@@ -314,8 +309,7 @@ function StickyWrap({
ref={scrollFooterRef}
style={{
overflow: 'hidden',
width: headerContainerWidth,
boxSizing: 'border-box',
scrollbarGutter: 'stable',
}}
role="presentation"
>
@@ -345,8 +339,6 @@ function StickyWrap({
height: bodyHeight,
overflow: 'auto',
scrollbarGutter: 'stable',
width: maxWidth,
boxSizing: 'border-box',
}}
css={scrollBarStyles}
onScroll={sticky.hasHorizontalScroll ? onScroll : undefined}

View File

@@ -25,33 +25,6 @@ import DateWithFormatter from '../src/utils/DateWithFormatter';
import testData from './testData';
import { ProviderWrapper } from './testHelpers';
const expectValidAriaLabels = (container: HTMLElement) => {
const allCells = container.querySelectorAll('tbody td');
const cellsWithLabels = container.querySelectorAll(
'tbody td[aria-labelledby]',
);
// Table must render data cells (catch empty table regression)
expect(allCells.length).toBeGreaterThan(0);
// ALL data cells must have aria-labelledby (no unlabeled cells)
expect(cellsWithLabels.length).toBe(allCells.length);
// ALL aria-labelledby values should be valid
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
expect(labelledBy).not.toBeNull();
expect(labelledBy).toEqual(expect.stringMatching(/\S/));
const labelledByValue = labelledBy as string;
expect(labelledByValue).not.toMatch(/\s/);
expect(labelledByValue).not.toMatch(/[%#△]/);
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledByValue)}`,
);
expect(referencedHeader).toBeTruthy();
});
};
test('sanitizeHeaderId should sanitize percent sign', () => {
expect(sanitizeHeaderId('%pct_nice')).toBe('percentpct_nice');
});
@@ -629,7 +602,7 @@ describe('plugin-chart-table', () => {
// Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety
const props = transformProps(testData.comparison);
render(<TableChart {...props} sticky={false} />);
const { container } = render(<TableChart {...props} sticky={false} />);
const headers = screen.getAllByRole('columnheader');
@@ -659,16 +632,25 @@ describe('plugin-chart-table', () => {
// IDs should only contain valid characters: alphanumeric, underscore, hyphen
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
});
test('should validate ARIA references for time-comparison table cells', () => {
// Test that ALL cells with aria-labelledby have valid references
// This is critical for screen reader accessibility
const props = transformProps(testData.comparison);
const { container } = render(<TableChart {...props} sticky={false} />);
expectValidAriaLabels(container);
// CRITICAL: Verify ALL cells reference valid headers (no broken ARIA)
const cellsWithLabels = container.querySelectorAll(
'td[aria-labelledby]',
);
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
if (labelledBy) {
// Check that the ID doesn't contain spaces (would be interpreted as multiple IDs)
expect(labelledBy).not.toMatch(/\s/);
// Check that the ID doesn't contain special characters
expect(labelledBy).not.toMatch(/[%#△]/);
// Verify the referenced header actually exists
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledBy)}`,
);
expect(referencedHeader).toBeTruthy();
}
});
});
test('should set meaningful header IDs for regular table columns', () => {
@@ -729,20 +711,25 @@ describe('plugin-chart-table', () => {
// IDs should only contain valid CSS selector characters
expect(header.id).toMatch(/^header-[a-zA-Z0-9_-]+$/);
});
});
test('should validate ARIA references for regular table cells', () => {
// Test that ALL cells with aria-labelledby have valid references
// This is critical for screen reader accessibility
const props = transformProps(testData.advanced);
const { container } = render(
ProviderWrapper({
children: <TableChart {...props} sticky={false} />,
}),
// Test 6: Verify ALL cells reference valid headers (no broken ARIA)
const cellsWithLabels = container.querySelectorAll(
'td[aria-labelledby]',
);
expectValidAriaLabels(container);
cellsWithLabels.forEach(cell => {
const labelledBy = cell.getAttribute('aria-labelledby');
if (labelledBy) {
// Verify no spaces (would be interpreted as multiple IDs)
expect(labelledBy).not.toMatch(/\s/);
// Verify no special characters
expect(labelledBy).not.toMatch(/[%#△]/);
// Verify the referenced header actually exists
const referencedHeader = container.querySelector(
`#${CSS.escape(labelledBy)}`,
);
expect(referencedHeader).toBeTruthy();
}
});
});
test('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {

View File

@@ -83,8 +83,6 @@ import {
} from 'src/logger/LogUtils';
import { Icons } from '@superset-ui/core/components/Icons';
import { findPermission } from 'src/utils/findPermission';
import { StreamingExportModal } from 'src/components/StreamingExportModal';
import { useStreamingExport } from 'src/components/StreamingExportModal/useStreamingExport';
import { ensureAppRoot } from 'src/utils/pathUtils';
import { useConfirmModal } from 'src/hooks/useConfirmModal';
import ExploreCtasResultsButton from '../ExploreCtasResultsButton';
@@ -186,10 +184,6 @@ const ResultSet = ({
defaultQueryLimit,
}: ResultSetProps) => {
const user = useSelector(({ user }: SqlLabRootState) => user, shallowEqual);
const streamingThreshold = useSelector(
(state: SqlLabRootState) =>
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD || 1000,
);
const query = useSelector(
({ sqlLab: { queries } }: SqlLabRootState) =>
pick(queries[queryId], [
@@ -230,21 +224,12 @@ const ResultSet = ({
const [searchText, setSearchText] = useState('');
const [cachedData, setCachedData] = useState<Record<string, unknown>[]>([]);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
const [showStreamingModal, setShowStreamingModal] = useState(false);
const history = useHistory();
const dispatch = useDispatch();
const logAction = useLogAction({ queryId, sqlEditorId: query.sqlEditorId });
const { showConfirm, ConfirmModal } = useConfirmModal();
const { progress, startExport, resetExport, retryExport, cancelExport } =
useStreamingExport({
onComplete: () => {},
onError: error => {
addDangerToast(t('Export failed: %s', error));
},
});
const reRunQueryIfSessionTimeoutErrorOnMount = useCallback(() => {
if (
query.errorMessage &&
@@ -317,28 +302,6 @@ const ResultSet = ({
const getExportCsvUrl = (clientId: string) =>
ensureAppRoot(`/api/v1/sqllab/export/${clientId}/`);
const handleCloseStreamingModal = () => {
cancelExport();
setShowStreamingModal(false);
resetExport();
};
const shouldUseStreamingExport = () => {
const { rows, queryLimit, limitingFactor } = query;
const limit = queryLimit || query.results?.query?.limit;
const rowsCount = Math.min(rows || 0, query.results?.data?.length || 0);
let actualRowCount = rowsCount;
if (limitingFactor === LimitingFactor.NotLimited && rows) {
actualRowCount = rows;
} else if (limit) {
actualRowCount = Math.max(actualRowCount, limit);
}
return actualRowCount >= streamingThreshold;
};
const renderControls = () => {
if (search || visualize || csv) {
const { limitingFactor, queryLimit, results, rows } = query;
@@ -409,27 +372,9 @@ const ResultSet = ({
<CopyStyledButton
buttonSize="small"
buttonStyle="secondary"
{...(!shouldUseStreamingExport() && {
href: getExportCsvUrl(query.id),
})}
href={getExportCsvUrl(query.id)}
data-test="export-csv-button"
onClick={e => {
const useStreaming = shouldUseStreamingExport();
if (useStreaming) {
e.preventDefault();
setShowStreamingModal(true);
startExport({
url: '/api/v1/sqllab/export_streaming/',
payload: { client_id: query.id },
exportType: 'csv',
expectedRows: rows,
});
} else {
handleDownloadCsv(e);
}
}}
onClick={handleDownloadCsv}
>
<Icons.DownloadOutlined iconSize="m" /> {t('Download to CSV')}
</CopyStyledButton>
@@ -778,75 +723,43 @@ const ResultSet = ({
</AutoSizer>
</div>
</ResultContainer>
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
onRetry={retryExport}
progress={progress}
/>
{ConfirmModal}
</>
);
}
if (data && data.length === 0) {
return (
<>
<Alert type="warning" message={t('The query returned no data')} />
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
onRetry={retryExport}
progress={progress}
/>
</>
);
return <Alert type="warning" message={t('The query returned no data')} />;
}
}
if (query.cached || (query.state === QueryState.Success && !query.results)) {
if (query.isDataPreview) {
return (
<>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={() =>
dispatch(
reFetchQueryResults({
...query,
isDataPreview: true,
}),
)
}
>
{t('Fetch data preview')}
</Button>
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
onRetry={retryExport}
progress={progress}
/>
</>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={() =>
dispatch(
reFetchQueryResults({
...query,
isDataPreview: true,
}),
)
}
>
{t('Fetch data preview')}
</Button>
);
}
if (query.resultsKey) {
return (
<>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={() => fetchResults(query)}
>
{t('Refetch results')}
</Button>
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
onRetry={retryExport}
progress={progress}
/>
</>
<Button
buttonSize="small"
buttonStyle="primary"
onClick={() => fetchResults(query)}
>
{t('Refetch results')}
</Button>
);
}
}
@@ -861,24 +774,15 @@ const ResultSet = ({
const progressMsg = query?.extra?.progress ?? null;
return (
<>
<ResultlessStyles>
<div>{!progressBar && <Loading position="normal" />}</div>
{/* show loading bar whenever progress bar is completed but needs time to render */}
<div>{query.progress === 100 && <Loading position="normal" />}</div>
<QueryStateLabel query={query} />
<div>
{progressMsg && <Alert type="success" message={progressMsg} />}
</div>
<div>{query.progress !== 100 && progressBar}</div>
{trackingUrl && <div>{trackingUrl}</div>}
</ResultlessStyles>
<StreamingExportModal
visible={showStreamingModal}
onCancel={handleCloseStreamingModal}
progress={progress}
/>
</>
<ResultlessStyles>
<div>{!progressBar && <Loading position="normal" />}</div>
{/* show loading bar whenever progress bar is completed but needs time to render */}
<div>{query.progress === 100 && <Loading position="normal" />}</div>
<QueryStateLabel query={query} />
<div>{progressMsg && <Alert type="success" message={progressMsg} />}</div>
<div>{query.progress !== 100 && progressBar}</div>
{trackingUrl && <div>{trackingUrl}</div>}
</ResultlessStyles>
);
};

View File

@@ -31,8 +31,6 @@ import { SqlLabRootState } from 'src/SqlLab/types';
import { useExtensionsContext } from 'src/extensions/ExtensionsContext';
import ExtensionsManager from 'src/extensions/ExtensionsManager';
import useQueryEditor from 'src/SqlLab/hooks/useQueryEditor';
import useLogAction from 'src/logger/useLogAction';
import { LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB } from 'src/logger/LogUtils';
import QueryHistory from '../QueryHistory';
import {
STATUS_OPTIONS,
@@ -128,10 +126,8 @@ const SouthPane = ({
[pinnedTables],
);
const southPaneRef = createRef<HTMLDivElement>();
const logAction = useLogAction({ sqlEditorId: queryEditorId });
const switchTab = (id: string) => {
dispatch(setActiveSouthPaneTab(id));
logAction(LOG_ACTIONS_SQLLAB_SWITCH_SOUTH_PANE_TAB, { tab: id });
};
const removeTable = useCallback(
(key, action) => {

View File

@@ -90,16 +90,11 @@ const ModalFooter = ({ formData, closeModal }: ModalFooterProps) => {
findPermission('can_explore', 'Superset', state.user?.roles),
);
const [datasourceIdStr, datasource_type] = formData.datasource.split('__');
// Try to parse as integer, fall back to string (UUID) if NaN
const parsedDatasourceId = parseInt(datasourceIdStr, 10);
const datasource_id = Number.isNaN(parsedDatasourceId)
? datasourceIdStr
: parsedDatasourceId;
const [datasource_id, datasource_type] = formData.datasource.split('__');
useEffect(() => {
// short circuit if the user is embedded as explore is not available
if (isEmbedded()) return;
postFormData(datasource_id, datasource_type, formData, 0)
postFormData(Number(datasource_id), datasource_type, formData, 0)
.then(key => {
setUrl(
`/explore/?form_data_key=${key}&dashboard_page_id=${dashboardPageId}`,

View File

@@ -407,12 +407,10 @@ export function exploreJSON(
ownState,
) {
return async (dispatch, getState) => {
const state = getState();
const logStart = Logger.getTimestamp();
const controller = new AbortController();
const prevController = state.charts?.[key]?.queryController;
const queryTimeout =
timeout || state.common.conf.SUPERSET_WEBSERVER_TIMEOUT;
timeout || getState().common.conf.SUPERSET_WEBSERVER_TIMEOUT;
const requestParams = {
signal: controller.signal,
@@ -424,14 +422,6 @@ export function exploreJSON(
dispatch(updateDataMask(formData.slice_id, dataMask));
};
dispatch(chartUpdateStarted(controller, formData, key));
/**
* Abort in-flight requests after the new controller has been stored in
* state. Delaying ensures we do not mutate the Redux state between
* dispatches while still cancelling the previous request promptly.
*/
if (prevController) {
setTimeout(() => prevController.abort(), 0);
}
const chartDataRequest = getChartDataRequest({
setDataMask,

View File

@@ -31,7 +31,6 @@ import * as exploreUtils from 'src/explore/exploreUtils';
import * as actions from 'src/components/Chart/chartAction';
import * as asyncEvent from 'src/middleware/asyncEvent';
import { handleChartDataResponse } from 'src/components/Chart/chartAction';
import * as dataMaskActions from 'src/dataMask/actions';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
@@ -111,69 +110,6 @@ describe('chart actions', () => {
.callsFake(data => Promise.resolve(data));
});
test('should defer abort of previous controller to avoid Redux state mutation', async () => {
jest.useFakeTimers();
const chartKey = 'defer_abort_test';
const formData = {
slice_id: 123,
datasource: 'table__1',
viz_type: 'table',
};
const oldController = new AbortController();
const abortSpy = jest.spyOn(oldController, 'abort');
const state = {
charts: {
[chartKey]: {
queryController: oldController,
},
},
common: {
conf: {
SUPERSET_WEBSERVER_TIMEOUT: 60,
},
},
};
const getState = jest.fn(() => state);
const dispatchMock = jest.fn();
const getChartDataRequestSpy = jest
.spyOn(actions, 'getChartDataRequest')
.mockResolvedValue({
response: { status: 200 },
json: { result: [] },
});
const handleChartDataResponseSpy = jest
.spyOn(actions, 'handleChartDataResponse')
.mockResolvedValue([]);
const updateDataMaskSpy = jest
.spyOn(dataMaskActions, 'updateDataMask')
.mockReturnValue({ type: 'UPDATE_DATA_MASK' });
const getQuerySettingsStub = sinon
.stub(exploreUtils, 'getQuerySettings')
.returns([false, () => {}]);
try {
const thunk = actions.exploreJSON(formData, false, undefined, chartKey);
const promise = thunk(dispatchMock, getState);
expect(abortSpy).not.toHaveBeenCalled();
expect(oldController.signal.aborted).toBe(false);
jest.runOnlyPendingTimers();
expect(abortSpy).toHaveBeenCalledTimes(1);
expect(oldController.signal.aborted).toBe(true);
await promise;
} finally {
getChartDataRequestSpy.mockRestore();
handleChartDataResponseSpy.mockRestore();
updateDataMaskSpy.mockRestore();
getQuerySettingsStub.restore();
abortSpy.mockRestore();
jest.useRealTimers();
}
});
afterEach(() => {
getExploreUrlStub.restore();
getChartDataUriStub.restore();

View File

@@ -1,244 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, screen, userEvent } from 'spec/helpers/testing-library';
import StreamingExportModal, {
ExportStatus,
StreamingProgress,
} from './StreamingExportModal';
const defaultProgress: StreamingProgress = {
rowsProcessed: 0,
totalRows: 1000,
totalSize: 0,
status: ExportStatus.STREAMING,
filename: 'test_export.csv',
};
const defaultProps = {
visible: true,
onCancel: jest.fn(),
onRetry: jest.fn(),
progress: defaultProgress,
};
beforeEach(() => {
jest.clearAllMocks();
URL.revokeObjectURL = jest.fn();
URL.createObjectURL = jest.fn(() => 'blob:mock-url');
});
test('renders modal with streaming state', () => {
render(<StreamingExportModal {...defaultProps} />);
expect(screen.getByText('CSV Export')).toBeInTheDocument();
expect(
screen.getByText(/Processing export for test_export.csv/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled();
});
test('shows progress percentage during streaming', () => {
const progress = {
...defaultProgress,
rowsProcessed: 500,
totalRows: 1000,
status: ExportStatus.STREAMING,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
test('shows completed state when export finishes', () => {
const progress = {
...defaultProgress,
rowsProcessed: 1000,
totalRows: 1000,
status: ExportStatus.COMPLETED,
downloadUrl: 'blob:mock-url',
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(
screen.getByText(/Export successful: test_export.csv/i),
).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Download' })).toBeEnabled();
});
test('shows error state when export fails', () => {
const progress = {
...defaultProgress,
status: ExportStatus.ERROR,
error: 'Database connection failed',
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByText('Database connection failed')).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
test('shows cancelled state when export is cancelled', () => {
const progress = {
...defaultProgress,
status: ExportStatus.CANCELLED,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByText('Export cancelled')).toBeInTheDocument();
expect(
screen.getAllByRole('button', { name: 'Close' })[0],
).toBeInTheDocument();
});
test('calls onCancel when cancel button is clicked during streaming', async () => {
const onCancel = jest.fn();
render(<StreamingExportModal {...defaultProps} onCancel={onCancel} />);
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('calls onRetry when retry button is clicked after error', async () => {
const onRetry = jest.fn();
const progress = {
...defaultProgress,
status: ExportStatus.ERROR,
error: 'Network error',
};
render(
<StreamingExportModal
{...defaultProps}
progress={progress}
onRetry={onRetry}
/>,
);
await userEvent.click(screen.getByRole('button', { name: 'Retry' }));
expect(onRetry).toHaveBeenCalledTimes(1);
});
test('triggers download when download button is clicked', async () => {
const progress = {
...defaultProgress,
rowsProcessed: 1000,
totalRows: 1000,
status: ExportStatus.COMPLETED,
downloadUrl: 'blob:mock-url',
filename: 'test_export.csv',
};
const onCancel = jest.fn();
render(
<StreamingExportModal
{...defaultProps}
progress={progress}
onCancel={onCancel}
/>,
);
const downloadButton = screen.getByRole('button', { name: 'Download' });
expect(downloadButton).toBeEnabled();
await userEvent.click(downloadButton);
expect(onCancel).toHaveBeenCalledTimes(1);
});
test('does not show download button when downloadUrl is missing', () => {
const progress = {
...defaultProgress,
status: ExportStatus.COMPLETED,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByRole('button', { name: 'Download' })).toBeDisabled();
});
test('progress bar shows correct percentage with decimal precision', () => {
const progress = {
...defaultProgress,
rowsProcessed: 333,
totalRows: 1000,
status: ExportStatus.STREAMING,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
});
test('shows generic processing message when filename is not provided', () => {
const progress = {
...defaultProgress,
filename: undefined,
status: ExportStatus.STREAMING,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByText('Processing export...')).toBeInTheDocument();
});
test('handles retry button visibility based on onRetry prop', () => {
const progress = {
...defaultProgress,
status: ExportStatus.ERROR,
error: 'Test error',
};
const { rerender } = render(
<StreamingExportModal
{...defaultProps}
progress={progress}
onRetry={undefined}
/>,
);
expect(
screen.queryByRole('button', { name: 'Retry' }),
).not.toBeInTheDocument();
rerender(
<StreamingExportModal
{...defaultProps}
progress={progress}
onRetry={jest.fn()}
/>,
);
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
test('shows generic error message when error details are not provided', () => {
const progress = {
...defaultProgress,
status: ExportStatus.ERROR,
};
render(<StreamingExportModal {...defaultProps} progress={progress} />);
expect(screen.getByText('Export failed')).toBeInTheDocument();
});

View File

@@ -1,380 +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 { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import {
Modal,
Button,
Typography,
Progress,
} from '@superset-ui/core/components';
import { Icons } from '@superset-ui/core/components/Icons';
const { Text } = Typography;
export enum ExportStatus {
STREAMING = 'streaming',
COMPLETED = 'completed',
ERROR = 'error',
CANCELLED = 'cancelled',
}
const COMPLETED_PERCENT = 100;
export interface StreamingProgress {
totalRows?: number;
rowsProcessed: number;
totalSize: number;
status: ExportStatus;
downloadUrl?: string;
error?: string;
filename?: string;
speed?: number;
mbPerSecond?: number;
elapsedTime?: number;
retryCount?: number;
}
interface StreamingExportModalProps {
visible: boolean;
onCancel: () => void;
onRetry?: () => void;
onDownload?: () => void;
progress: StreamingProgress;
}
const ModalContent = styled.div`
${({ theme }) => `
padding: ${theme.sizeUnit * 4}px 0 ${theme.sizeUnit * 2}px;
`}
`;
const ProgressSection = styled.div`
${({ theme }) => `
margin: ${theme.sizeUnit * 6}px 0;
position: relative;
`}
`;
const ProgressWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
gap: ${theme.sizeUnit * 3}px;
`}
`;
const StyledProgress = styled(Progress)`
flex: 1;
`;
const SuccessIcon = styled(Icons.CheckCircleFilled)`
${({ theme }) => `
color: ${theme.colorSuccess};
font-size: ${theme.sizeUnit * 6}px;
flex-shrink: 0;
`}
`;
const ErrorIconWrapper = styled.div`
${({ theme }) => `
display: flex;
align-items: center;
justify-content: center;
width: ${theme.sizeUnit * 4}px;
height: ${theme.sizeUnit * 4}px;
background-color: ${theme.colorError};
border-radius: 50%;
flex-shrink: 0;
`}
`;
const ErrorIconStyled = styled(Icons.CloseOutlined)`
${({ theme }) => `
color: ${theme.colorWhite};
font-size: ${theme.sizeUnit * 2.5}px;
`}
`;
const ActionButtons = styled.div`
${({ theme }) => `
display: flex;
gap: ${theme.sizeUnit * 2}px;
justify-content: flex-end;
`}
`;
const CenteredText = styled(Text)`
${({ theme }) => `
display: block;
text-align: center;
margin-top: ${theme.sizeUnit * 4}px;
`}
`;
const ErrorText = styled(CenteredText)`
${({ theme }) => `
color: ${theme.colorError};
`}
`;
const CancelButton = styled(Button)`
${({ theme }) => `
background-color: ${theme.colorSuccessBg};
color: ${theme.colorSuccess};
border-color: ${theme.colorSuccessBg};
&:hover {
background-color: ${theme.colorSuccessBg};
color: ${theme.colorSuccess};
border-color: ${theme.colorSuccess};
}
&:focus {
background-color: ${theme.colorSuccessBg};
color: ${theme.colorSuccess};
border-color: ${theme.colorSuccess};
}
`}
`;
const DownloadButton = styled(Button)`
${({ theme }) => `
background-color: ${theme.colorSuccess};
border-color: ${theme.colorSuccess};
color: ${theme.colorWhite};
&:hover:not(:disabled) {
background-color: ${theme.colorSuccessActive};
border-color: ${theme.colorSuccessActive};
color: ${theme.colorWhite};
}
&:focus:not(:disabled) {
background-color: ${theme.colorSuccess};
border-color: ${theme.colorSuccess};
color: ${theme.colorWhite};
}
&:disabled {
background-color: ${theme.colorBgContainerDisabled};
border-color: ${theme.colorBgContainerDisabled};
color: ${theme.colorTextDisabled};
}
`}
`;
const triggerFileDownload = (url: string, filename: string) => {
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
const calculateProgressPercentage = (
status: ExportStatus,
totalRows?: number,
rowsProcessed?: number,
): number => {
if (status === ExportStatus.COMPLETED) return COMPLETED_PERCENT;
if (!totalRows || totalRows <= 0 || !rowsProcessed) return 0;
const percentage = (rowsProcessed / totalRows) * 100;
return Math.floor(percentage);
};
const getProgressStatus = (
status: ExportStatus,
): 'success' | 'exception' | 'normal' => {
switch (status) {
case ExportStatus.COMPLETED:
return 'success';
case ExportStatus.ERROR:
case ExportStatus.CANCELLED:
return 'exception';
case ExportStatus.STREAMING:
default:
return 'normal';
}
};
const getMessageText = (
status: ExportStatus,
filename?: string,
error?: string,
): string => {
switch (status) {
case ExportStatus.ERROR:
return error || t('Export failed');
case ExportStatus.CANCELLED:
return t('Export cancelled');
case ExportStatus.COMPLETED:
return t('Export successful: %s', filename || 'export');
case ExportStatus.STREAMING:
default:
return filename
? t('Processing export for %s', filename)
: t('Processing export...');
}
};
const getButtonText = (status: ExportStatus): string => {
switch (status) {
case ExportStatus.ERROR:
case ExportStatus.CANCELLED:
case ExportStatus.COMPLETED:
return t('Close');
case ExportStatus.STREAMING:
default:
return t('Cancel');
}
};
interface ModalStateContentProps {
status: ExportStatus;
progress: StreamingProgress;
onCancel: () => void;
onRetry?: () => void;
onDownload: () => void;
getProgressPercentage: () => number;
}
const ModalStateContent = ({
status,
progress,
onCancel,
onRetry,
onDownload,
getProgressPercentage,
}: ModalStateContentProps) => {
const theme = useTheme();
const { downloadUrl, filename, error } = progress;
const isError = status === ExportStatus.ERROR;
const isCancelled = status === ExportStatus.CANCELLED;
const isCompleted = status === ExportStatus.COMPLETED;
const isStreaming = status === ExportStatus.STREAMING;
const hasIcon = isError || isCompleted;
const shouldShowRetry = (isError || isCancelled) && onRetry;
const progressStatus = getProgressStatus(status);
const progressPercent = isCompleted ? 100 : getProgressPercentage();
const messageText = getMessageText(status, filename, error);
const buttonText = getButtonText(status);
const progressProps = {
percent: progressPercent,
status: progressStatus,
showInfo: isStreaming,
...(isStreaming && {
strokeColor: theme.colorSuccess,
format: (percent?: number) => `${Math.round(percent || 0)}%`,
}),
};
return (
<ModalContent>
<ProgressSection>
{hasIcon ? (
<ProgressWrapper>
<StyledProgress {...progressProps} />
{isError && (
<ErrorIconWrapper>
<ErrorIconStyled />
</ErrorIconWrapper>
)}
{isCompleted && <SuccessIcon />}
</ProgressWrapper>
) : (
<Progress {...progressProps} />
)}
{isError ? (
<ErrorText>{messageText}</ErrorText>
) : (
<CenteredText>{messageText}</CenteredText>
)}
</ProgressSection>
<ActionButtons>
<CancelButton onClick={onCancel}>{buttonText}</CancelButton>
{shouldShowRetry ? (
<DownloadButton onClick={onRetry}>{t('Retry')}</DownloadButton>
) : (
<DownloadButton
onClick={onDownload}
disabled={!isCompleted || !downloadUrl}
>
{t('Download')}
</DownloadButton>
)}
</ActionButtons>
</ModalContent>
);
};
const StreamingExportModal = ({
visible,
onCancel,
onRetry,
onDownload,
progress,
}: StreamingExportModalProps) => {
const { status, downloadUrl, filename } = progress;
const getProgressPercentage = (): number =>
calculateProgressPercentage(
status,
progress.totalRows,
progress.rowsProcessed,
);
const handleDownload = () => {
if (downloadUrl && filename) {
triggerFileDownload(downloadUrl, filename);
onDownload?.(); // Call onDownload callback if provided
onCancel();
}
};
return (
<Modal
title={t('CSV Export')}
show={visible}
onHide={onCancel}
hideFooter
width={600}
maskClosable={false}
centered
>
<ModalStateContent
status={status}
progress={progress}
onCancel={onCancel}
onRetry={onRetry}
onDownload={handleDownload}
getProgressPercentage={getProgressPercentage}
/>
</Modal>
);
};
export default StreamingExportModal;

View File

@@ -1,21 +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.
*/
export { default as StreamingExportModal } from './StreamingExportModal';
export type { StreamingProgress } from './StreamingExportModal';
export { useStreamingExport } from './useStreamingExport';

View File

@@ -1,126 +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 { renderHook, act } from '@testing-library/react-hooks';
import { useStreamingExport } from './useStreamingExport';
import { ExportStatus } from './StreamingExportModal';
// Mock SupersetClient
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
SupersetClient: {
getCSRFToken: jest.fn(() => Promise.resolve('mock-csrf-token')),
},
}));
global.URL.createObjectURL = jest.fn(() => 'blob:mock-url');
global.URL.revokeObjectURL = jest.fn();
global.fetch = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
test('useStreamingExport initializes with default progress state', () => {
const { result } = renderHook(() => useStreamingExport());
expect(result.current.progress).toEqual({
rowsProcessed: 0,
totalRows: undefined,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
});
});
test('useStreamingExport provides startExport function', () => {
const { result } = renderHook(() => useStreamingExport());
expect(typeof result.current.startExport).toBe('function');
});
test('useStreamingExport provides resetExport function', () => {
const { result } = renderHook(() => useStreamingExport());
expect(typeof result.current.resetExport).toBe('function');
});
test('useStreamingExport provides retryExport function', () => {
const { result } = renderHook(() => useStreamingExport());
expect(typeof result.current.retryExport).toBe('function');
});
test('useStreamingExport provides cancelExport function', () => {
const { result } = renderHook(() => useStreamingExport());
expect(typeof result.current.cancelExport).toBe('function');
});
test('useStreamingExport resetExport resets progress to initial state', () => {
const { result } = renderHook(() => useStreamingExport());
act(() => {
result.current.resetExport();
});
expect(result.current.progress.status).toBe(ExportStatus.STREAMING);
expect(result.current.progress.rowsProcessed).toBe(0);
expect(result.current.progress.totalSize).toBe(0);
});
test('useStreamingExport accepts onComplete callback option', () => {
const onComplete = jest.fn();
const { result } = renderHook(() => useStreamingExport({ onComplete }));
expect(result.current).toBeDefined();
});
test('useStreamingExport accepts onError callback option', () => {
const onError = jest.fn();
const { result } = renderHook(() => useStreamingExport({ onError }));
expect(result.current).toBeDefined();
});
test('useStreamingExport progress includes all required fields', () => {
const { result } = renderHook(() => useStreamingExport());
expect(result.current.progress).toHaveProperty('rowsProcessed');
expect(result.current.progress).toHaveProperty('totalRows');
expect(result.current.progress).toHaveProperty('totalSize');
expect(result.current.progress).toHaveProperty('status');
expect(result.current.progress).toHaveProperty('speed');
expect(result.current.progress).toHaveProperty('mbPerSecond');
expect(result.current.progress).toHaveProperty('elapsedTime');
});
test('useStreamingExport cleans up on unmount', () => {
const revokeObjectURL = jest.fn();
global.URL.revokeObjectURL = revokeObjectURL;
const { unmount } = renderHook(() => useStreamingExport());
unmount();
// Cleanup should not throw errors
expect(true).toBe(true);
});

View File

@@ -1,380 +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 { useState, useCallback, useRef, useEffect } from 'react';
import { SupersetClient } from '@superset-ui/core';
import { ExportStatus, StreamingProgress } from './StreamingExportModal';
interface UseStreamingExportOptions {
onComplete?: (downloadUrl: string, filename: string) => void;
onError?: (error: string) => void;
}
interface StreamingExportPayload {
[key: string]: any;
}
interface StreamingExportParams {
url: string;
payload: StreamingExportPayload;
filename?: string;
exportType: 'csv' | 'xlsx';
expectedRows?: number;
}
const NEWLINE_BYTE = 10; // '\n' character code
const createFetchRequest = async (
_url: string,
payload: StreamingExportPayload,
filename: string | undefined,
_exportType: string,
expectedRows: number | undefined,
signal: AbortSignal,
): Promise<RequestInit> => {
const headers: Record<string, string> = {
'Content-Type': 'application/x-www-form-urlencoded',
};
// Get CSRF token using SupersetClient
const csrfToken = await SupersetClient.getCSRFToken();
if (csrfToken) {
headers['X-CSRFToken'] = csrfToken;
}
const formParams: Record<string, string> = {};
if (filename) {
formParams.filename = filename;
}
if (expectedRows) {
formParams.expected_rows = expectedRows.toString();
}
if ('client_id' in payload) {
// SQL Lab export - pass client_id directly
formParams.client_id = String(payload.client_id);
} else {
// Chart export - wrap payload in form_data
formParams.form_data = JSON.stringify(payload);
}
return {
method: 'POST',
headers,
body: new URLSearchParams(formParams),
signal,
credentials: 'same-origin',
};
};
const countNewlines = (value: Uint8Array): number =>
value.filter(byte => byte === NEWLINE_BYTE).length;
const createBlob = (
chunks: Uint8Array[],
receivedLength: number,
exportType: string,
): Blob => {
const completeData = new Uint8Array(receivedLength);
let position = 0;
for (const chunk of chunks) {
completeData.set(chunk, position);
position += chunk.length;
}
const mimeType =
exportType === 'csv'
? 'text/csv;charset=utf-8'
: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
return new Blob([completeData], { type: mimeType });
};
export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
const [progress, setProgress] = useState<StreamingProgress>({
rowsProcessed: 0,
totalRows: undefined,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
});
const [retryCount, setRetryCount] = useState(0);
const abortControllerRef = useRef<AbortController | null>(null);
const lastExportParamsRef = useRef<StreamingExportParams | null>(null);
const currentBlobUrlRef = useRef<string | null>(null);
const isExportingRef = useRef(false);
const updateProgress = useCallback((updates: Partial<StreamingProgress>) => {
setProgress(prev => ({ ...prev, ...updates }));
}, []);
const executeExport = useCallback(
async (params: StreamingExportParams) => {
const { url, payload, filename, exportType, expectedRows } = params;
if (isExportingRef.current) {
return;
}
isExportingRef.current = true;
abortControllerRef.current = new AbortController();
updateProgress({
rowsProcessed: 0,
totalRows: expectedRows,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
filename,
});
try {
const fetchOptions = await createFetchRequest(
url,
payload,
filename,
exportType,
expectedRows,
abortControllerRef.current.signal,
);
const response = await fetch(url, fetchOptions);
if (!response.ok) {
throw new Error(
`Export failed: ${response.status} ${response.statusText}`,
);
}
if (!response.body) {
throw new Error('Response body is not available for streaming');
}
const contentDisposition = response.headers.get('Content-Disposition');
const defaultFilename = `export.${exportType}`;
let serverFilename = defaultFilename;
if (contentDisposition) {
const filenameMatch =
contentDisposition.match(/filename="?([^"]+)"?/);
if (filenameMatch && filenameMatch[1]) {
serverFilename = filenameMatch[1];
}
}
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let receivedLength = 0;
let rowsProcessed = 0;
let hasError = false;
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
if (abortControllerRef.current?.signal.aborted) {
throw new Error('Export cancelled by user');
}
// Check for error marker in the chunk
const textDecoder = new TextDecoder();
const chunkText = textDecoder.decode(value);
if (chunkText.includes('__STREAM_ERROR__')) {
const errorMatch = chunkText.match(/__STREAM_ERROR__:(.+)/);
const errorMsg = errorMatch
? errorMatch[1].trim()
: 'Export failed. Please try again.';
// Update progress to show error with current progress preserved
updateProgress({
status: ExportStatus.ERROR,
error: errorMsg,
rowsProcessed,
totalRows: expectedRows,
totalSize: receivedLength,
});
isExportingRef.current = false;
options.onError?.(errorMsg);
hasError = true;
break;
}
chunks.push(value);
receivedLength += value.length;
// Count newlines using filter (more efficient than loop)
// Note: This counts all newlines, including those within quoted CSV fields.
// For an exact row count, server should send row count in response headers.
rowsProcessed += countNewlines(value);
// Update progress based on rows processed
updateProgress({
status: ExportStatus.STREAMING,
rowsProcessed,
totalRows: expectedRows,
totalSize: receivedLength,
filename: serverFilename,
});
}
// Check if we exited early due to error marker
if (hasError) {
return;
}
const blob = createBlob(chunks, receivedLength, exportType);
if (currentBlobUrlRef.current) {
URL.revokeObjectURL(currentBlobUrlRef.current);
}
const downloadUrl = URL.createObjectURL(blob);
currentBlobUrlRef.current = downloadUrl;
updateProgress({
status: ExportStatus.COMPLETED,
downloadUrl,
filename: serverFilename,
});
isExportingRef.current = false;
options.onComplete?.(downloadUrl, serverFilename);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : 'Unknown error occurred';
if (
errorMessage.includes('cancelled') ||
errorMessage.includes('aborted')
) {
updateProgress({
status: ExportStatus.CANCELLED,
});
isExportingRef.current = false;
} else {
updateProgress({
status: ExportStatus.ERROR,
error: errorMessage,
});
options.onError?.(errorMessage);
isExportingRef.current = false;
}
} finally {
abortControllerRef.current = null;
}
},
[updateProgress, options],
);
const startExport = useCallback(
async (params: StreamingExportParams) => {
if (isExportingRef.current) {
return;
}
setRetryCount(0);
lastExportParamsRef.current = params;
updateProgress({
rowsProcessed: 0,
totalRows: params.expectedRows,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
filename: params.filename,
});
executeExport(params);
},
[updateProgress, executeExport],
);
const retryExport = useCallback(() => {
if (!lastExportParamsRef.current) {
return;
}
if (isExportingRef.current) {
return;
}
setRetryCount(0);
executeExport(lastExportParamsRef.current);
}, [executeExport]);
const cancelExport = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
updateProgress({
status: ExportStatus.CANCELLED,
});
}
}, [updateProgress]);
const resetExport = useCallback(() => {
if (currentBlobUrlRef.current) {
URL.revokeObjectURL(currentBlobUrlRef.current);
currentBlobUrlRef.current = null;
}
isExportingRef.current = false;
abortControllerRef.current = null;
setProgress({
rowsProcessed: 0,
totalRows: undefined,
totalSize: 0,
speed: 0,
mbPerSecond: 0,
elapsedTime: 0,
status: ExportStatus.STREAMING,
});
}, []);
// Cleanup blob URL on unmount to prevent memory leak
useEffect(
() => () => {
if (currentBlobUrlRef.current) {
URL.revokeObjectURL(currentBlobUrlRef.current);
}
},
[],
);
return {
progress,
isExporting: isExportingRef.current,
retryCount,
startExport,
cancelExport,
resetExport,
retryExport,
};
};

View File

@@ -194,10 +194,3 @@ export enum Actions {
CREATE = 'create',
UPDATE = 'update',
}
/**
* Default threshold for CSV streaming export.
* Exports with row counts >= this value will use streaming with progress tracking.
* Exports with row counts < this value will use traditional download.
*/
export const DEFAULT_CSV_STREAMING_ROW_THRESHOLD = 100000;

View File

@@ -58,52 +58,30 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({
jest.mock('src/components/ResizableSidebar/useStoredSidebarWidth');
// mock following dependent components to fix the prop warnings
jest.mock('@superset-ui/core/components/Select/Select', () => {
const MockSelect = () => <div data-test="mock-select" />;
MockSelect.displayName = 'MockSelect';
return MockSelect;
});
jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => {
const MockAsyncSelect = () => <div data-test="mock-async-select" />;
MockAsyncSelect.displayName = 'MockAsyncSelect';
return MockAsyncSelect;
});
jest.mock('@superset-ui/core/components/PageHeaderWithActions', () => {
const MockPageHeaderWithActions = () => (
jest.mock('@superset-ui/core/components/Select/Select', () => () => (
<div data-test="mock-select" />
));
jest.mock('@superset-ui/core/components/Select/AsyncSelect', () => () => (
<div data-test="mock-async-select" />
));
jest.mock('@superset-ui/core/components/PageHeaderWithActions', () => ({
PageHeaderWithActions: () => (
<div data-test="mock-page-header-with-actions" />
);
MockPageHeaderWithActions.displayName = 'MockPageHeaderWithActions';
return {
PageHeaderWithActions: MockPageHeaderWithActions,
};
});
),
}));
jest.mock(
'src/dashboard/components/nativeFilters/FiltersConfigModal/FiltersConfigModal',
() => {
const MockFiltersConfigModal = () => (
<div data-test="mock-filters-config-modal" />
);
MockFiltersConfigModal.displayName = 'MockFiltersConfigModal';
return MockFiltersConfigModal;
},
() => () => <div data-test="mock-filters-config-modal" />,
);
jest.mock('src/dashboard/components/BuilderComponentPane', () => {
const MockBuilderComponentPane = () => (
<div data-test="mock-builder-component-pane" />
);
MockBuilderComponentPane.displayName = 'MockBuilderComponentPane';
return MockBuilderComponentPane;
});
jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => {
const MockFilterBar = () => <div data-test="mock-filter-bar" />;
MockFilterBar.displayName = 'MockFilterBar';
return MockFilterBar;
});
jest.mock('src/dashboard/containers/DashboardGrid', () => {
const MockDashboardGrid = () => <div data-test="mock-dashboard-grid" />;
MockDashboardGrid.displayName = 'MockDashboardGrid';
return MockDashboardGrid;
});
jest.mock('src/dashboard/components/BuilderComponentPane', () => () => (
<div data-test="mock-builder-component-pane" />
));
jest.mock('src/dashboard/components/nativeFilters/FilterBar', () => () => (
<div data-test="mock-filter-bar" />
));
jest.mock('src/dashboard/containers/DashboardGrid', () => () => (
<div data-test="mock-dashboard-grid" />
));
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('DashboardBuilder', () => {
@@ -200,8 +178,8 @@ describe('DashboardBuilder', () => {
dashboardLayout: undoableDashboardLayoutWithTabs,
});
const parentSize = await findByTestId('grid-container');
const firstTab = screen.getByText('tab1');
expect(firstTab).toBeInTheDocument();
const first_tab = screen.getByText('tab1');
expect(first_tab).toBeInTheDocument();
const tabPanels = within(parentSize).getAllByRole('tabpanel', {
// to include invisible tab panels
hidden: false,
@@ -220,9 +198,9 @@ describe('DashboardBuilder', () => {
},
});
const parentSize = await findByTestId('grid-container');
const secondTab = screen.getByText('tab2');
expect(secondTab).toBeInTheDocument();
fireEvent.click(secondTab);
const second_tab = screen.getByText('tab2');
expect(second_tab).toBeInTheDocument();
fireEvent.click(second_tab);
const tabPanels = within(parentSize).getAllByRole('tabpanel', {
// to include invisible tab panels
hidden: true,
@@ -457,67 +435,3 @@ describe('DashboardBuilder', () => {
expect(queryByTestId('dashboard-filters-panel')).not.toBeInTheDocument();
});
});
test('should render ParentSize wrapper with height 100% for tabs', async () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
const { findByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayoutWithTabs,
}),
useDnd: true,
useTheme: true,
});
const gridContainer = await findByTestId('grid-container');
const parentSizeWrapper = gridContainer.querySelector('div');
const tabPanels = within(gridContainer).getAllByRole('tabpanel', {
hidden: true,
});
expect(gridContainer).toBeInTheDocument();
expect(parentSizeWrapper).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});
test('should maintain layout when switching between tabs', async () => {
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
100,
jest.fn(),
]);
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
(setDirectPathToChild as jest.Mock).mockImplementation(arg0 => ({
type: 'type',
arg0,
}));
const { findByTestId } = render(<DashboardBuilder />, {
useRedux: true,
store: storeWithState({
...mockState,
dashboardLayout: undoableDashboardLayoutWithTabs,
}),
useDnd: true,
useTheme: true,
});
const gridContainer = await findByTestId('grid-container');
fireEvent.click(screen.getByText('tab1'));
fireEvent.click(screen.getByText('tab2'));
const tabPanels = within(gridContainer).getAllByRole('tabpanel', {
hidden: true,
});
expect(gridContainer).toBeInTheDocument();
expect(tabPanels.length).toBeGreaterThan(0);
});

View File

@@ -298,7 +298,7 @@ const StyledDashboardContent = styled.div<{
/* this is the ParentSize wrapper */
& > div:first-child {
height: 100% !important;
height: inherit !important;
}
}

View File

@@ -314,7 +314,6 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
renderTabBar={renderTabBar}
animated={false}
allowOverflow
fullHeight
onFocus={handleFocus}
items={tabItems}
tabBarStyle={{ paddingLeft: 0 }}

View File

@@ -54,9 +54,6 @@ const DashboardEmptyStateContainer = styled.div`
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: center;
`;
const GridContent = styled.div`

View File

@@ -24,22 +24,22 @@ import newComponentFactory from 'src/dashboard/util/newComponentFactory';
import { DASHBOARD_GRID_TYPE } from 'src/dashboard/util/componentTypes';
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
jest.mock('src/dashboard/containers/DashboardComponent', () => {
const MockDashboardComponent = ({ onResizeStart, onResizeStop }) => (
<button
type="button"
data-test="mock-dashboard-component"
onClick={() => onResizeStart()}
onBlur={() =>
onResizeStop(null, null, null, { width: 1, height: 3 }, 'id')
}
>
Mock
</button>
);
MockDashboardComponent.displayName = 'MockDashboardComponent';
return MockDashboardComponent;
});
jest.mock(
'src/dashboard/containers/DashboardComponent',
() =>
({ onResizeStart, onResizeStop }) => (
<button
type="button"
data-test="mock-dashboard-component"
onClick={() => onResizeStart()}
onBlur={() =>
onResizeStop(null, null, null, { width: 1, height: 3 }, 'id')
}
>
Mock
</button>
),
);
const props = {
depth: 1,
@@ -106,51 +106,3 @@ test('should call resizeComponent when a child DashboardComponent calls resizeSt
height: 3,
});
});
test('should apply flexbox centering and absolute positioning to empty state', () => {
const { container } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: true,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const dashboardGrid = container.querySelector('.dashboard-grid');
const emptyState = dashboardGrid?.previousElementSibling;
expect(emptyState).toBeInTheDocument();
const styles = window.getComputedStyle(emptyState);
expect(styles.display).toBe('flex');
expect(styles.alignItems).toBe('center');
expect(styles.justifyContent).toBe('center');
expect(styles.position).toBe('absolute');
});
test('should render empty state in both edit and view modes', () => {
const { container: editContainer } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: true,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const { container: viewContainer } = setup({
gridComponent: { ...props.gridComponent, children: [] },
editMode: false,
canEdit: true,
setEditMode: jest.fn(),
dashboardId: 1,
});
const editDashboardGrid = editContainer.querySelector('.dashboard-grid');
const editEmptyState = editDashboardGrid?.previousElementSibling;
const viewDashboardGrid = viewContainer.querySelector('.dashboard-grid');
const viewEmptyState = viewDashboardGrid?.previousElementSibling;
expect(editEmptyState).toBeInTheDocument();
expect(viewEmptyState).toBeInTheDocument();
});

View File

@@ -18,7 +18,6 @@
*/
import { memo, useMemo, useState, useRef } from 'react';
import { useSelector } from 'react-redux';
import { createSelector } from '@reduxjs/toolkit';
import { t } from '@superset-ui/core';
import { styled, useTheme } from '@apache-superset/core/ui';
import { Icons, Badge, Tooltip, Tag } from '@superset-ui/core/components';
@@ -27,26 +26,6 @@ import { ChartCustomizationItem } from '../nativeFilters/ChartCustomization/type
import { RootState } from '../../types';
import { isChartWithoutGroupBy } from '../../util/charts/chartTypeLimitations';
const makeSelectChartDataset = (chartId: number) =>
createSelector(
(state: RootState) => state.charts[chartId]?.latestQueryFormData,
latestQueryFormData => {
if (!latestQueryFormData?.datasource) {
return null;
}
const chartDatasetParts = String(latestQueryFormData.datasource).split(
'__',
);
return chartDatasetParts[0];
},
);
const makeSelectChartFormData = (chartId: number) =>
createSelector(
(state: RootState) => state.charts[chartId]?.latestQueryFormData,
latestQueryFormData => latestQueryFormData,
);
export interface GroupByBadgeProps {
chartId: number;
}
@@ -163,19 +142,16 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
dashboardInfo.metadata?.chart_customization_config || [],
);
// Use memoized selectors for chart data
const selectChartDataset = useMemo(
() => makeSelectChartDataset(chartId),
[chartId],
);
const selectChartFormData = useMemo(
() => makeSelectChartFormData(chartId),
[chartId],
);
const chartDataset = useSelector(selectChartDataset);
const chartFormData = useSelector(selectChartFormData);
const chartType = chartFormData?.viz_type;
const chartDataset = useSelector<RootState, string | null>(state => {
const chart = state.charts[chartId];
if (!chart?.latestQueryFormData?.datasource) {
return null;
}
const chartDatasetParts = String(
chart.latestQueryFormData.datasource,
).split('__');
return chartDatasetParts[0];
});
const applicableGroupBys = useMemo(() => {
if (!chartDataset) {
@@ -197,6 +173,9 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
});
}, [chartCustomizationItems, chartDataset]);
const chart = useSelector<RootState, any>(state => state.charts[chartId]);
const chartType = chart?.latestQueryFormData?.viz_type;
const effectiveGroupBys = useMemo(() => {
if (!chartType || applicableGroupBys.length === 0) {
return [];
@@ -206,6 +185,7 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
return [];
}
const chartFormData = chart?.latestQueryFormData;
if (!chartFormData) {
return applicableGroupBys;
}
@@ -298,7 +278,7 @@ export const GroupByBadge = ({ chartId }: GroupByBadgeProps) => {
return columnNames.length > 0;
});
}, [applicableGroupBys, chartType, chartFormData]);
}, [applicableGroupBys, chartType, chart]);
const groupByCount = effectiveGroupBys.length;

View File

@@ -711,134 +711,3 @@ test('should call setShowUnsavedChangesModal(false) on cancel', async () => {
expect(setShowModal).toHaveBeenCalledWith(false);
});
test('should clear history and unsaved changes when entering edit mode', () => {
const clearDashboardHistory = jest.fn();
jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({
addSuccessToast,
addDangerToast,
addWarningToast,
onUndo,
onRedo,
setEditMode,
setUnsavedChanges,
fetchFaveStar,
saveFaveStar,
savePublished,
fetchCharts,
updateDashboardTitle,
updateCss,
onChange,
onSave,
setMaxUndoHistoryExceeded,
maxUndoHistoryToast,
logEvent,
setRefreshFrequency,
onRefresh,
dashboardInfoChanged,
dashboardTitleChanged,
clearDashboardHistory,
}));
const canEditState = {
dashboardInfo: {
...initialState.dashboardInfo,
dash_edit_perm: true,
},
};
setup(canEditState);
const editButton = screen.getByText('Edit dashboard');
userEvent.click(editButton);
expect(clearDashboardHistory).toHaveBeenCalledTimes(1);
expect(setUnsavedChanges).toHaveBeenCalledWith(false);
});
test('should mark theme change as unsaved when in edit mode', async () => {
const testStore = createStore(
{
...initialState,
...editableState,
dashboardInfo: {
...editableState.dashboardInfo,
theme: 'LIGHT',
},
},
reducerIndex,
);
render(
<div className="dashboard">
<Header />
</div>,
{
useRedux: true,
useTheme: true,
store: testStore,
},
);
expect(setUnsavedChanges).not.toHaveBeenCalledWith(true);
testStore.dispatch({
type: 'DASHBOARD_INFO_UPDATED',
newInfo: {
theme: 'DARK',
},
});
await waitFor(() => {
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
});
});
test('should not mark initial theme as unsaved change', () => {
setup({
...editableState,
dashboardInfo: {
...editableState.dashboardInfo,
theme: 'LIGHT',
},
});
expect(setUnsavedChanges).not.toHaveBeenCalledWith(true);
});
test('should sync theme ref when navigating between dashboards', async () => {
const testStore = createStore(
{
...initialState,
dashboardInfo: {
...initialState.dashboardInfo,
theme: 'LIGHT',
},
},
reducerIndex,
);
render(
<div className="dashboard">
<Header />
</div>,
{
useRedux: true,
useTheme: true,
store: testStore,
},
);
testStore.dispatch({
type: 'DASHBOARD_INFO_UPDATED',
newInfo: {
id: 2,
theme: 'DARK',
},
});
await waitFor(() => {
expect(setUnsavedChanges).toHaveBeenCalledTimes(0);
});
});

View File

@@ -218,7 +218,6 @@ const Header = () => {
const refreshTimer = useRef(0);
const ctrlYTimeout = useRef(0);
const ctrlZTimeout = useRef(0);
const previousThemeRef = useRef(dashboardInfo.theme);
const dashboardTitle = layout[DASHBOARD_HEADER_ID]?.meta?.text;
const { slug } = dashboardInfo;
@@ -325,12 +324,11 @@ const Header = () => {
startPeriodicRender(refreshFrequency * 1000);
}, [refreshFrequency, startPeriodicRender]);
// Track theme changes as unsaved changes, and sync ref when navigating between dashboards
// Ensure theme changes are tracked as unsaved changes
useEffect(() => {
if (editMode && dashboardInfo.theme !== previousThemeRef.current) {
if (editMode && dashboardInfo.theme !== undefined) {
boundActionCreators.setUnsavedChanges(true);
}
previousThemeRef.current = dashboardInfo.theme;
}, [dashboardInfo.theme, editMode, boundActionCreators]);
useEffect(() => {
@@ -561,12 +559,6 @@ const Header = () => {
[boundActionCreators],
);
const handleEnterEditMode = useCallback(() => {
toggleEditMode();
boundActionCreators.clearDashboardHistory?.();
boundActionCreators.setUnsavedChanges(false);
}, [toggleEditMode, boundActionCreators]);
const NavExtension = extensionsRegistry.get('dashboard.nav.right');
const editableTitleProps = useMemo(
@@ -718,7 +710,10 @@ const Header = () => {
{userCanEdit && (
<Button
buttonStyle="secondary"
onClick={handleEnterEditMode}
onClick={() => {
toggleEditMode();
boundActionCreators.clearDashboardHistory?.(); // Resets the `past` as an empty array
}}
data-test="edit-dashboard-button"
className="action-button"
css={editButtonStyle}
@@ -741,7 +736,6 @@ const Header = () => {
emphasizeUndo,
handleCtrlY,
handleCtrlZ,
handleEnterEditMode,
hasUnsavedChanges,
overwriteDashboard,
redoLength,

View File

@@ -579,40 +579,3 @@ test('Dataset drill info API call is not made when user lacks drill permissions'
expect(mockCachedSupersetGet).not.toHaveBeenCalled();
});
test('Should show "Embed code" in Share menu when feature flag is enabled and chart has data', async () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Share'));
expect(await screen.findByText('Embed code')).toBeInTheDocument();
});
test('Should NOT show "Embed code" in Share menu when feature flag is disabled', async () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: false,
};
const props = createProps();
renderWrapper(props);
openMenu();
userEvent.hover(screen.getByText('Share'));
expect(
await screen.findByText('Copy permalink to clipboard'),
).toBeInTheDocument();
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
});
test('Should pass formData to Share menu for embed code feature', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
const { container } = renderWrapper(props);
expect(container).toBeInTheDocument();
openMenu();
expect(screen.getByText('Share')).toBeInTheDocument();
});

View File

@@ -471,8 +471,6 @@ const SliceHeaderControls = (
addSuccessToast,
addDangerToast,
title: t('Share'),
latestQueryFormData: props.formData,
maxWidth: `${theme.sizeUnit * 100}px`,
});
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {

View File

@@ -28,10 +28,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { exportChart, mountExploreUrl } from 'src/explore/exploreUtils';
import ChartContainer from 'src/components/Chart/ChartContainer';
import {
StreamingExportModal,
useStreamingExport,
} from 'src/components/StreamingExportModal';
import {
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
@@ -40,7 +36,7 @@ import {
LOG_ACTIONS_FORCE_REFRESH_CHART,
} from 'src/logger/LogUtils';
import { postFormData } from 'src/explore/exploreUtils/formData';
import { URL_PARAMS, DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
import { URL_PARAMS } from 'src/constants';
import { enforceSharedLabelsColorsArray } from 'src/utils/colorScheme';
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
import {
@@ -86,6 +82,8 @@ const propTypes = {
isInView: PropTypes.bool,
};
// we use state + shouldComponentUpdate() logic to prevent perf-wrecking
// resizing across all slices on a dashboard on every update
const RESIZE_TIMEOUT = 500;
const DEFAULT_HEADER_HEIGHT = 22;
@@ -112,7 +110,6 @@ const SliceContainer = styled.div`
`;
const EMPTY_OBJECT = {};
const EMPTY_ARRAY = [];
const Chart = props => {
const dispatch = useDispatch();
@@ -166,11 +163,6 @@ const Chart = props => {
const maxRows = useSelector(
state => state.dashboardInfo.common.conf.SQL_MAX_ROW,
);
const streamingThreshold = useSelector(
state =>
state.dashboardInfo.common.conf.CSV_STREAMING_ROW_THRESHOLD ||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
);
const datasource = useSelector(
state =>
(chart &&
@@ -189,27 +181,6 @@ const Chart = props => {
const [descriptionHeight, setDescriptionHeight] = useState(0);
const [height, setHeight] = useState(props.height);
const [width, setWidth] = useState(props.width);
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
const {
progress,
isExporting,
startExport,
cancelExport,
resetExport,
retryExport,
} = useStreamingExport({
onComplete: () => {
// Don't show toast here - wait for user to click Download button
},
onError: () => {
boundActionCreators.addDangerToast(t('Export failed - please try again'));
},
});
const handleDownloadComplete = useCallback(() => {
boundActionCreators.addSuccessToast(t('CSV file downloaded successfully'));
}, [boundActionCreators]);
const history = useHistory();
const resize = useCallback(
debounce(() => {
@@ -313,8 +284,7 @@ const Chart = props => {
state => state.dashboardInfo.metadata?.chart_configuration,
);
const chartCustomizationItems = useSelector(
state =>
state.dashboardInfo.metadata?.chart_customization_config || EMPTY_ARRAY,
state => state.dashboardInfo.metadata?.chart_customization_config || [],
);
const colorScheme = useSelector(state => state.dashboardState.colorScheme);
const colorNamespace = useSelector(
@@ -326,8 +296,8 @@ const Chart = props => {
const allSliceIds = useSelector(state => state.dashboardState.sliceIds);
const nativeFilters = useSelector(state => state.nativeFilters?.filters);
const dataMask = useSelector(state => state.dataMask);
const chartState = useSelector(
state => state.dashboardState.chartStates?.[props.id],
const chartStates = useSelector(
state => state.dashboardState.chartStates || EMPTY_OBJECT,
);
const labelsColor = useSelector(
state => state.dashboardInfo?.metadata?.label_colors || EMPTY_OBJECT,
@@ -344,7 +314,7 @@ const Chart = props => {
const formData = useMemo(
() =>
getFormDataWithExtraFilters({
chart: { id: chart.id, form_data: chart.form_data }, // avoid passing the whole chart object
chart,
chartConfiguration,
chartCustomizationItems,
filters: getAppliedFilterValues(props.id),
@@ -361,8 +331,7 @@ const Chart = props => {
ownColorScheme,
}),
[
chart.id,
chart.form_data,
chart,
chartConfiguration,
chartCustomizationItems,
props.id,
@@ -381,25 +350,6 @@ const Chart = props => {
formData.dashboardId = dashboardInfo.id;
const ownState = useMemo(() => {
const baseOwnState = dataMask[props.id]?.ownState || EMPTY_OBJECT;
if (hasChartStateConverter(slice.viz_type) && chartState?.state) {
return {
...baseOwnState,
...convertChartStateToOwnState(slice.viz_type, chartState.state),
chartState: chartState.state,
};
}
return baseOwnState;
}, [
dataMask[props.id]?.ownState,
props.id,
slice.viz_type,
chartState?.state,
]);
const onExploreChart = useCallback(
async clickEvent => {
const isOpenInNewTab =
@@ -453,46 +403,16 @@ const Chart = props => {
is_cached: isCached,
});
const exportFormData = isFullCSV
? { ...formData, row_limit: maxRows }
: formData;
const resultType = isPivot ? 'post_processed' : 'full';
let actualRowCount;
const isTableViz = formData?.viz_type === 'table';
if (
isTableViz &&
queriesResponse?.length > 1 &&
queriesResponse[1]?.data?.[0]?.rowcount
) {
actualRowCount = queriesResponse[1].data[0].rowcount;
} else if (queriesResponse?.[0]?.sql_rowcount != null) {
actualRowCount = queriesResponse[0].sql_rowcount;
} else {
actualRowCount = exportFormData?.row_limit;
}
// Handle streaming CSV exports based on row threshold
const shouldUseStreaming =
format === 'csv' && !isPivot && actualRowCount >= streamingThreshold;
let filename;
if (shouldUseStreaming) {
const now = new Date();
const date = now.toISOString().slice(0, 10);
const time = now.toISOString().slice(11, 19).replace(/:/g, '');
const timestamp = `_${date}_${time}`;
const chartName = slice.slice_name || formData.viz_type || 'chart';
const safeChartName = chartName.replace(/[^a-zA-Z0-9_-]/g, '_');
filename = `${safeChartName}${timestamp}.csv`;
}
let ownState = dataMask[props.id]?.ownState || {};
// Convert chart-specific state to backend format using registered converter
if (hasChartStateConverter(slice.viz_type) && chartState?.state) {
if (
hasChartStateConverter(slice.viz_type) &&
chartStates[props.id]?.state
) {
const convertedState = convertChartStateToOwnState(
slice.viz_type,
chartState.state,
chartStates[props.id].state,
);
ownState = {
...ownState,
@@ -501,21 +421,11 @@ const Chart = props => {
}
exportChart({
formData: exportFormData,
resultType,
formData: isFullCSV ? { ...formData, row_limit: maxRows } : formData,
resultType: isPivot ? 'post_processed' : 'full',
resultFormat: format,
force: true,
ownState,
onStartStreamingExport: shouldUseStreaming
? exportParams => {
setIsStreamingModalVisible(true);
startExport({
...exportParams,
filename,
expectedRows: actualRowCount,
});
}
: null,
});
},
[
@@ -525,13 +435,9 @@ const Chart = props => {
formData,
maxRows,
dataMask[props.id]?.ownState,
chartState,
chartStates,
props.id,
boundActionCreators.logEvent,
queriesResponse,
startExport,
resetExport,
streamingThreshold,
],
);
@@ -671,7 +577,19 @@ const Chart = props => {
formData={formData}
labelsColor={labelsColor}
labelsColorMap={labelsColorMap}
ownState={ownState}
ownState={{
...dataMask[props.id]?.ownState,
...(hasChartStateConverter(slice.viz_type) &&
chartStates[props.id]?.state
? {
...convertChartStateToOwnState(
slice.viz_type,
chartStates[props.id].state,
),
chartState: chartStates[props.id].state,
}
: {}),
}}
filterState={dataMask[props.id]?.filterState}
queriesResponse={chart.queriesResponse}
timeout={timeout}
@@ -684,19 +602,6 @@ const Chart = props => {
onChartStateChange={handleChartStateChange}
/>
</ChartWrapper>
<StreamingExportModal
visible={isStreamingModalVisible}
onCancel={() => {
cancelExport();
setIsStreamingModalVisible(false);
resetExport();
}}
onRetry={retryExport}
onDownload={handleDownloadComplete}
progress={progress}
exportType="csv"
/>
</SliceContainer>
);
};
@@ -709,9 +614,8 @@ export default memo(Chart, (prevProps, nextProps) => {
}
return (
!nextProps.isComponentVisible ||
(prevProps.componentId === nextProps.componentId &&
prevProps.isComponentVisible &&
prevProps.isInView === nextProps.isInView &&
(prevProps.isInView === nextProps.isInView &&
prevProps.componentId === nextProps.componentId &&
prevProps.id === nextProps.id &&
prevProps.dashboardId === nextProps.dashboardId &&
prevProps.extraControls === nextProps.extraControls &&

View File

@@ -267,33 +267,3 @@ test('should call exportChart with row_limit props.maxRows when exportFullXLSX i
stubbedExportXLSX.mockRestore();
});
test('should re-render when chart becomes visible', () => {
const { rerender, getByTestId } = setup({ isComponentVisible: false });
expect(getByTestId('chart-container')).toBeInTheDocument();
rerender(<Chart {...props} isComponentVisible />);
expect(getByTestId('chart-container')).toBeInTheDocument();
});
test('should re-render when componentId changes', () => {
const { rerender, getByTestId } = setup({
isComponentVisible: true,
componentId: 'test-1',
});
expect(getByTestId('chart-container')).toBeInTheDocument();
rerender(<Chart {...props} isComponentVisible componentId="test-2" />);
expect(getByTestId('chart-container')).toBeInTheDocument();
});
test('should re-render when cacheBusterProp changes', () => {
const { rerender, getByTestId } = setup({
isComponentVisible: true,
cacheBusterProp: 'v1',
});
expect(getByTestId('chart-container')).toBeInTheDocument();
rerender(<Chart {...props} isComponentVisible cacheBusterProp="v2" />);
expect(getByTestId('chart-container')).toBeInTheDocument();
});

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Fragment, useCallback, memo, useEffect } from 'react';
import { Fragment, useCallback, memo } from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { useDispatch, useSelector } from 'react-redux';
@@ -24,8 +24,7 @@ import { t } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
import { EditableTitle, EmptyState } from '@superset-ui/core/components';
import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponent';
import { setEditMode } from 'src/dashboard/actions/dashboardState';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import AnchorLink from 'src/dashboard/components/AnchorLink';
import {
@@ -38,9 +37,6 @@ import { TAB_TYPE } from 'src/dashboard/util/componentTypes';
export const RENDER_TAB = 'RENDER_TAB';
export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
// Delay before refreshing charts to ensure they are fully mounted
const CHART_MOUNT_DELAY = 100;
const propTypes = {
dashboardId: PropTypes.number.isRequired,
id: PropTypes.string.isRequired,
@@ -56,7 +52,6 @@ const propTypes = {
onHoverTab: PropTypes.func,
editMode: PropTypes.bool.isRequired,
embeddedMode: PropTypes.bool,
onTabTitleEditingChange: PropTypes.func,
// grid related
availableColumnCount: PropTypes.number,
@@ -69,7 +64,6 @@ const propTypes = {
handleComponentDrop: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
setDirectPathToChild: PropTypes.func.isRequired,
isComponentVisible: PropTypes.bool,
};
const defaultProps = {
@@ -82,7 +76,6 @@ const defaultProps = {
onResizeStart() {},
onResize() {},
onResizeStop() {},
onTabTitleEditingChange() {},
};
const TabTitleContainer = styled.div`
@@ -121,43 +114,6 @@ const renderDraggableContent = dropProps =>
const Tab = props => {
const dispatch = useDispatch();
const canEdit = useSelector(state => state.dashboardInfo.dash_edit_perm);
const dashboardLayout = useSelector(state => state.dashboardLayout.present);
const lastRefreshTime = useSelector(
state => state.dashboardState.lastRefreshTime,
);
const tabActivationTime = useSelector(
state => state.dashboardState.tabActivationTimes?.[props.id] || 0,
);
const dashboardInfo = useSelector(state => state.dashboardInfo);
useEffect(() => {
if (props.renderType === RENDER_TAB_CONTENT && props.isComponentVisible) {
if (
lastRefreshTime &&
tabActivationTime &&
lastRefreshTime > tabActivationTime
) {
const chartIds = getChartIdsFromComponent(props.id, dashboardLayout);
if (chartIds.length > 0) {
requestAnimationFrame(() => {
setTimeout(() => {
dispatch(onRefresh(chartIds, true, 0, dashboardInfo.id));
}, CHART_MOUNT_DELAY);
});
}
}
}
}, [
props.isComponentVisible,
props.renderType,
props.id,
lastRefreshTime,
tabActivationTime,
dashboardLayout,
dashboardInfo.id,
dispatch,
]);
const handleChangeTab = useCallback(
({ pathToTabIndex }) => {
props.setDirectPathToChild(pathToTabIndex);
@@ -359,7 +315,6 @@ const Tab = props => {
isHighlighted,
dashboardId,
embeddedMode,
onTabTitleEditingChange,
} = props;
return (
<TabTitleContainer
@@ -375,7 +330,6 @@ const Tab = props => {
onSaveTitle={handleChangeText}
showTooltip={false}
editing={editMode && isFocused}
onEditingChange={onTabTitleEditingChange}
/>
{!editMode && !embeddedMode && (
<AnchorLink
@@ -401,7 +355,6 @@ const Tab = props => {
props.isFocused,
props.isHighlighted,
props.dashboardId,
props.onTabTitleEditingChange,
handleChangeText,
],
);

View File

@@ -26,15 +26,11 @@ import {
} from 'spec/helpers/testing-library';
import DashboardComponent from 'src/dashboard/containers/DashboardComponent';
import { EditableTitle } from '@superset-ui/core/components';
import { setEditMode, onRefresh } from 'src/dashboard/actions/dashboardState';
import { setEditMode } from 'src/dashboard/actions/dashboardState';
import Tab from './Tab';
import Markdown from '../Markdown';
jest.mock('src/dashboard/util/getChartIdsFromComponent', () =>
jest.fn(() => []),
);
jest.mock('src/dashboard/containers/DashboardComponent', () =>
jest.fn(() => <div data-test="DashboardComponent" />),
);
@@ -70,9 +66,6 @@ jest.mock('src/dashboard/actions/dashboardState', () => ({
setEditMode: jest.fn(() => ({
type: 'SET_EDIT_MODE',
})),
onRefresh: jest.fn(() => ({
type: 'ON_REFRESH',
})),
}));
const createProps = () => ({
@@ -452,91 +445,3 @@ test('AnchorLink does not render in embedded mode', () => {
expect(screen.queryByTestId('anchor-link')).not.toBeInTheDocument();
});
test('Should refresh charts when tab becomes active after dashboard refresh', async () => {
jest.clearAllMocks();
const getChartIdsFromComponent = require('src/dashboard/util/getChartIdsFromComponent');
getChartIdsFromComponent.mockReturnValue([101, 102]);
const props = createProps();
props.renderType = 'RENDER_TAB_CONTENT';
props.isComponentVisible = false;
const initialState = {
dashboardState: {
lastRefreshTime: Date.now() - 5000, // Dashboard was refreshed 5 seconds ago
tabActivationTimes: {
'TAB-YT6eNksV-': Date.now() - 10000, // Tab was activated 10 seconds ago (before refresh)
},
},
dashboardInfo: {
id: 23,
dash_edit_perm: true,
},
};
const { rerender } = render(<Tab {...props} />, {
useRedux: true,
useDnd: true,
initialState,
});
// onRefresh should not be called when tab is not visible
expect(onRefresh).not.toHaveBeenCalled();
// Make tab visible - this should trigger refresh since lastRefreshTime > tabActivationTime
rerender(<Tab {...props} isComponentVisible />);
// Wait for the refresh to be triggered after the delay
await waitFor(
() => {
expect(onRefresh).toHaveBeenCalled();
},
{ timeout: 500 },
);
expect(onRefresh).toHaveBeenCalledWith(
[101, 102], // Chart IDs from the tab
true, // Force refresh
0, // Interval
23, // Dashboard ID
);
});
test('Should not refresh charts when tab becomes active if no dashboard refresh occurred', async () => {
jest.clearAllMocks();
const getChartIdsFromComponent = require('src/dashboard/util/getChartIdsFromComponent');
getChartIdsFromComponent.mockReturnValue([101]);
const props = createProps();
props.renderType = 'RENDER_TAB_CONTENT';
props.isComponentVisible = false;
const currentTime = Date.now();
const initialState = {
dashboardState: {
lastRefreshTime: currentTime - 10000, // Dashboard was refreshed 10 seconds ago
tabActivationTimes: {
'TAB-YT6eNksV-': currentTime - 5000, // Tab was activated 5 seconds ago (after refresh)
},
},
dashboardInfo: {
id: 23,
dash_edit_perm: true,
},
};
const { rerender } = render(<Tab {...props} />, {
useRedux: true,
useDnd: true,
initialState,
});
// Make tab visible - should NOT trigger refresh since tabActivationTime > lastRefreshTime
rerender(<Tab {...props} isComponentVisible />);
// Wait a bit to ensure no refresh is triggered
await new Promise(resolve => setTimeout(resolve, 200));
expect(onRefresh).not.toHaveBeenCalled();
});

View File

@@ -133,8 +133,8 @@ const Tabs = props => {
const [selectedTabIndex, setSelectedTabIndex] = useState(initTabIndex);
const [dropPosition, setDropPosition] = useState(null);
const [dragOverTabIndex, setDragOverTabIndex] = useState(null);
const [draggingTabId, setDraggingTabId] = useState(null);
const [tabToDelete, setTabToDelete] = useState(null);
const [isEditingTabTitle, setIsEditingTabTitle] = useState(false);
const prevActiveKey = usePrevious(activeKey);
const prevDashboardId = usePrevious(props.dashboardId);
const prevDirectPathToChild = usePrevious(directPathToChild);
@@ -214,12 +214,8 @@ const Tabs = props => {
});
props.onChangeTab({ pathToTabIndex });
setSelectedTabIndex(tabIndex);
}
// Always set activeKey to ensure it's synchronized
if (tabIds[tabIndex]) {
setActiveKey(tabIds[tabIndex]);
}
setActiveKey(tabIds[tabIndex]);
},
[
props.component,
@@ -331,40 +327,14 @@ const Tabs = props => {
}
}, []);
const handleTabTitleEditingChange = useCallback(isEditing => {
setIsEditingTabTitle(isEditing);
const handleDragggingTab = useCallback(tabId => {
if (tabId) {
setDraggingTabId(tabId);
} else {
setDraggingTabId(null);
}
}, []);
const handleTabsReorder = useCallback(
(oldIndex, newIndex) => {
const { component, updateComponents } = props;
const oldTabIds = component.children;
const newTabIds = [...oldTabIds];
const [removed] = newTabIds.splice(oldIndex, 1);
newTabIds.splice(newIndex, 0, removed);
const currentActiveTabId = oldTabIds[selectedTabIndex];
const newActiveIndex = newTabIds.indexOf(currentActiveTabId);
updateComponents({
[component.id]: {
...component,
children: newTabIds,
},
});
// Update selected index to match the active tab's new position
if (newActiveIndex !== -1 && newActiveIndex !== selectedTabIndex) {
setSelectedTabIndex(newActiveIndex);
}
// Always update activeKey to ensure it stays synchronized after reorder
if (newActiveIndex !== -1) {
setActiveKey(currentActiveTabId);
}
},
[props.component, props.updateComponents, selectedTabIndex],
);
const {
depth,
component: tabsComponent,
@@ -399,6 +369,11 @@ const Tabs = props => {
[dragOverTabIndex, dropPosition, editMode],
);
const removeDraggedTab = useCallback(
tabID => draggingTabId === tabID,
[draggingTabId],
);
// Extract tab highlighting logic into a hook
const useTabHighlighting = useCallback(() => {
const highlightedFilterId =
@@ -415,7 +390,9 @@ const Tabs = props => {
() =>
tabIds.map((tabId, tabIndex) => ({
key: tabId,
label: (
label: removeDraggedTab(tabId) ? (
<></>
) : (
<>
{showDropIndicators(tabIndex).left && (
<DropIndicator className="drop-indicator-left" pos="left" />
@@ -430,16 +407,18 @@ const Tabs = props => {
columnWidth={columnWidth}
onDropOnTab={handleDropOnTab}
onDropPositionChange={handleGetDropPosition}
onDragTab={handleDragggingTab}
onHoverTab={() => handleClickTab(tabIndex)}
isFocused={activeKey === tabId}
isHighlighted={
activeKey !== tabId && tabsToHighlight?.includes(tabId)
}
onTabTitleEditingChange={handleTabTitleEditingChange}
/>
</>
),
closeIcon: (
closeIcon: removeDraggedTab(tabId) ? (
<></>
) : (
<CloseIconWithDropIndicator
role="button"
tabIndex={tabIndex}
@@ -467,6 +446,7 @@ const Tabs = props => {
})),
[
tabIds,
removeDraggedTab,
showDropIndicators,
tabsComponent.id,
depth,
@@ -474,6 +454,7 @@ const Tabs = props => {
columnWidth,
handleDropOnTab,
handleGetDropPosition,
handleDragggingTab,
handleClickTab,
activeKey,
tabsToHighlight,
@@ -483,7 +464,6 @@ const Tabs = props => {
onResizeStop,
selectedTabIndex,
isCurrentTabVisible,
handleTabTitleEditingChange,
],
);
@@ -501,9 +481,6 @@ const Tabs = props => {
handleClickTab={handleClickTab}
handleEdit={handleEdit}
tabBarPaddingLeft={tabBarPaddingLeft}
onTabsReorder={handleTabsReorder}
isEditingTabTitle={isEditingTabTitle}
onTabTitleEditingChange={handleTabTitleEditingChange}
/>
),
[
@@ -517,9 +494,6 @@ const Tabs = props => {
handleClickTab,
handleEdit,
tabBarPaddingLeft,
handleTabsReorder,
isEditingTabTitle,
handleTabTitleEditingChange,
],
);

View File

@@ -16,36 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
cloneElement,
memo,
ReactElement,
RefObject,
useCallback,
useState,
} from 'react';
import { memo, ReactElement, RefObject } from 'react';
import { styled } from '@apache-superset/core/ui';
import {
LineEditableTabs,
TabsProps as AntdTabsProps,
} from '@superset-ui/core/components/Tabs';
import type { DragEndEvent } from '@dnd-kit/core';
import {
DndContext,
PointerSensor,
useSensor,
closestCenter,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
useSortable,
} from '@dnd-kit/sortable';
import HoverMenu from '../../menu/HoverMenu';
import DragHandle from '../../dnd/DragHandle';
import DeleteComponentButton from '../../DeleteComponentButton';
const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
const StyledTabsContainer = styled.div`
width: 100%;
background-color: ${({ theme }) => theme.colorBgContainer};
@@ -60,16 +41,6 @@ const StyledTabsContainer = styled.div<{ isDragging?: boolean }>`
&.dragdroppable-row .dashboard-component-tabs-content {
height: calc(100% - 47px);
}
/* Hide ink-bar during drag */
${({ isDragging }) =>
isDragging &&
`
.ant-tabs-card > .ant-tabs-nav .ant-tabs-ink-bar,
.ant-tabs > .ant-tabs-nav .ant-tabs-ink-bar {
display: none !important;
}
`}
`;
export interface TabItem {
@@ -95,51 +66,8 @@ export interface TabsRendererProps {
handleClickTab: (index: number) => void;
handleEdit: AntdTabsProps['onEdit'];
tabBarPaddingLeft?: number;
onTabsReorder?: (oldIndex: number, newIndex: number) => void;
isEditingTabTitle?: boolean;
onTabTitleEditingChange?: (isEditing: boolean) => void;
}
interface DraggableTabNodeProps extends React.HTMLAttributes<HTMLDivElement> {
'data-node-key': string;
disabled?: boolean;
}
const DraggableTabNode: React.FC<Readonly<DraggableTabNodeProps>> = ({
className,
disabled = false,
...props
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: props['data-node-key'],
disabled,
});
const style: React.CSSProperties = {
...props.style,
position: 'relative',
transform: transform ? `translate3d(${transform.x}px, 0, 0)` : undefined,
transition,
cursor: disabled ? 'default' : 'move',
zIndex: isDragging ? 1000 : 'auto',
opacity: 1,
};
return cloneElement(props.children as React.ReactElement, {
ref: setNodeRef,
style,
...attributes,
...(disabled ? {} : listeners),
});
};
/**
* TabsRenderer component handles the rendering of dashboard tabs
* Extracted from the main Tabs component for better separation of concerns
@@ -157,100 +85,35 @@ const TabsRenderer = memo<TabsRendererProps>(
handleClickTab,
handleEdit,
tabBarPaddingLeft = 0,
onTabsReorder,
isEditingTabTitle = false,
onTabTitleEditingChange,
}) => {
const [activeId, setActiveId] = useState<string | null>(null);
}) => (
<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>
)}
const sensor = useSensor(PointerSensor, {
activationConstraint: { distance: 10 },
});
const onDragStart = useCallback((event: any) => {
setActiveId(event.active.id);
}, []);
const onDragEnd = useCallback(
({ active, over }: DragEndEvent) => {
if (active.id !== over?.id && onTabsReorder) {
const activeIndex = tabIds.findIndex(id => id === active.id);
const overIndex = tabIds.findIndex(id => id === over?.id);
onTabsReorder(activeIndex, overIndex);
}
setActiveId(null);
},
[onTabsReorder, tabIds],
);
const onDragCancel = useCallback(() => {
setActiveId(null);
}, []);
const isDragging = activeId !== null;
return (
<StyledTabsContainer
className="dashboard-component dashboard-component-tabs"
data-test="dashboard-component-tabs"
isDragging={isDragging}
>
{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 }}
fullHeight
{...(editMode && {
renderTabBar: (tabBarProps, DefaultTabBar) => (
<DndContext
sensors={[sensor]}
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragCancel={onDragCancel}
collisionDetection={closestCenter}
>
<SortableContext
items={tabIds}
strategy={horizontalListSortingStrategy}
>
<DefaultTabBar {...tabBarProps}>
{(node: React.ReactElement) => (
<DraggableTabNode
{...(node as React.ReactElement<DraggableTabNodeProps>)
.props}
key={node.key}
data-node-key={node.key as string}
disabled={isEditingTabTitle}
>
{node}
</DraggableTabNode>
)}
</DefaultTabBar>
</SortableContext>
</DndContext>
),
})}
/>
</StyledTabsContainer>
);
},
<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';

View File

@@ -220,118 +220,3 @@ test('Click on "Share dashboard by email" and fail', async () => {
);
});
});
test('Should show "Embed code" menu item when feature flag is enabled and chart has data', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
const propsWithFormData = {
...props,
latestQueryFormData: {
datasource: '1__table',
viz_type: 'table',
},
};
render(
<MenuWrapper
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={propsWithFormData}
/>,
{ useRedux: true },
);
expect(screen.getByText('Embed code')).toBeInTheDocument();
});
test('Should NOT show "Embed code" when feature flag is disabled', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: false,
};
const props = createProps();
const propsWithFormData = {
...props,
latestQueryFormData: {
datasource: '1__table',
viz_type: 'table',
},
};
render(
<MenuWrapper
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={propsWithFormData}
/>,
{ useRedux: true },
);
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
});
test('Should NOT show "Embed code" when chart has no data', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
render(
<MenuWrapper
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={props}
/>,
{ useRedux: true },
);
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
});
test('Should NOT show "Embed code" when latestQueryFormData is empty object', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
const propsWithEmptyFormData = {
...props,
latestQueryFormData: {},
};
render(
<MenuWrapper
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={propsWithEmptyFormData}
/>,
{ useRedux: true },
);
expect(screen.queryByText('Embed code')).not.toBeInTheDocument();
});
test('Should render "Embed code" with data-test attribute', () => {
window.featureFlags = {
EMBEDDABLE_CHARTS: true,
};
const props = createProps();
const propsWithFormData = {
...props,
latestQueryFormData: {
datasource: '1__table',
viz_type: 'table',
},
};
render(
<MenuWrapper
onClick={jest.fn()}
selectable={false}
data-test="main-menu"
forceSubMenuRender
shareProps={propsWithFormData}
/>,
{ useRedux: true },
);
expect(screen.getByTestId('embed-code-button')).toBeInTheDocument();
});

View File

@@ -18,17 +18,9 @@
*/
import { ComponentProps, RefObject } from 'react';
import copyTextToClipboard from 'src/utils/copy';
import {
t,
logging,
FeatureFlag,
isFeatureEnabled,
LatestQueryFormData,
} from '@superset-ui/core';
import { t, logging } from '@superset-ui/core';
import { Menu, MenuItem } from '@superset-ui/core/components/Menu';
import { getDashboardPermalink } from 'src/utils/urlUtils';
import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
import { ModalTrigger } from '@superset-ui/core/components';
import { MenuKeys, RootState } from 'src/dashboard/types';
import { shallowEqual, useSelector } from 'react-redux';
import { hasStatefulCharts } from 'src/dashboard/util/chartStateConverter';
@@ -40,19 +32,17 @@ export interface ShareMenuItemProps
emailMenuItemTitle: string;
emailSubject: string;
emailBody: string;
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
addDangerToast: Function;
addSuccessToast: Function;
dashboardId: string | number;
dashboardComponentId?: string;
latestQueryFormData?: LatestQueryFormData;
maxWidth?: string;
copyMenuItemRef?: RefObject<HTMLElement>;
shareByEmailMenuItemRef?: RefObject<HTMLElement>;
copyMenuItemRef?: RefObject<any>;
shareByEmailMenuItemRef?: RefObject<any>;
selectedKeys?: string[];
setOpenKeys?: (keys: string[] | undefined) => void;
setOpenKeys?: Function;
title: string;
disabled?: boolean;
[key: string]: unknown;
[key: string]: any;
}
export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
@@ -65,17 +55,10 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
addSuccessToast,
dashboardId,
dashboardComponentId,
latestQueryFormData,
maxWidth,
title,
disabled,
...rest
} = props;
const sliceExists = !!(
latestQueryFormData && Object.keys(latestQueryFormData).length > 0
);
const isEmbedCodeEnabled = isFeatureEnabled(FeatureFlag.EmbeddableCharts);
const { dataMask, activeTabs, chartStates, sliceEntities } = useSelector(
(state: RootState) => ({
dataMask: state.dataMask,
@@ -126,48 +109,23 @@ export const useShareMenuItems = (props: ShareMenuItemProps): MenuItem => {
}
}
const children: MenuItem[] = [
{
key: MenuKeys.CopyLink,
label: copyMenuItemTitle,
onClick: onCopyLink,
},
{
key: MenuKeys.ShareByEmail,
label: emailMenuItemTitle,
onClick: onShareByEmail,
},
];
// Add embed code option if feature is enabled and chart data exists
if (isEmbedCodeEnabled && sliceExists) {
children.push({
key: MenuKeys.EmbedCode,
label: (
<ModalTrigger
triggerNode={
<span data-test="embed-code-button">{t('Embed code')}</span>
}
modalTitle={t('Embed code')}
modalBody={
<EmbedCodeContent
formData={latestQueryFormData}
addDangerToast={addDangerToast}
/>
}
maxWidth={maxWidth}
responsive
/>
),
});
}
return {
...rest,
type: 'submenu',
label: title,
key: MenuKeys.Share,
disabled,
children,
children: [
{
key: MenuKeys.CopyLink,
label: copyMenuItemTitle,
onClick: onCopyLink,
},
{
key: MenuKeys.ShareByEmail,
label: emailMenuItemTitle,
onClick: onShareByEmail,
},
],
};
};

View File

@@ -16,32 +16,30 @@
* specific language governing permissions and limitations
* under the License.
*/
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
import { ChartCustomizationItem } from './types';
const EMPTY_ARRAY: ChartCustomizationItem[] = [];
export const selectChartCustomizationItems = (
state: RootState,
): ChartCustomizationItem[] => {
const { metadata } = state.dashboardInfo;
export const selectChartCustomizationItems = createSelector(
(state: RootState) => state.dashboardInfo.metadata,
(metadata): ChartCustomizationItem[] => {
if (
metadata?.chart_customization_config &&
metadata.chart_customization_config.length > 0
) {
return metadata.chart_customization_config;
}
if (
metadata?.chart_customization_config &&
metadata.chart_customization_config.length > 0
) {
return metadata.chart_customization_config;
}
const legacyCustomization = metadata?.native_filter_configuration?.find(
(item: any) =>
item.type === 'CHART_CUSTOMIZATION' &&
item.id === 'chart_customization_groupby',
);
const legacyCustomization = metadata?.native_filter_configuration?.find(
(item: any) =>
item.type === 'CHART_CUSTOMIZATION' &&
item.id === 'chart_customization_groupby',
);
if (legacyCustomization?.chart_customization) {
return legacyCustomization.chart_customization;
}
if (legacyCustomization?.chart_customization) {
return legacyCustomization.chart_customization;
}
return EMPTY_ARRAY;
},
);
return [];
};

View File

@@ -154,10 +154,6 @@ const VerticalFormItem = styled(StyledFormItem)<{
flex-direction: column;
`}
}
.ant-col {
min-height: auto;
}
`;
const HorizontalFormItem = styled(StyledFormItem)<{

View File

@@ -54,6 +54,7 @@ import {
import { Icons } from '@superset-ui/core/components/Icons';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { useChartLayoutItems } from 'src/dashboard/util/useChartLayoutItems';
import { ChartCustomizationItem } from 'src/dashboard/components/nativeFilters/ChartCustomization/types';
import { FiltersOutOfScopeCollapsible } from '../FiltersOutOfScopeCollapsible';
import { useFilterControlFactory } from '../useFilterControlFactory';
import { FiltersDropdownContent } from '../FiltersDropdownContent';
@@ -140,7 +141,10 @@ const FilterControls: FC<FilterControlsProps> = ({
const chartLayoutItems = useChartLayoutItems();
const verboseMaps = useChartsVerboseMaps();
const chartCustomizationItems = useSelector(selectChartCustomizationItems);
const chartCustomizationItems = useSelector<
RootState,
ChartCustomizationItem[]
>(state => selectChartCustomizationItems(state));
const selectedCrossFilters = useMemo(
() =>

View File

@@ -96,7 +96,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
const chartCustomizationItems = useSelector<
RootState,
ChartCustomizationItem[]
>(selectChartCustomizationItems);
>(state => selectChartCustomizationItems(state));
const hasFilters =
filterValues.length > 0 ||

View File

@@ -177,7 +177,7 @@ const VerticalFilterBar: FC<VerticalBarProps> = ({
const chartCustomizationItems = useSelector<
RootState,
ChartCustomizationItem[]
>(selectChartCustomizationItems);
>(state => selectChartCustomizationItems(state));
const dataMask = useSelector<RootState, DataMaskStateWithId>(
state => state.dataMask,

View File

@@ -18,32 +18,17 @@
*/
import { ensureIsArray, Filter } from '@superset-ui/core';
import { useSelector } from 'react-redux';
import { useMemo } from 'react';
import { createSelector } from '@reduxjs/toolkit';
import { RootState } from 'src/dashboard/types';
const EMPTY_ARRAY: Filter[] = [];
const makeSelectFilterDependencies = (filterDependencyIds: string[]) =>
createSelector(
(state: RootState) => state.nativeFilters.filters,
(filters): Filter[] => {
if (filterDependencyIds.length === 0) {
return EMPTY_ARRAY;
}
return filterDependencyIds
.map(id => filters[id] as Filter)
.filter(Boolean);
},
);
export const useFilterDependencies = (filter: Filter) => {
const filterDependencyIds = ensureIsArray(filter.cascadeParentIds);
const selectFilterDependencies = useMemo(
() => makeSelectFilterDependencies(filterDependencyIds),
[filterDependencyIds.join(',')],
);
return useSelector(selectFilterDependencies);
return useSelector<RootState, Filter[]>(state => {
if (filterDependencyIds.length === 0) {
return [];
}
return filterDependencyIds.reduce((acc: Filter[], filterDependencyId) => {
acc.push(state.nativeFilters.filters[filterDependencyId] as Filter);
return acc;
}, []);
});
};

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