mirror of
https://github.com/apache/superset.git
synced 2026-05-04 15:34:18 +00:00
Compare commits
1 Commits
semantic-l
...
issue-3607
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
59ddd52789 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,7 +33,6 @@ cover
|
||||
.env
|
||||
.envrc
|
||||
.idea
|
||||
.roo
|
||||
.mypy_cache
|
||||
.python-version
|
||||
.tox
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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}
|
||||
|
||||
101
UPDATING.md
101
UPDATING.md
@@ -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.
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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`
|
||||
@@ -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':
|
||||
|
||||
@@ -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": [
|
||||
|
||||
BIN
docs/static/img/superset-logo-icon-only.png
vendored
BIN
docs/static/img/superset-logo-icon-only.png
vendored
Binary file not shown.
|
Before Width: | Height: | Size: 116 KiB |
201
docs/yarn.lock
201
docs/yarn.lock
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
7
setup.py
7
setup.py
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
90
superset-core/src/superset_core/api/types/models.py
Normal file
90
superset-core/src/superset_core/api/types/models.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
@@ -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.
|
||||
"""
|
||||
...
|
||||
64
superset-core/src/superset_core/api/types/rest_api.py
Normal file
64
superset-core/src/superset_core/api/types/rest_api.py
Normal 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.
|
||||
"""
|
||||
...
|
||||
12
superset-embedded-sdk/package-lock.json
generated
12
superset-embedded-sdk/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
36
superset-frontend/package-lock.json
generated
36
superset-frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -33,5 +33,4 @@ export interface EditableTitleProps {
|
||||
renderLink?: (title: string) => React.ReactNode;
|
||||
maxWidth?: number;
|
||||
autoSize?: boolean;
|
||||
onEditingChange?: (isEditing: boolean) => void;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -156,7 +156,7 @@ export interface QueryObject
|
||||
|
||||
export interface QueryContext {
|
||||
datasource: {
|
||||
id: number | string;
|
||||
id: number;
|
||||
type: DatasourceType;
|
||||
};
|
||||
/** Force refresh of all queries */
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 = () => {},
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -42,5 +42,4 @@ export const DEFAULT_FORM_DATA: Partial<EchartsTreeFormData> = {
|
||||
nodeLabelPosition: 'left',
|
||||
childLabelPosition: 'bottom',
|
||||
emphasis: 'descendant',
|
||||
initialTreeDepth: 2,
|
||||
};
|
||||
|
||||
@@ -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.',
|
||||
),
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -67,7 +67,7 @@ const legendTypeControl: ControlSetItem = {
|
||||
label: t('Type'),
|
||||
choices: [
|
||||
['scroll', t('Scroll')],
|
||||
['plain', t('List')],
|
||||
['plain', t('Plain')],
|
||||
],
|
||||
default: legendType,
|
||||
renderTrigger: true,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -298,7 +298,7 @@ const StyledDashboardContent = styled.div<{
|
||||
|
||||
/* this is the ParentSize wrapper */
|
||||
& > div:first-child {
|
||||
height: 100% !important;
|
||||
height: inherit !important;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -314,7 +314,6 @@ const DashboardContainer: FC<DashboardContainerProps> = ({ topLevelTabs }) => {
|
||||
renderTabBar={renderTabBar}
|
||||
animated={false}
|
||||
allowOverflow
|
||||
fullHeight
|
||||
onFocus={handleFocus}
|
||||
items={tabItems}
|
||||
tabBarStyle={{ paddingLeft: 0 }}
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -471,8 +471,6 @@ const SliceHeaderControls = (
|
||||
addSuccessToast,
|
||||
addDangerToast,
|
||||
title: t('Share'),
|
||||
latestQueryFormData: props.formData,
|
||||
maxWidth: `${theme.sizeUnit * 100}px`,
|
||||
});
|
||||
|
||||
if (isFeatureEnabled(FeatureFlag.DrillToDetail) && canDrillToDetail) {
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 [];
|
||||
};
|
||||
|
||||
@@ -154,10 +154,6 @@ const VerticalFormItem = styled(StyledFormItem)<{
|
||||
flex-direction: column;
|
||||
`}
|
||||
}
|
||||
|
||||
.ant-col {
|
||||
min-height: auto;
|
||||
}
|
||||
`;
|
||||
|
||||
const HorizontalFormItem = styled(StyledFormItem)<{
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -96,7 +96,7 @@ const HorizontalFilterBar: FC<HorizontalBarProps> = ({
|
||||
const chartCustomizationItems = useSelector<
|
||||
RootState,
|
||||
ChartCustomizationItem[]
|
||||
>(selectChartCustomizationItems);
|
||||
>(state => selectChartCustomizationItems(state));
|
||||
|
||||
const hasFilters =
|
||||
filterValues.length > 0 ||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user