mirror of
https://github.com/apache/superset.git
synced 2026-07-31 02:52:27 +00:00
Compare commits
36 Commits
semantic-l
...
feat-conve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b08b8e41e8 | ||
|
|
89b0ca0905 | ||
|
|
7f057b1ef8 | ||
|
|
8eb510f21a | ||
|
|
022cd3c2e4 | ||
|
|
6210bfa8b5 | ||
|
|
f726f26a16 | ||
|
|
3fae07398d | ||
|
|
078a57dc2d | ||
|
|
13513c35c4 | ||
|
|
46404692d1 | ||
|
|
8e3164a4f2 | ||
|
|
825b7edcc0 | ||
|
|
8952e80ffd | ||
|
|
e5ecf755fd | ||
|
|
c4369f907f | ||
|
|
f51f1cdd89 | ||
|
|
d65f5ac719 | ||
|
|
67e365b2e3 | ||
|
|
71cabc33e0 | ||
|
|
468cf9a686 | ||
|
|
382928b1e7 | ||
|
|
e830742f36 | ||
|
|
65e71dc429 | ||
|
|
a567408a63 | ||
|
|
f8538d104d | ||
|
|
16eb3229c9 | ||
|
|
e673ffbf6f | ||
|
|
b3e8fea741 | ||
|
|
aa75b805f7 | ||
|
|
bad24486b8 | ||
|
|
551f8968c8 | ||
|
|
f6e7d7bff5 | ||
|
|
51c3e2133d | ||
|
|
4cbdff6338 | ||
|
|
c39a2e89ab |
13
.github/workflows/bashlib.sh
vendored
13
.github/workflows/bashlib.sh
vendored
@@ -117,6 +117,19 @@ testdata() {
|
||||
say "::endgroup::"
|
||||
}
|
||||
|
||||
playwright_testdata() {
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
say "::group::Load all examples for Playwright tests"
|
||||
# must specify PYTHONPATH to make `tests.superset_test_config` importable
|
||||
export PYTHONPATH="$GITHUB_WORKSPACE"
|
||||
pip install -e .
|
||||
superset db upgrade
|
||||
superset load_test_users
|
||||
superset load_examples
|
||||
superset init
|
||||
say "::endgroup::"
|
||||
}
|
||||
|
||||
celery-worker() {
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
say "::group::Start Celery worker"
|
||||
|
||||
2
.github/workflows/superset-e2e.yml
vendored
2
.github/workflows/superset-e2e.yml
vendored
@@ -223,7 +223,7 @@ jobs:
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: testdata
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
2
.github/workflows/superset-playwright.yml
vendored
2
.github/workflows/superset-playwright.yml
vendored
@@ -97,7 +97,7 @@ jobs:
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: ./.github/actions/cached-dependencies
|
||||
with:
|
||||
run: testdata
|
||||
run: playwright_testdata
|
||||
- name: Setup Node.js
|
||||
if: steps.check.outputs.python || steps.check.outputs.frontend
|
||||
uses: actions/setup-node@v5
|
||||
|
||||
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.
|
||||
|
||||
@@ -67,7 +67,6 @@ x-superset-volumes: &superset-volumes
|
||||
- ./superset-frontend:/app/superset-frontend
|
||||
- superset_home_light:/app/superset_home
|
||||
- ./tests:/app/tests
|
||||
- ./extensions:/app/extensions
|
||||
x-common-build: &common-build
|
||||
context: .
|
||||
target: ${SUPERSET_BUILD_TARGET:-dev} # can use `dev` (default) or `lean`
|
||||
|
||||
@@ -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..."
|
||||
|
||||
@@ -105,15 +105,7 @@ class CeleryConfig:
|
||||
|
||||
CELERY_CONFIG = CeleryConfig
|
||||
|
||||
# Extensions configuration
|
||||
# For local development, point to the extensions directory
|
||||
# Note: If running in Docker, this path needs to be accessible from inside the container
|
||||
EXTENSIONS_PATH = os.getenv("EXTENSIONS_PATH", "/app/extensions")
|
||||
|
||||
FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"ENABLE_EXTENSIONS": True,
|
||||
}
|
||||
FEATURE_FLAGS = {"ALERT_REPORTS": True}
|
||||
ALERT_REPORTS_NOTIFICATION_DRY_RUN = True
|
||||
WEBDRIVER_BASEURL = f"http://superset_app{os.environ.get('SUPERSET_APP_ROOT', '/')}/" # When using docker compose baseurl should be http://superset_nginx{ENV{BASEPATH}}/ # noqa: E501
|
||||
# The base URL for the email report hyperlinks.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
# Requirements for the Snowflake Semantic Layer extension
|
||||
# Install with: pip install -r extensions/requirements-snowflake.txt
|
||||
|
||||
snowflake-connector-python>=3.0.0
|
||||
snowflake-sqlalchemy>=1.5.0
|
||||
Binary file not shown.
@@ -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
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
3
superset-frontend/.gitignore
vendored
3
superset-frontend/.gitignore
vendored
@@ -1,6 +1,9 @@
|
||||
coverage/*
|
||||
cypress/screenshots
|
||||
cypress/videos
|
||||
playwright/.auth
|
||||
playwright-report/
|
||||
test-results/
|
||||
src/temp
|
||||
.temp_cache/
|
||||
.tsbuildinfo
|
||||
|
||||
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() {
|
||||
|
||||
@@ -67,7 +67,6 @@ export function normalizeTimeColumn(
|
||||
sqlExpression: formData.x_axis,
|
||||
label: formData.x_axis,
|
||||
expressionType: 'SQL',
|
||||
isColumnReference: true,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ export interface AdhocColumn {
|
||||
optionName?: string;
|
||||
sqlExpression: string;
|
||||
expressionType: 'SQL';
|
||||
isColumnReference?: boolean;
|
||||
columnType?: 'BASE_AXIS' | 'SERIES';
|
||||
timeGrain?: string;
|
||||
datasourceWarning?: boolean;
|
||||
@@ -75,10 +74,6 @@ export function isAdhocColumn(column?: any): column is AdhocColumn {
|
||||
);
|
||||
}
|
||||
|
||||
export function isAdhocColumnReference(column?: any): column is AdhocColumn {
|
||||
return isAdhocColumn(column) && column?.isColumnReference === true;
|
||||
}
|
||||
|
||||
export function isQueryFormColumn(column: any): column is QueryFormColumn {
|
||||
return isPhysicalColumn(column) || isAdhocColumn(column);
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -86,7 +86,6 @@ test('should support different columns for x-axis and granularity', () => {
|
||||
{
|
||||
timeGrain: 'P1Y',
|
||||
columnType: 'BASE_AXIS',
|
||||
isColumnReference: true,
|
||||
sqlExpression: 'time_column_in_x_axis',
|
||||
label: 'time_column_in_x_axis',
|
||||
expressionType: 'SQL',
|
||||
|
||||
@@ -33,6 +33,9 @@ export default defineConfig({
|
||||
? undefined
|
||||
: '**/experimental/**',
|
||||
|
||||
// Global setup - authenticate once before all tests
|
||||
globalSetup: './playwright/global-setup.ts',
|
||||
|
||||
// Timeout settings
|
||||
timeout: 30000,
|
||||
expect: { timeout: 8000 },
|
||||
@@ -77,10 +80,32 @@ export default defineConfig({
|
||||
|
||||
projects: [
|
||||
{
|
||||
// Default project - uses global authentication for speed
|
||||
// E2E tests login once via global-setup.ts and reuse auth state
|
||||
// Explicitly ignore auth tests (they run in chromium-unauth project)
|
||||
// Also respect the global experimental testIgnore setting
|
||||
name: 'chromium',
|
||||
testIgnore: [
|
||||
'**/tests/auth/**/*.spec.ts',
|
||||
...(process.env.INCLUDE_EXPERIMENTAL ? [] : ['**/experimental/**']),
|
||||
],
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
testIdAttribute: 'data-test',
|
||||
// Reuse authentication state from global setup (fast E2E tests)
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Separate project for unauthenticated tests (login, signup, etc.)
|
||||
// These tests use beforeEach for per-test navigation - no global auth
|
||||
// This hybrid approach: simple auth tests, fast E2E tests
|
||||
name: 'chromium-unauth',
|
||||
testMatch: '**/tests/auth/**/*.spec.ts',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
testIdAttribute: 'data-test',
|
||||
// No storageState = clean browser with no cached cookies
|
||||
},
|
||||
},
|
||||
],
|
||||
|
||||
105
superset-frontend/playwright/components/core/Modal.ts
Normal file
105
superset-frontend/playwright/components/core/Modal.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Base Modal component for Ant Design modals.
|
||||
* Provides minimal primitives - extend this for specific modal types.
|
||||
* Add methods to this class only when multiple modal types need them (YAGNI).
|
||||
*
|
||||
* @example
|
||||
* class DeleteConfirmationModal extends Modal {
|
||||
* async clickDelete(): Promise<void> {
|
||||
* await this.footer.locator('button', { hasText: 'Delete' }).click();
|
||||
* }
|
||||
* }
|
||||
*/
|
||||
export class Modal {
|
||||
protected readonly page: Page;
|
||||
protected readonly modalSelector: string;
|
||||
|
||||
// Ant Design modal structure selectors (shared by all modal types)
|
||||
protected static readonly BASE_SELECTORS = {
|
||||
FOOTER: '.ant-modal-footer',
|
||||
BODY: '.ant-modal-body',
|
||||
};
|
||||
|
||||
constructor(page: Page, modalSelector = '[role="dialog"]') {
|
||||
this.page = page;
|
||||
this.modalSelector = modalSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the modal element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.page.locator(this.modalSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the modal footer locator (contains action buttons)
|
||||
*/
|
||||
get footer(): Locator {
|
||||
return this.element.locator(Modal.BASE_SELECTORS.FOOTER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the modal body locator (contains content)
|
||||
*/
|
||||
get body(): Locator {
|
||||
return this.element.locator(Modal.BASE_SELECTORS.BODY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a footer button by text content (private helper)
|
||||
* @param buttonText - The text content of the button
|
||||
*/
|
||||
private getFooterButton(buttonText: string): Locator {
|
||||
return this.footer.locator('button', { hasText: buttonText });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a footer button by text content
|
||||
* @param buttonText - The text content of the button to click
|
||||
* @param options - Optional click options
|
||||
*/
|
||||
protected async clickFooterButton(
|
||||
buttonText: string,
|
||||
options?: { timeout?: number; force?: boolean; delay?: number },
|
||||
): Promise<void> {
|
||||
await this.getFooterButton(buttonText).click(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the modal to become visible
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForVisible(options?: { timeout?: number }): Promise<void> {
|
||||
await this.element.waitFor({ state: 'visible', ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the modal to be hidden
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForHidden(options?: { timeout?: number }): Promise<void> {
|
||||
await this.element.waitFor({ state: 'hidden', ...options });
|
||||
}
|
||||
}
|
||||
85
superset-frontend/playwright/components/core/Table.ts
Normal file
85
superset-frontend/playwright/components/core/Table.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 { Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Table component for Superset ListView tables.
|
||||
*/
|
||||
export class Table {
|
||||
private readonly page: Page;
|
||||
private readonly tableSelector: string;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
TABLE_ROW: '[data-test="table-row"]',
|
||||
};
|
||||
|
||||
constructor(page: Page, tableSelector = '[data-test="listview-table"]') {
|
||||
this.page = page;
|
||||
this.tableSelector = tableSelector;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the table element locator
|
||||
*/
|
||||
get element(): Locator {
|
||||
return this.page.locator(this.tableSelector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a table row by exact text match in the first cell (dataset name column).
|
||||
* Uses exact match to avoid substring collisions (e.g., 'members_channels_2' vs 'duplicate_members_channels_2_123').
|
||||
* @param rowText - Exact text to find in the row's first cell
|
||||
*/
|
||||
getRow(rowText: string): Locator {
|
||||
return this.element
|
||||
.locator(Table.SELECTORS.TABLE_ROW)
|
||||
.filter({
|
||||
has: this.page.getByRole('cell', { name: rowText, exact: true }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks a link within a specific row
|
||||
* @param rowText - Text to identify the row
|
||||
* @param linkSelector - Selector for the link within the row
|
||||
*/
|
||||
async clickRowLink(rowText: string, linkSelector: string): Promise<void> {
|
||||
const row = this.getRow(rowText);
|
||||
await row.locator(linkSelector).click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the table to be visible
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForVisible(options?: { timeout?: number }): Promise<void> {
|
||||
await this.element.waitFor({ state: 'visible', ...options });
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks an action button in a row by selector
|
||||
* @param rowText - Text to identify the row
|
||||
* @param selector - CSS selector for the action element
|
||||
*/
|
||||
async clickRowAction(rowText: string, selector: string): Promise<void> {
|
||||
const row = this.getRow(rowText);
|
||||
await row.locator(selector).first().click();
|
||||
}
|
||||
}
|
||||
105
superset-frontend/playwright/components/core/Toast.ts
Normal file
105
superset-frontend/playwright/components/core/Toast.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* 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 { Page, Locator } from '@playwright/test';
|
||||
|
||||
export type ToastType = 'success' | 'danger' | 'warning' | 'info';
|
||||
|
||||
const SELECTORS = {
|
||||
CONTAINER: '[data-test="toast-container"][role="alert"]',
|
||||
CONTENT: '.toast__content',
|
||||
CLOSE_BUTTON: '[data-test="close-button"]',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Toast notification component
|
||||
* Handles success, danger, warning, and info toasts
|
||||
*/
|
||||
export class Toast {
|
||||
private page: Page;
|
||||
private container: Locator;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.container = page.locator(SELECTORS.CONTAINER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the toast container locator
|
||||
*/
|
||||
get(): Locator {
|
||||
return this.container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the toast message text
|
||||
*/
|
||||
getMessage(): Locator {
|
||||
return this.container.locator(SELECTORS.CONTENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a toast to appear
|
||||
*/
|
||||
async waitForVisible(): Promise<void> {
|
||||
await this.container.waitFor({ state: 'visible' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for toast to disappear
|
||||
*/
|
||||
async waitForHidden(): Promise<void> {
|
||||
await this.container.waitFor({ state: 'hidden' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a success toast
|
||||
*/
|
||||
getSuccess(): Locator {
|
||||
return this.page.locator(`${SELECTORS.CONTAINER}.toast--success`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a danger/error toast
|
||||
*/
|
||||
getDanger(): Locator {
|
||||
return this.page.locator(`${SELECTORS.CONTAINER}.toast--danger`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a warning toast
|
||||
*/
|
||||
getWarning(): Locator {
|
||||
return this.page.locator(`${SELECTORS.CONTAINER}.toast--warning`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an info toast
|
||||
*/
|
||||
getInfo(): Locator {
|
||||
return this.page.locator(`${SELECTORS.CONTAINER}.toast--info`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the toast by clicking the close button
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
await this.container.locator(SELECTORS.CLOSE_BUTTON).click();
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,5 @@
|
||||
export { Button } from './Button';
|
||||
export { Form } from './Form';
|
||||
export { Input } from './Input';
|
||||
export { Modal } from './Modal';
|
||||
export { Table } from './Table';
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
* Delete confirmation modal that requires typing "DELETE" to confirm.
|
||||
* Used throughout Superset for destructive delete operations.
|
||||
*
|
||||
* Provides primitives for tests to compose deletion flows.
|
||||
*/
|
||||
export class DeleteConfirmationModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
CONFIRMATION_INPUT: 'input[type="text"]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the confirmation input component
|
||||
*/
|
||||
private get confirmationInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the confirmation input with the specified text.
|
||||
*
|
||||
* @param confirmationText - The text to type
|
||||
* @param options - Optional fill options (timeout, force)
|
||||
*
|
||||
* @example
|
||||
* const deleteModal = new DeleteConfirmationModal(page);
|
||||
* await deleteModal.waitForVisible();
|
||||
* await deleteModal.fillConfirmationInput('DELETE');
|
||||
* await deleteModal.clickDelete();
|
||||
* await deleteModal.waitForHidden();
|
||||
*/
|
||||
async fillConfirmationInput(
|
||||
confirmationText: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.confirmationInput.fill(confirmationText, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Delete button in the footer
|
||||
*
|
||||
* @param options - Optional click options (timeout, force, delay)
|
||||
*/
|
||||
async clickDelete(options?: {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.clickFooterButton('Delete', options);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Modal, Input } from '../core';
|
||||
|
||||
/**
|
||||
* Duplicate dataset modal that requires entering a new dataset name.
|
||||
* Used for duplicating virtual datasets with custom SQL.
|
||||
*/
|
||||
export class DuplicateDatasetModal extends Modal {
|
||||
private static readonly SELECTORS = {
|
||||
NAME_INPUT: '[data-test="duplicate-modal-input"]',
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the new dataset name input component
|
||||
*/
|
||||
private get nameInput(): Input {
|
||||
return new Input(
|
||||
this.page,
|
||||
this.body.locator(DuplicateDatasetModal.SELECTORS.NAME_INPUT),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the new dataset name input
|
||||
*
|
||||
* @param datasetName - The new name for the duplicated dataset
|
||||
* @param options - Optional fill options (timeout, force)
|
||||
*
|
||||
* @example
|
||||
* const duplicateModal = new DuplicateDatasetModal(page);
|
||||
* await duplicateModal.waitForVisible();
|
||||
* await duplicateModal.fillDatasetName('my_dataset_copy');
|
||||
* await duplicateModal.clickDuplicate();
|
||||
* await duplicateModal.waitForHidden();
|
||||
*/
|
||||
async fillDatasetName(
|
||||
datasetName: string,
|
||||
options?: { timeout?: number; force?: boolean },
|
||||
): Promise<void> {
|
||||
await this.nameInput.fill(datasetName, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the Duplicate button in the footer
|
||||
*
|
||||
* @param options - Optional click options (timeout, force, delay)
|
||||
*/
|
||||
async clickDuplicate(options?: {
|
||||
timeout?: number;
|
||||
force?: boolean;
|
||||
delay?: number;
|
||||
}): Promise<void> {
|
||||
await this.clickFooterButton('Duplicate', options);
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
export { default as StreamingExportModal } from './StreamingExportModal';
|
||||
export type { StreamingProgress } from './StreamingExportModal';
|
||||
export { useStreamingExport } from './useStreamingExport';
|
||||
|
||||
// Specific modal implementations
|
||||
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
|
||||
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
|
||||
87
superset-frontend/playwright/global-setup.ts
Normal file
87
superset-frontend/playwright/global-setup.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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 {
|
||||
chromium,
|
||||
FullConfig,
|
||||
Browser,
|
||||
BrowserContext,
|
||||
} from '@playwright/test';
|
||||
import { mkdir } from 'fs/promises';
|
||||
import { dirname } from 'path';
|
||||
import { AuthPage } from './pages/AuthPage';
|
||||
|
||||
/**
|
||||
* Global setup function that runs once before all tests.
|
||||
* Authenticates as admin user and saves the authentication state
|
||||
* to be reused by tests in the 'chromium' project (E2E tests).
|
||||
*
|
||||
* Auth tests (chromium-unauth project) don't use this - they login
|
||||
* per-test via beforeEach for isolation and simplicity.
|
||||
*/
|
||||
async function globalSetup(config: FullConfig) {
|
||||
// Get baseURL with fallback to default
|
||||
// FullConfig.use doesn't exist in the type - baseURL is only in projects[0].use
|
||||
const baseURL = config.projects[0]?.use?.baseURL || 'http://localhost:8088';
|
||||
|
||||
console.log('[Global Setup] Authenticating as admin user...');
|
||||
|
||||
let browser: Browser | null = null;
|
||||
let context: BrowserContext | null = null;
|
||||
|
||||
try {
|
||||
// Launch browser
|
||||
browser = await chromium.launch();
|
||||
} catch (error) {
|
||||
console.error('[Global Setup] Failed to launch browser:', error);
|
||||
throw new Error('Browser launch failed - check Playwright installation');
|
||||
}
|
||||
|
||||
try {
|
||||
context = await browser.newContext({ baseURL });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Use AuthPage to handle login logic (DRY principle)
|
||||
const authPage = new AuthPage(page);
|
||||
await authPage.goto();
|
||||
await authPage.waitForLoginForm();
|
||||
await authPage.loginWithCredentials('admin', 'general');
|
||||
await authPage.waitForLoginSuccess();
|
||||
|
||||
// Save authentication state for all tests to reuse
|
||||
const authStatePath = 'playwright/.auth/user.json';
|
||||
await mkdir(dirname(authStatePath), { recursive: true });
|
||||
await context.storageState({
|
||||
path: authStatePath,
|
||||
});
|
||||
|
||||
console.log(
|
||||
'[Global Setup] Authentication successful - state saved to playwright/.auth/user.json',
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[Global Setup] Authentication failed:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
// Ensure cleanup even if auth fails
|
||||
if (context) await context.close();
|
||||
if (browser) await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
export default globalSetup;
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 { Page } from '@playwright/test';
|
||||
import { apiPostDatabase } from './database';
|
||||
|
||||
// Public Google Sheets URL for testing
|
||||
const NETFLIX_TITLES_SHEET =
|
||||
'https://docs.google.com/spreadsheets/d/19XNqckHGKGGPh83JGFdFGP4Bw9gdXeujq5EoIGwttdM/edit#gid=347941303';
|
||||
|
||||
/**
|
||||
* Create a Google Sheets database connection for testing
|
||||
* Uses a public Netflix titles dataset by default
|
||||
* @param page - Playwright page instance
|
||||
* @param databaseName - Name for the database connection
|
||||
* @param tableName - Name for the table/dataset
|
||||
* @returns Database ID from the created database
|
||||
*/
|
||||
export async function createGsheetsDatabase(
|
||||
page: Page,
|
||||
databaseName: string,
|
||||
tableName: string,
|
||||
): Promise<number> {
|
||||
const requestBody = {
|
||||
database_name: databaseName,
|
||||
engine: 'gsheets',
|
||||
configuration_method: 'dynamic_form',
|
||||
engine_information: {
|
||||
disable_ssh_tunneling: true,
|
||||
supports_dynamic_catalog: false,
|
||||
supports_file_upload: true,
|
||||
supports_oauth2: true,
|
||||
},
|
||||
driver: 'apsw',
|
||||
sqlalchemy_uri_placeholder: 'gsheets://',
|
||||
extra: JSON.stringify({
|
||||
allows_virtual_table_explore: true,
|
||||
engine_params: {
|
||||
catalog: {
|
||||
[tableName]: NETFLIX_TITLES_SHEET,
|
||||
},
|
||||
},
|
||||
}),
|
||||
expose_in_sqllab: true,
|
||||
catalog: [
|
||||
{
|
||||
name: tableName,
|
||||
value: NETFLIX_TITLES_SHEET,
|
||||
},
|
||||
],
|
||||
parameters: {
|
||||
service_account_info: '',
|
||||
catalog: {
|
||||
[tableName]: NETFLIX_TITLES_SHEET,
|
||||
},
|
||||
},
|
||||
masked_encrypted_extra: '{}',
|
||||
impersonate_user: true,
|
||||
};
|
||||
|
||||
const response = await apiPostDatabase(page, requestBody);
|
||||
|
||||
if (!response.ok()) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Failed to create database: ${response.status()} ${response.statusText()}\n${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
return body.id;
|
||||
}
|
||||
79
superset-frontend/playwright/helpers/api/database.ts
Normal file
79
superset-frontend/playwright/helpers/api/database.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* 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 { Page, APIResponse } from '@playwright/test';
|
||||
import { apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
|
||||
const ENDPOINTS = {
|
||||
DATABASE: 'api/v1/database/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TypeScript interface for database creation API payload
|
||||
* Provides compile-time safety for required fields
|
||||
*/
|
||||
export interface DatabaseCreatePayload {
|
||||
database_name: string;
|
||||
engine: string;
|
||||
configuration_method?: string;
|
||||
engine_information?: {
|
||||
disable_ssh_tunneling?: boolean;
|
||||
supports_dynamic_catalog?: boolean;
|
||||
supports_file_upload?: boolean;
|
||||
supports_oauth2?: boolean;
|
||||
};
|
||||
driver?: string;
|
||||
sqlalchemy_uri_placeholder?: string;
|
||||
extra?: string;
|
||||
expose_in_sqllab?: boolean;
|
||||
catalog?: Array<{ name: string; value: string }>;
|
||||
parameters?: {
|
||||
service_account_info?: string;
|
||||
catalog?: Record<string, string>;
|
||||
};
|
||||
masked_encrypted_extra?: string;
|
||||
impersonate_user?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to create a database connection
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param requestBody - Database configuration object with type safety
|
||||
* @returns API response from database creation
|
||||
*/
|
||||
export async function apiPostDatabase(
|
||||
page: Page,
|
||||
requestBody: DatabaseCreatePayload,
|
||||
): Promise<APIResponse> {
|
||||
return apiPost(page, ENDPOINTS.DATABASE, requestBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request to remove a database connection
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param databaseId - ID of the database to delete
|
||||
* @returns API response from database deletion
|
||||
*/
|
||||
export async function apiDeleteDatabase(
|
||||
page: Page,
|
||||
databaseId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.DATABASE}${databaseId}`, options);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* 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 { Page } from '@playwright/test';
|
||||
import { createGsheetsDatabase } from './database.factories';
|
||||
import { apiPostDataset } from './dataset';
|
||||
import { apiDeleteDatabase } from './database';
|
||||
|
||||
/**
|
||||
* Create a test dataset with Google Sheets database
|
||||
* Creates both the database connection and dataset in one call
|
||||
* @param page - Playwright page instance
|
||||
* @param datasetName - Name for the dataset/table
|
||||
* @returns Object containing database ID and dataset ID
|
||||
*/
|
||||
export async function createTestDataset(
|
||||
page: Page,
|
||||
datasetName: string,
|
||||
): Promise<{ dbId: number; datasetId: number }> {
|
||||
// Step 1: Create Google Sheets database with catalog entry
|
||||
// The tableName in the catalog must match the table_name used when creating the dataset
|
||||
const dbName = `test_db_${Date.now()}`;
|
||||
const tableName = datasetName; // Use same name for catalog entry and dataset table_name
|
||||
const dbId = await createGsheetsDatabase(page, dbName, tableName);
|
||||
|
||||
// Step 2: Create dataset using the database
|
||||
// Wrap in try/finally to ensure database cleanup on failure
|
||||
try {
|
||||
// For Google Sheets, table_name must reference the catalog entry name
|
||||
// catalog: null is required to avoid OAuth validation issues
|
||||
const datasetRequestBody = {
|
||||
database: dbId,
|
||||
catalog: null,
|
||||
schema: 'main',
|
||||
table_name: tableName, // Must match the catalog entry name
|
||||
};
|
||||
|
||||
const response = await apiPostDataset(page, datasetRequestBody);
|
||||
|
||||
if (!response.ok()) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Failed to create dataset: ${response.status()} ${response.statusText()}\n${errorText}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
const datasetId = body.id;
|
||||
|
||||
return { dbId, datasetId };
|
||||
} catch (error) {
|
||||
// Clean up the orphaned database before rethrowing
|
||||
await apiDeleteDatabase(page, dbId, { failOnStatusCode: false }).catch(
|
||||
() => {
|
||||
// Silently ignore cleanup errors - the original error is more important
|
||||
},
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
112
superset-frontend/playwright/helpers/api/dataset.ts
Normal file
112
superset-frontend/playwright/helpers/api/dataset.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
/**
|
||||
* 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 { Page, APIResponse } from '@playwright/test';
|
||||
import { apiGet, apiPost, apiDelete, ApiRequestOptions } from './requests';
|
||||
|
||||
const ENDPOINTS = {
|
||||
DATASET: 'api/v1/dataset/',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* TypeScript interface for dataset creation API payload
|
||||
* Provides compile-time safety for required fields
|
||||
*/
|
||||
export interface DatasetCreatePayload {
|
||||
database: number;
|
||||
catalog: string | null;
|
||||
schema: string;
|
||||
table_name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request to create a dataset
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param requestBody - Dataset configuration object (database, schema, table_name)
|
||||
* @returns API response from dataset creation
|
||||
*/
|
||||
export async function apiPostDataset(
|
||||
page: Page,
|
||||
requestBody: DatasetCreatePayload,
|
||||
): Promise<APIResponse> {
|
||||
return apiPost(page, ENDPOINTS.DATASET, requestBody);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a dataset by its table name
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param tableName - The table_name to search for
|
||||
* @returns Object with id and data if found, null if not found
|
||||
*/
|
||||
export async function getDatasetByName(
|
||||
page: Page,
|
||||
tableName: string,
|
||||
): Promise<{ id: number; data: any } | null> {
|
||||
// Use Superset's filter API to search by table_name
|
||||
const filter = {
|
||||
filters: [
|
||||
{
|
||||
col: 'table_name',
|
||||
opr: 'eq',
|
||||
value: tableName,
|
||||
},
|
||||
],
|
||||
};
|
||||
const queryParam = encodeURIComponent(JSON.stringify(filter));
|
||||
const response = await apiGet(page, `${ENDPOINTS.DATASET}?q=${queryParam}`);
|
||||
|
||||
if (!response.ok()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = await response.json();
|
||||
if (body.result && body.result.length > 0) {
|
||||
return { id: body.result[0].id, data: body.result[0] };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request to fetch a dataset's details
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param datasetId - ID of the dataset to fetch
|
||||
* @returns API response with dataset details
|
||||
*/
|
||||
export async function apiGetDataset(
|
||||
page: Page,
|
||||
datasetId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiGet(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request to remove a dataset
|
||||
* @param page - Playwright page instance (provides authentication context)
|
||||
* @param datasetId - ID of the dataset to delete
|
||||
* @returns API response from dataset deletion
|
||||
*/
|
||||
export async function apiDeleteDataset(
|
||||
page: Page,
|
||||
datasetId: number,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return apiDelete(page, `${ENDPOINTS.DATASET}${datasetId}`, options);
|
||||
}
|
||||
178
superset-frontend/playwright/helpers/api/requests.ts
Normal file
178
superset-frontend/playwright/helpers/api/requests.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* 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 { Page, APIResponse } from '@playwright/test';
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
headers?: Record<string, string>;
|
||||
params?: Record<string, string>;
|
||||
failOnStatusCode?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base URL for Referer header
|
||||
* Reads from environment variable configured in playwright.config.ts
|
||||
* Preserves full base URL including path prefix (e.g., /app/prefix)
|
||||
*/
|
||||
function getBaseUrl(_page: Page): string {
|
||||
// Use environment variable which includes path prefix if configured
|
||||
// This matches playwright.config.ts baseURL setting exactly
|
||||
return process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSRF token from the API endpoint
|
||||
* Superset provides a CSRF token via api/v1/security/csrf_token/
|
||||
* The session cookie is automatically included by page.request
|
||||
*/
|
||||
async function getCsrfToken(page: Page): Promise<string> {
|
||||
try {
|
||||
const response = await page.request.get('api/v1/security/csrf_token/', {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
|
||||
if (!response.ok()) {
|
||||
console.warn('[CSRF] Failed to fetch CSRF token:', response.status());
|
||||
return '';
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.result || '';
|
||||
} catch (error) {
|
||||
console.warn('[CSRF] Error fetching CSRF token:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build headers for mutation requests (POST, PUT, PATCH, DELETE)
|
||||
* Includes CSRF token and Referer for Flask-WTF CSRFProtect
|
||||
*/
|
||||
async function buildHeaders(
|
||||
page: Page,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<Record<string, string>> {
|
||||
const csrfToken = await getCsrfToken(page);
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers,
|
||||
};
|
||||
|
||||
// Include CSRF token and Referer for Flask-WTF CSRFProtect
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
headers['Referer'] = getBaseUrl(page);
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a GET request
|
||||
* Uses page.request to automatically include browser authentication
|
||||
*/
|
||||
export async function apiGet(
|
||||
page: Page,
|
||||
url: string,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
return page.request.get(url, {
|
||||
headers: options?.headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a POST request
|
||||
* Uses page.request to automatically include browser authentication
|
||||
*/
|
||||
export async function apiPost(
|
||||
page: Page,
|
||||
url: string,
|
||||
data?: unknown,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.post(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PUT request
|
||||
* Uses page.request to automatically include browser authentication
|
||||
*/
|
||||
export async function apiPut(
|
||||
page: Page,
|
||||
url: string,
|
||||
data?: unknown,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.put(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a PATCH request
|
||||
* Uses page.request to automatically include browser authentication
|
||||
*/
|
||||
export async function apiPatch(
|
||||
page: Page,
|
||||
url: string,
|
||||
data?: unknown,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.patch(url, {
|
||||
data,
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a DELETE request
|
||||
* Uses page.request to automatically include browser authentication
|
||||
*/
|
||||
export async function apiDelete(
|
||||
page: Page,
|
||||
url: string,
|
||||
options?: ApiRequestOptions,
|
||||
): Promise<APIResponse> {
|
||||
const headers = await buildHeaders(page, options);
|
||||
|
||||
return page.request.delete(url, {
|
||||
headers,
|
||||
params: options?.params,
|
||||
failOnStatusCode: options?.failOnStatusCode ?? true,
|
||||
});
|
||||
}
|
||||
@@ -17,9 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { Page, Response } from '@playwright/test';
|
||||
import { Page, Response, Cookie } from '@playwright/test';
|
||||
import { Form } from '../components/core';
|
||||
import { URL } from '../utils/urls';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
export class AuthPage {
|
||||
private readonly page: Page;
|
||||
@@ -56,7 +57,7 @@ export class AuthPage {
|
||||
* Wait for login form to be visible
|
||||
*/
|
||||
async waitForLoginForm(): Promise<void> {
|
||||
await this.loginForm.waitForVisible({ timeout: 5000 });
|
||||
await this.loginForm.waitForVisible({ timeout: TIMEOUT.FORM_LOAD });
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,6 +84,54 @@ export class AuthPage {
|
||||
await loginButton.click();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for successful login by verifying the login response and session cookie.
|
||||
* Call this after loginWithCredentials to ensure authentication completed.
|
||||
*
|
||||
* This does NOT assume a specific landing page (which is configurable).
|
||||
* Instead it:
|
||||
* 1. Checks if session cookie already exists (guards against race condition)
|
||||
* 2. Waits for POST /login/ response with redirect status
|
||||
* 3. Polls for session cookie to appear
|
||||
*
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForLoginSuccess(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout || TIMEOUT.PAGE_LOAD;
|
||||
const startTime = Date.now();
|
||||
|
||||
// 1. Guard: Check if session cookie already exists (race condition protection)
|
||||
const existingCookie = await this.getSessionCookie();
|
||||
if (existingCookie?.value) {
|
||||
// Already authenticated - login completed before we started waiting
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Wait for POST /login/ response
|
||||
const loginResponse = await this.waitForLoginRequest();
|
||||
|
||||
// 3. Verify it's a redirect (3xx status code indicates successful login)
|
||||
const status = loginResponse.status();
|
||||
if (status < 300 || status >= 400) {
|
||||
throw new Error(`Login failed: expected redirect (3xx), got ${status}`);
|
||||
}
|
||||
|
||||
// 4. Poll for session cookie to appear (may take a moment after redirect)
|
||||
const pollInterval = TIMEOUT.API_POLL_INTERVAL;
|
||||
while (Date.now() - startTime < timeout) {
|
||||
const sessionCookie = await this.getSessionCookie();
|
||||
if (sessionCookie && sessionCookie.value) {
|
||||
// Success - session cookie has landed
|
||||
return;
|
||||
}
|
||||
await this.page.waitForTimeout(pollInterval);
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Login timeout: session cookie did not appear within ${timeout}ms`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page URL
|
||||
*/
|
||||
@@ -93,9 +142,9 @@ export class AuthPage {
|
||||
/**
|
||||
* Get the session cookie specifically
|
||||
*/
|
||||
async getSessionCookie(): Promise<{ name: string; value: string } | null> {
|
||||
async getSessionCookie(): Promise<Cookie | null> {
|
||||
const cookies = await this.page.context().cookies();
|
||||
return cookies.find((c: any) => c.name === 'session') || null;
|
||||
return cookies.find(c => c.name === 'session') || null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,7 +155,7 @@ export class AuthPage {
|
||||
selector => this.page.locator(selector).isVisible(),
|
||||
);
|
||||
const visibilityResults = await Promise.all(visibilityPromises);
|
||||
return visibilityResults.some((isVisible: any) => isVisible);
|
||||
return visibilityResults.some(isVisible => isVisible);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,7 +163,7 @@ export class AuthPage {
|
||||
*/
|
||||
async waitForLoginRequest(): Promise<Response> {
|
||||
return this.page.waitForResponse(
|
||||
(response: any) =>
|
||||
response =>
|
||||
response.url().includes('/login/') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
|
||||
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal file
115
superset-frontend/playwright/pages/DatasetListPage.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
/**
|
||||
* 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 { Page, Locator } from '@playwright/test';
|
||||
import { Table } from '../components/core';
|
||||
import { URL } from '../utils/urls';
|
||||
|
||||
/**
|
||||
* Dataset List Page object.
|
||||
*/
|
||||
export class DatasetListPage {
|
||||
private readonly page: Page;
|
||||
private readonly table: Table;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DATASET_LINK: '[data-test="internal-link"]',
|
||||
DELETE_ACTION: '.action-button svg[data-icon="delete"]',
|
||||
EXPORT_ACTION: '.action-button svg[data-icon="upload"]',
|
||||
DUPLICATE_ACTION: '.action-button svg[data-icon="copy"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
this.table = new Table(page);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigate to the dataset list page
|
||||
*/
|
||||
async goto(): Promise<void> {
|
||||
await this.page.goto(URL.DATASET_LIST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the table to load
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForTableLoad(options?: { timeout?: number }): Promise<void> {
|
||||
await this.table.waitForVisible(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a dataset row locator by name.
|
||||
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
|
||||
*
|
||||
* @param datasetName - The name of the dataset
|
||||
* @returns Locator for the dataset row
|
||||
*
|
||||
* @example
|
||||
* await expect(datasetListPage.getDatasetRow('birth_names')).toBeVisible();
|
||||
*/
|
||||
getDatasetRow(datasetName: string): Locator {
|
||||
return this.table.getRow(datasetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks on a dataset name to navigate to Explore
|
||||
* @param datasetName - The name of the dataset to click
|
||||
*/
|
||||
async clickDatasetName(datasetName: string): Promise<void> {
|
||||
await this.table.clickRowLink(
|
||||
datasetName,
|
||||
DatasetListPage.SELECTORS.DATASET_LINK,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the delete action button for a dataset
|
||||
* @param datasetName - The name of the dataset to delete
|
||||
*/
|
||||
async clickDeleteAction(datasetName: string): Promise<void> {
|
||||
await this.table.clickRowAction(
|
||||
datasetName,
|
||||
DatasetListPage.SELECTORS.DELETE_ACTION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the export action button for a dataset
|
||||
* @param datasetName - The name of the dataset to export
|
||||
*/
|
||||
async clickExportAction(datasetName: string): Promise<void> {
|
||||
await this.table.clickRowAction(
|
||||
datasetName,
|
||||
DatasetListPage.SELECTORS.EXPORT_ACTION,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clicks the duplicate action button for a dataset (virtual datasets only)
|
||||
* @param datasetName - The name of the dataset to duplicate
|
||||
*/
|
||||
async clickDuplicateAction(datasetName: string): Promise<void> {
|
||||
await this.table.clickRowAction(
|
||||
datasetName,
|
||||
DatasetListPage.SELECTORS.DUPLICATE_ACTION,
|
||||
);
|
||||
}
|
||||
}
|
||||
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal file
88
superset-frontend/playwright/pages/ExplorePage.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 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 { Page, Locator } from '@playwright/test';
|
||||
import { TIMEOUT } from '../utils/constants';
|
||||
|
||||
/**
|
||||
* Explore Page object
|
||||
*/
|
||||
export class ExplorePage {
|
||||
private readonly page: Page;
|
||||
|
||||
private static readonly SELECTORS = {
|
||||
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
|
||||
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Explore page to load.
|
||||
* Validates URL contains /explore/ and datasource control is visible.
|
||||
*
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async waitForPageLoad(options?: { timeout?: number }): Promise<void> {
|
||||
const timeout = options?.timeout || TIMEOUT.PAGE_LOAD;
|
||||
|
||||
await this.page.waitForURL('**/explore/**', { timeout });
|
||||
|
||||
await this.page.waitForSelector(ExplorePage.SELECTORS.DATASOURCE_CONTROL, {
|
||||
state: 'visible',
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the datasource control locator.
|
||||
* Returns a Locator that tests can use with expect() or to read text.
|
||||
*
|
||||
* @returns Locator for the datasource control
|
||||
*
|
||||
* @example
|
||||
* const name = await explorePage.getDatasourceControl().textContent();
|
||||
*/
|
||||
getDatasourceControl(): Locator {
|
||||
return this.page.locator(ExplorePage.SELECTORS.DATASOURCE_CONTROL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently selected dataset name from the datasource control
|
||||
*/
|
||||
async getDatasetName(): Promise<string> {
|
||||
const text = await this.getDatasourceControl().textContent();
|
||||
return text?.trim() || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the visualization switcher locator.
|
||||
* Returns a Locator that tests can use with expect().toBeVisible(), etc.
|
||||
*
|
||||
* @returns Locator for the viz switcher
|
||||
*
|
||||
* @example
|
||||
* await expect(explorePage.getVizSwitcher()).toBeVisible();
|
||||
*/
|
||||
getVizSwitcher(): Locator {
|
||||
return this.page.locator(ExplorePage.SELECTORS.VIZ_SWITCHER);
|
||||
}
|
||||
}
|
||||
@@ -20,69 +20,74 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { AuthPage } from '../../pages/AuthPage';
|
||||
import { URL } from '../../utils/urls';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
test.describe('Login view', () => {
|
||||
let authPage: AuthPage;
|
||||
/**
|
||||
* Auth/login tests use per-test navigation via beforeEach.
|
||||
* Each test starts fresh on the login page without global authentication.
|
||||
* This follows the Cypress pattern for auth testing - simple and isolated.
|
||||
*/
|
||||
|
||||
test.beforeEach(async ({ page }: any) => {
|
||||
authPage = new AuthPage(page);
|
||||
await authPage.goto();
|
||||
await authPage.waitForLoginForm();
|
||||
});
|
||||
|
||||
test('should redirect to login with incorrect username and password', async ({
|
||||
page,
|
||||
}: any) => {
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Attempt login with incorrect credentials
|
||||
await authPage.loginWithCredentials('admin', 'wrongpassword');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Failed login returns 401 Unauthorized or 302 redirect to login
|
||||
expect([401, 302]).toContain(loginResponse.status());
|
||||
|
||||
// Wait for redirect to complete before checking URL
|
||||
await page.waitForURL((url: any) => url.pathname.endsWith('login/'), {
|
||||
timeout: 10000,
|
||||
});
|
||||
|
||||
// Verify we stay on login page
|
||||
const currentUrl = await authPage.getCurrentUrl();
|
||||
expect(currentUrl).toContain(URL.LOGIN);
|
||||
|
||||
// Verify error message is shown
|
||||
const hasError = await authPage.hasLoginError();
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
|
||||
test('should login with correct username and password', async ({
|
||||
page,
|
||||
}: any) => {
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Login with correct credentials
|
||||
await authPage.loginWithCredentials('admin', 'general');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Successful login returns 302 redirect
|
||||
expect(loginResponse.status()).toBe(302);
|
||||
|
||||
// Wait for successful redirect to welcome page
|
||||
await page.waitForURL(
|
||||
(url: any) => url.pathname.endsWith('superset/welcome/'),
|
||||
{
|
||||
timeout: 10000,
|
||||
},
|
||||
);
|
||||
|
||||
// Verify specific session cookie exists
|
||||
const sessionCookie = await authPage.getSessionCookie();
|
||||
expect(sessionCookie).not.toBeNull();
|
||||
expect(sessionCookie?.value).toBeTruthy();
|
||||
});
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Navigate to login page before each test (ensures clean state)
|
||||
const authPage = new AuthPage(page);
|
||||
await authPage.goto();
|
||||
await authPage.waitForLoginForm();
|
||||
});
|
||||
|
||||
test('should redirect to login with incorrect username and password', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create page object (already on login page from beforeEach)
|
||||
const authPage = new AuthPage(page);
|
||||
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Attempt login with incorrect credentials
|
||||
await authPage.loginWithCredentials('admin', 'wrongpassword');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Failed login returns 401 Unauthorized or 302 redirect to login
|
||||
expect([401, 302]).toContain(loginResponse.status());
|
||||
|
||||
// Wait for redirect to complete before checking URL
|
||||
await page.waitForURL(url => url.pathname.endsWith(URL.LOGIN), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Verify we stay on login page
|
||||
const currentUrl = await authPage.getCurrentUrl();
|
||||
expect(currentUrl).toContain(URL.LOGIN);
|
||||
|
||||
// Verify error message is shown
|
||||
const hasError = await authPage.hasLoginError();
|
||||
expect(hasError).toBe(true);
|
||||
});
|
||||
|
||||
test('should login with correct username and password', async ({ page }) => {
|
||||
// Create page object (already on login page from beforeEach)
|
||||
const authPage = new AuthPage(page);
|
||||
|
||||
// Setup request interception before login attempt
|
||||
const loginRequestPromise = authPage.waitForLoginRequest();
|
||||
|
||||
// Login with correct credentials
|
||||
await authPage.loginWithCredentials('admin', 'general');
|
||||
|
||||
// Wait for login request and verify response
|
||||
const loginResponse = await loginRequestPromise;
|
||||
// Successful login returns 302 redirect
|
||||
expect(loginResponse.status()).toBe(302);
|
||||
|
||||
// Wait for successful redirect to welcome page
|
||||
await page.waitForURL(url => url.pathname.endsWith(URL.WELCOME), {
|
||||
timeout: TIMEOUT.PAGE_LOAD,
|
||||
});
|
||||
|
||||
// Verify specific session cookie exists
|
||||
const sessionCookie = await authPage.getSessionCookie();
|
||||
expect(sessionCookie).not.toBeNull();
|
||||
expect(sessionCookie?.value).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -19,52 +19,98 @@ under the License.
|
||||
|
||||
# Experimental Playwright Tests
|
||||
|
||||
This directory contains Playwright tests that are still under development or validation.
|
||||
|
||||
## Purpose
|
||||
|
||||
Tests in this directory run in "shadow mode" with `continue-on-error: true` in CI:
|
||||
- Failures do NOT block PR merges
|
||||
- Allows tests to run in CI to validate stability before promotion
|
||||
- Provides visibility into test reliability over time
|
||||
This directory contains **experimental** Playwright E2E tests that are being developed and stabilized before becoming part of the required test suite.
|
||||
|
||||
## Promoting Tests to Stable
|
||||
## How Experimental Tests Work
|
||||
|
||||
Once a test has proven stable (no false positives/negatives over sufficient time):
|
||||
|
||||
1. Move the test file out of `experimental/` to the appropriate feature directory:
|
||||
```bash
|
||||
# From the repository root:
|
||||
git mv superset-frontend/playwright/tests/experimental/dashboard/test.spec.ts \
|
||||
superset-frontend/playwright/tests/dashboard/
|
||||
|
||||
# Or from the superset-frontend/ directory:
|
||||
git mv playwright/tests/experimental/dashboard/test.spec.ts \
|
||||
playwright/tests/dashboard/
|
||||
```
|
||||
|
||||
2. The test will automatically become required for merge
|
||||
|
||||
## Test Organization
|
||||
|
||||
Organize tests by feature area:
|
||||
- `auth/` - Authentication and authorization tests
|
||||
- `dashboard/` - Dashboard functionality tests
|
||||
- `explore/` - Chart builder tests
|
||||
- `sqllab/` - SQL Lab tests
|
||||
- etc.
|
||||
|
||||
## Running Tests
|
||||
### Running Tests
|
||||
|
||||
**By default (CI and local), experimental tests are EXCLUDED:**
|
||||
```bash
|
||||
# Run all experimental tests (requires INCLUDE_EXPERIMENTAL env var)
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/
|
||||
|
||||
# Run specific experimental test
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test -- experimental/dashboard/test.spec.ts
|
||||
|
||||
# Run in UI mode for debugging
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:ui -- experimental/
|
||||
npm run playwright:test
|
||||
# Only runs stable tests (tests/auth/*)
|
||||
```
|
||||
|
||||
**Note**: The `INCLUDE_EXPERIMENTAL=true` environment variable is required because experimental tests are filtered out by default in `playwright.config.ts`. Without it, Playwright will report "No tests found".
|
||||
**To include experimental tests, set the environment variable:**
|
||||
```bash
|
||||
INCLUDE_EXPERIMENTAL=true npm run playwright:test
|
||||
# Runs all tests including experimental/
|
||||
```
|
||||
|
||||
### CI Behavior
|
||||
|
||||
- **Required CI jobs**: Experimental tests are excluded by default
|
||||
- Tests in `experimental/` do NOT block merges
|
||||
- Failures in `experimental/` do NOT fail the build
|
||||
|
||||
- **Experimental CI jobs** (optional): Use `TEST_PATH=experimental/`
|
||||
- `.github/workflows/bashlib.sh` sets `INCLUDE_EXPERIMENTAL=true` when `TEST_PATH` is provided
|
||||
- These jobs can use `continue-on-error: true` for shadow mode
|
||||
|
||||
### Configuration
|
||||
|
||||
The experimental pattern is configured in `playwright.config.ts`:
|
||||
|
||||
```typescript
|
||||
testIgnore: process.env.INCLUDE_EXPERIMENTAL
|
||||
? undefined
|
||||
: '**/experimental/**',
|
||||
```
|
||||
|
||||
This ensures:
|
||||
- Without `INCLUDE_EXPERIMENTAL`: Tests in `experimental/` are ignored
|
||||
- With `INCLUDE_EXPERIMENTAL=true`: All tests run, including experimental
|
||||
|
||||
## When to Use Experimental
|
||||
|
||||
Add tests to `experimental/` when:
|
||||
|
||||
1. **Testing new infrastructure** - New page objects, components, or patterns that need real-world validation
|
||||
2. **Flaky tests** - Tests that pass locally but have intermittent CI failures that need investigation
|
||||
3. **New test types** - E2E tests for new features that need to prove stability before becoming required
|
||||
4. **Prototyping** - Experimental approaches that may or may not become standard patterns
|
||||
|
||||
## Moving Tests to Stable
|
||||
|
||||
Once an experimental test has proven stable (consistent CI passes over time):
|
||||
|
||||
1. **Move the test file** from `experimental/` to the appropriate stable directory:
|
||||
```bash
|
||||
git mv tests/experimental/dataset/my-test.spec.ts tests/dataset/my-test.spec.ts
|
||||
```
|
||||
|
||||
2. **Commit the move** with a clear message:
|
||||
```bash
|
||||
git commit -m "test(playwright): promote my-test from experimental to stable"
|
||||
```
|
||||
|
||||
3. **Test will now be required** - It will run by default and block merges on failure
|
||||
|
||||
## Current Experimental Tests
|
||||
|
||||
### Dataset Tests
|
||||
|
||||
- **`dataset/dataset-list.spec.ts`** - Dataset list E2E tests
|
||||
- Status: Infrastructure complete, validating stability
|
||||
- Includes: Delete dataset test with API-based test data
|
||||
- Supporting infrastructure: API helpers, Modal components, page objects
|
||||
|
||||
## Infrastructure Location
|
||||
|
||||
**Important**: Supporting infrastructure (components, page objects, API helpers) should live in **stable locations**, NOT under `experimental/`:
|
||||
|
||||
✅ **Correct locations:**
|
||||
- `playwright/components/` - Components used by any tests
|
||||
- `playwright/pages/` - Page objects for any features
|
||||
- `playwright/helpers/api/` - API helpers for test data setup
|
||||
|
||||
❌ **Avoid:**
|
||||
- `playwright/tests/experimental/components/` - Makes it hard to share infrastructure
|
||||
|
||||
This keeps infrastructure reusable and avoids duplication when tests graduate from experimental to stable.
|
||||
|
||||
## Questions?
|
||||
|
||||
See [Superset Testing Documentation](https://superset.apache.org/docs/contributing/development#testing) or ask in the `#testing` Slack channel.
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* 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 { test, expect, Page } from '@playwright/test';
|
||||
import { DatasetListPage } from '../../../pages/DatasetListPage';
|
||||
import { ExplorePage } from '../../../pages/ExplorePage';
|
||||
import { DeleteConfirmationModal } from '../../../components/modals/DeleteConfirmationModal';
|
||||
import { DuplicateDatasetModal } from '../../../components/modals/DuplicateDatasetModal';
|
||||
import { Toast } from '../../../components/core/Toast';
|
||||
import { createTestDataset } from '../../../helpers/api/dataset.factories';
|
||||
import {
|
||||
apiDeleteDataset,
|
||||
apiGetDataset,
|
||||
getDatasetByName,
|
||||
} from '../../../helpers/api/dataset';
|
||||
import { apiDeleteDatabase } from '../../../helpers/api/database';
|
||||
|
||||
test.describe('Dataset List', () => {
|
||||
let datasetListPage: DatasetListPage;
|
||||
let explorePage: ExplorePage;
|
||||
let testResources: { datasetIds: number[]; dbId?: number } = {
|
||||
datasetIds: [],
|
||||
};
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
datasetListPage = new DatasetListPage(page);
|
||||
explorePage = new ExplorePage(page);
|
||||
testResources = { datasetIds: [] }; // Reset for each test
|
||||
|
||||
// Navigate to dataset list page
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
});
|
||||
|
||||
test.afterEach(async ({ page }) => {
|
||||
// Cleanup any resources created during the test
|
||||
await cleanupTestAssets(page, testResources);
|
||||
});
|
||||
|
||||
function cleanupTestAssets(
|
||||
page: Page,
|
||||
resources: { datasetIds: number[]; dbId?: number },
|
||||
) {
|
||||
const promises = [];
|
||||
|
||||
// Delete all datasets
|
||||
for (const datasetId of resources.datasetIds) {
|
||||
promises.push(
|
||||
apiDeleteDataset(page, datasetId, {
|
||||
failOnStatusCode: false,
|
||||
}).catch(() => {}),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete database if exists
|
||||
if (resources.dbId) {
|
||||
promises.push(
|
||||
apiDeleteDatabase(page, resources.dbId, {
|
||||
failOnStatusCode: false,
|
||||
}).catch(() => {}),
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
test('should navigate to Explore when dataset name is clicked', async ({
|
||||
page,
|
||||
}) => {
|
||||
// Create test dataset (hermetic - no dependency on sample data)
|
||||
const datasetName = `test_nav_${Date.now()}`;
|
||||
const result = await createTestDataset(page, datasetName);
|
||||
testResources = { datasetIds: [result.datasetId], dbId: result.dbId };
|
||||
|
||||
// Refresh page to see new dataset
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list (uses page object + Playwright auto-wait)
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
|
||||
// Click on dataset name to navigate to Explore
|
||||
await datasetListPage.clickDatasetName(datasetName);
|
||||
|
||||
// Wait for Explore page to load (validates URL + datasource control)
|
||||
await explorePage.waitForPageLoad();
|
||||
|
||||
// Verify correct dataset is loaded in datasource control
|
||||
const loadedDatasetName = await explorePage.getDatasetName();
|
||||
expect(loadedDatasetName).toContain(datasetName);
|
||||
|
||||
// Verify visualization switcher shows default viz type (indicates full page load)
|
||||
await expect(explorePage.getVizSwitcher()).toBeVisible();
|
||||
await expect(explorePage.getVizSwitcher()).toContainText('Table');
|
||||
});
|
||||
|
||||
test('should delete a dataset with confirmation', async ({ page }) => {
|
||||
// Create test dataset (hermetic - creates own test data)
|
||||
const datasetName = `test_delete_${Date.now()}`;
|
||||
const result = await createTestDataset(page, datasetName);
|
||||
testResources = { datasetIds: [result.datasetId], dbId: result.dbId };
|
||||
|
||||
// Refresh page to see new dataset
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
|
||||
|
||||
// Click delete action button
|
||||
await datasetListPage.clickDeleteAction(datasetName);
|
||||
|
||||
// Delete confirmation modal should appear
|
||||
const deleteModal = new DeleteConfirmationModal(page);
|
||||
await deleteModal.waitForVisible();
|
||||
|
||||
// Type "DELETE" to confirm
|
||||
await deleteModal.fillConfirmationInput('DELETE');
|
||||
|
||||
// Click the Delete button
|
||||
await deleteModal.clickDelete();
|
||||
|
||||
// Modal should close
|
||||
await deleteModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears with correct message
|
||||
const toast = new Toast(page);
|
||||
const successToast = toast.getSuccess();
|
||||
await expect(successToast).toBeVisible();
|
||||
await expect(toast.getMessage()).toContainText('Deleted');
|
||||
|
||||
// Verify dataset is removed from list
|
||||
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('should duplicate a dataset with new name', async ({ page }) => {
|
||||
// Use virtual example dataset (members_channels_2)
|
||||
const originalName = 'members_channels_2';
|
||||
const duplicateName = `duplicate_${originalName}_${Date.now()}`;
|
||||
|
||||
// Get the dataset by name (ID varies by environment)
|
||||
const original = await getDatasetByName(page, originalName);
|
||||
expect(original).not.toBeNull();
|
||||
expect(original!.id).toBeGreaterThan(0);
|
||||
|
||||
// Verify original dataset is visible in list
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
|
||||
// Set up response intercept to capture duplicate dataset ID
|
||||
const duplicateResponsePromise = page.waitForResponse(
|
||||
response =>
|
||||
response.url().includes('/dataset/duplicate') &&
|
||||
response.status() === 200,
|
||||
);
|
||||
|
||||
// Click duplicate action button
|
||||
await datasetListPage.clickDuplicateAction(originalName);
|
||||
|
||||
// Duplicate modal should appear
|
||||
const duplicateModal = new DuplicateDatasetModal(page);
|
||||
await duplicateModal.waitForVisible();
|
||||
|
||||
// Fill in new dataset name
|
||||
await duplicateModal.fillDatasetName(duplicateName);
|
||||
|
||||
// Click the Duplicate button
|
||||
await duplicateModal.clickDuplicate();
|
||||
|
||||
// Get the duplicate dataset ID from response
|
||||
const duplicateResponse = await duplicateResponsePromise;
|
||||
const duplicateData = await duplicateResponse.json();
|
||||
const duplicateId = duplicateData.id;
|
||||
|
||||
// Track duplicate for cleanup (original is example data, don't delete it)
|
||||
testResources = { datasetIds: [duplicateId] };
|
||||
|
||||
// Modal should close
|
||||
await duplicateModal.waitForHidden();
|
||||
|
||||
// Verify success toast appears
|
||||
const toast = new Toast(page);
|
||||
const successToast = toast.getSuccess();
|
||||
await expect(successToast).toBeVisible();
|
||||
|
||||
// Refresh to see the duplicated dataset
|
||||
await datasetListPage.goto();
|
||||
await datasetListPage.waitForTableLoad();
|
||||
|
||||
// Verify both datasets exist in list
|
||||
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
|
||||
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
|
||||
|
||||
// API Verification: Compare original and duplicate datasets
|
||||
const duplicateResponseData = await apiGetDataset(page, duplicateId);
|
||||
const duplicateDataFull = await duplicateResponseData.json();
|
||||
|
||||
// Verify key properties were copied correctly (original data already fetched)
|
||||
expect(duplicateDataFull.result.sql).toBe(original!.data.sql);
|
||||
expect(duplicateDataFull.result.database.id).toBe(
|
||||
original!.data.database.id,
|
||||
);
|
||||
expect(duplicateDataFull.result.schema).toBe(original!.data.schema);
|
||||
// Name should be different (the duplicate name)
|
||||
expect(duplicateDataFull.result.table_name).toBe(duplicateName);
|
||||
});
|
||||
});
|
||||
47
superset-frontend/playwright/utils/constants.ts
Normal file
47
superset-frontend/playwright/utils/constants.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Timeout constants for Playwright tests.
|
||||
* Only define timeouts that differ from Playwright defaults or are semantically important.
|
||||
*
|
||||
* Default Playwright timeouts (from playwright.config.ts):
|
||||
* - Test timeout: 30000ms (30s)
|
||||
* - Expect timeout: 8000ms (8s)
|
||||
*
|
||||
* Use these constants instead of magic numbers for better maintainability.
|
||||
*/
|
||||
|
||||
export const TIMEOUT = {
|
||||
/**
|
||||
* Page navigation and load timeouts
|
||||
*/
|
||||
PAGE_LOAD: 10000, // 10s for page transitions (login → welcome, dataset → explore)
|
||||
|
||||
/**
|
||||
* Form and UI element load timeouts
|
||||
*/
|
||||
FORM_LOAD: 5000, // 5s for forms to become visible (login form, modals)
|
||||
|
||||
/**
|
||||
* API polling intervals
|
||||
*/
|
||||
API_POLL_INTERVAL: 100, // 100ms between API polling attempts
|
||||
API_POLL_TIMEOUT: 5000, // 5s total timeout for API state changes
|
||||
} as const;
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
export const URL = {
|
||||
DATASET_LIST: 'tablemodelview/list',
|
||||
LOGIN: 'login/',
|
||||
WELCOME: 'superset/welcome/',
|
||||
} as const;
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -101,35 +101,36 @@ describe('queryObject conversion', () => {
|
||||
|
||||
it('should convert queryObject', () => {
|
||||
const { queries } = buildQuery({ ...formData, x_axis: 'time_column' });
|
||||
expect(queries[0]).toMatchObject({
|
||||
granularity: 'time_column',
|
||||
time_range: '1 year ago : 2013',
|
||||
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
|
||||
columns: [
|
||||
{
|
||||
columnType: 'BASE_AXIS',
|
||||
expressionType: 'SQL',
|
||||
label: 'time_column',
|
||||
sqlExpression: 'time_column',
|
||||
timeGrain: 'P1Y',
|
||||
isColumnReference: true,
|
||||
},
|
||||
'col1',
|
||||
],
|
||||
series_columns: ['col1'],
|
||||
metrics: ['count(*)'],
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: { 'count(*)': { operator: 'mean' } },
|
||||
columns: ['col1'],
|
||||
drop_missing_columns: true,
|
||||
index: ['time_column'],
|
||||
expect(queries[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
granularity: 'time_column',
|
||||
time_range: '1 year ago : 2013',
|
||||
extras: { having: '', where: '', time_grain_sqla: 'P1Y' },
|
||||
columns: [
|
||||
{
|
||||
columnType: 'BASE_AXIS',
|
||||
expressionType: 'SQL',
|
||||
label: 'time_column',
|
||||
sqlExpression: 'time_column',
|
||||
timeGrain: 'P1Y',
|
||||
},
|
||||
},
|
||||
{ operation: 'flatten' },
|
||||
],
|
||||
});
|
||||
'col1',
|
||||
],
|
||||
series_columns: ['col1'],
|
||||
metrics: ['count(*)'],
|
||||
post_processing: [
|
||||
{
|
||||
operation: 'pivot',
|
||||
options: {
|
||||
aggregates: { 'count(*)': { operator: 'mean' } },
|
||||
columns: ['col1'],
|
||||
drop_missing_columns: true,
|
||||
index: ['time_column'],
|
||||
},
|
||||
},
|
||||
{ operation: 'flatten' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -139,31 +139,6 @@ function cellWidth({
|
||||
return perc2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a column identifier for use in HTML id attributes and CSS selectors.
|
||||
* Replaces characters that are invalid in CSS selectors with safe alternatives.
|
||||
*
|
||||
* Note: The returned value should be prefixed with a string (e.g., "header-")
|
||||
* to ensure it forms a valid HTML ID (IDs cannot start with a digit).
|
||||
*
|
||||
* Exported for testing.
|
||||
*/
|
||||
export function sanitizeHeaderId(columnId: string): string {
|
||||
return (
|
||||
columnId
|
||||
// Semantic replacements first: preserve meaning in IDs for readability
|
||||
// (e.g., '%pct_nice' → 'percentpct_nice' instead of '_pct_nice')
|
||||
.replace(/%/g, 'percent')
|
||||
.replace(/#/g, 'hash')
|
||||
.replace(/△/g, 'delta')
|
||||
// Generic sanitization for remaining special characters
|
||||
.replace(/\s+/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_+/g, '_') // Collapse consecutive underscores
|
||||
.replace(/^_+|_+$/g, '') // Trim leading/trailing underscores
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cell left margin (offset) calculation for horizontal bar chart elements
|
||||
* when alignPositiveNegative is not set
|
||||
@@ -869,9 +844,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
}
|
||||
|
||||
// Cache sanitized header ID to avoid recomputing it multiple times
|
||||
const headerId = sanitizeHeaderId(column.originalLabel ?? column.key);
|
||||
|
||||
return {
|
||||
id: String(i), // to allow duplicate column keys
|
||||
// must use custom accessor to allow `.` in column names
|
||||
@@ -997,7 +969,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
}
|
||||
|
||||
const cellProps = {
|
||||
'aria-labelledby': `header-${headerId}`,
|
||||
'aria-labelledby': `header-${column.key}`,
|
||||
role: 'cell',
|
||||
// show raw number in title in case of numeric values
|
||||
title: typeof value === 'number' ? String(value) : undefined,
|
||||
@@ -1084,7 +1056,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
},
|
||||
Header: ({ column: col, onClick, style, onDragStart, onDrop }) => (
|
||||
<th
|
||||
id={`header-${headerId}`}
|
||||
id={`header-${column.originalLabel}`}
|
||||
title={t('Shift + Click to sort by multiple columns')}
|
||||
className={[className, col.isSorted ? 'is-sorted' : ''].join(' ')}
|
||||
style={{
|
||||
|
||||
@@ -18,120 +18,15 @@
|
||||
*/
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@superset-ui/core/spec';
|
||||
import { cloneDeep } from 'lodash';
|
||||
import TableChart, { sanitizeHeaderId } from '../src/TableChart';
|
||||
import TableChart from '../src/TableChart';
|
||||
import transformProps from '../src/transformProps';
|
||||
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');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should sanitize hash/pound sign', () => {
|
||||
expect(sanitizeHeaderId('# metric_1')).toBe('hash_metric_1');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should sanitize delta symbol', () => {
|
||||
expect(sanitizeHeaderId('△ delta')).toBe('delta_delta');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should replace spaces with underscores', () => {
|
||||
expect(sanitizeHeaderId('Main metric_1')).toBe('Main_metric_1');
|
||||
expect(sanitizeHeaderId('multiple spaces')).toBe('multiple_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle multiple special characters', () => {
|
||||
expect(sanitizeHeaderId('% #△ test')).toBe('percent_hashdelta_test');
|
||||
expect(sanitizeHeaderId('% # △ test')).toBe('percent_hash_delta_test');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should preserve alphanumeric, underscore, and hyphen', () => {
|
||||
expect(sanitizeHeaderId('valid-name_123')).toBe('valid-name_123');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should replace other special characters with underscore', () => {
|
||||
expect(sanitizeHeaderId('col@name!test')).toBe('col_name_test');
|
||||
expect(sanitizeHeaderId('test.column')).toBe('test_column');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle edge cases', () => {
|
||||
expect(sanitizeHeaderId('')).toBe('');
|
||||
expect(sanitizeHeaderId('simple')).toBe('simple');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should collapse consecutive underscores', () => {
|
||||
expect(sanitizeHeaderId('test @@ space')).toBe('test_space');
|
||||
expect(sanitizeHeaderId('col___name')).toBe('col_name');
|
||||
expect(sanitizeHeaderId('a b c')).toBe('a_b_c');
|
||||
expect(sanitizeHeaderId('test@@name')).toBe('test_name');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove leading underscores', () => {
|
||||
expect(sanitizeHeaderId('@col')).toBe('col');
|
||||
expect(sanitizeHeaderId('!revenue')).toBe('revenue');
|
||||
expect(sanitizeHeaderId('@@test')).toBe('test');
|
||||
expect(sanitizeHeaderId(' leading_spaces')).toBe('leading_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove trailing underscores', () => {
|
||||
expect(sanitizeHeaderId('col@')).toBe('col');
|
||||
expect(sanitizeHeaderId('revenue!')).toBe('revenue');
|
||||
expect(sanitizeHeaderId('test@@')).toBe('test');
|
||||
expect(sanitizeHeaderId('trailing_spaces ')).toBe('trailing_spaces');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should remove leading and trailing underscores', () => {
|
||||
expect(sanitizeHeaderId('@col@')).toBe('col');
|
||||
expect(sanitizeHeaderId('!test!')).toBe('test');
|
||||
expect(sanitizeHeaderId(' spaced ')).toBe('spaced');
|
||||
expect(sanitizeHeaderId('@@multiple@@')).toBe('multiple');
|
||||
});
|
||||
|
||||
test('sanitizeHeaderId should handle inputs with only special characters', () => {
|
||||
expect(sanitizeHeaderId('@')).toBe('');
|
||||
expect(sanitizeHeaderId('@@')).toBe('');
|
||||
expect(sanitizeHeaderId(' ')).toBe('');
|
||||
expect(sanitizeHeaderId('!@$')).toBe('');
|
||||
expect(sanitizeHeaderId('!@#$')).toBe('hash'); // # is replaced with 'hash' (semantic replacement)
|
||||
// Semantic replacements produce readable output even when alone
|
||||
expect(sanitizeHeaderId('%')).toBe('percent');
|
||||
expect(sanitizeHeaderId('#')).toBe('hash');
|
||||
expect(sanitizeHeaderId('△')).toBe('delta');
|
||||
expect(sanitizeHeaderId('% # △')).toBe('percent_hash_delta');
|
||||
});
|
||||
|
||||
describe('plugin-chart-table', () => {
|
||||
describe('transformProps', () => {
|
||||
test('should parse pageLength to pageSize', () => {
|
||||
it('should parse pageLength to pageSize', () => {
|
||||
expect(transformProps(testData.basic).pageSize).toBe(20);
|
||||
expect(
|
||||
transformProps({
|
||||
@@ -147,13 +42,13 @@ describe('plugin-chart-table', () => {
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
test('should memoize data records', () => {
|
||||
it('should memoize data records', () => {
|
||||
expect(transformProps(testData.basic).data).toBe(
|
||||
transformProps(testData.basic).data,
|
||||
);
|
||||
});
|
||||
|
||||
test('should memoize columns meta', () => {
|
||||
it('should memoize columns meta', () => {
|
||||
expect(transformProps(testData.basic).columns).toBe(
|
||||
transformProps({
|
||||
...testData.basic,
|
||||
@@ -162,14 +57,14 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('should format timestamp', () => {
|
||||
it('should format timestamp', () => {
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
const parsedDate = transformProps(testData.basic).data[0]
|
||||
.__timestamp as DateWithFormatter;
|
||||
expect(String(parsedDate)).toBe('2020-01-01 12:34:56');
|
||||
expect(parsedDate.getTime()).toBe(1577882096000);
|
||||
});
|
||||
test('should process comparison columns when time_compare and comparison_type are set', () => {
|
||||
it('should process comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
@@ -191,7 +86,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(comparisonColumns.some(col => col.label === '%')).toBe(true);
|
||||
});
|
||||
|
||||
test('should not process comparison columns when time_compare is empty', () => {
|
||||
it('should not process comparison columns when time_compare is empty', () => {
|
||||
const propsWithoutTimeCompare = {
|
||||
...testData.comparison,
|
||||
rawFormData: {
|
||||
@@ -214,7 +109,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(comparisonColumns.length).toBe(0);
|
||||
});
|
||||
|
||||
test('should correctly apply column configuration for comparison columns', () => {
|
||||
it('should correctly apply column configuration for comparison columns', () => {
|
||||
const transformedProps = transformProps(testData.comparisonWithConfig);
|
||||
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
@@ -252,7 +147,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(percentMetricConfig?.config).toEqual({ d3NumberFormat: '.3f' });
|
||||
});
|
||||
|
||||
test('should correctly format comparison columns using getComparisonColFormatter', () => {
|
||||
it('should correctly format comparison columns using getComparisonColFormatter', () => {
|
||||
const transformedProps = transformProps(testData.comparisonWithConfig);
|
||||
const comparisonColumns = transformedProps.columns.filter(
|
||||
col =>
|
||||
@@ -283,7 +178,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(formattedPercentMetric).toBe('0.123');
|
||||
});
|
||||
|
||||
test('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
|
||||
it('should set originalLabel for comparison columns when time_compare and comparison_type are set', () => {
|
||||
const transformedProps = transformProps(testData.comparison);
|
||||
|
||||
// Check if comparison columns are processed
|
||||
@@ -370,7 +265,7 @@ describe('plugin-chart-table', () => {
|
||||
});
|
||||
|
||||
describe('TableChart', () => {
|
||||
test('render basic data', () => {
|
||||
it('render basic data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.basic)} sticky={false} />,
|
||||
);
|
||||
@@ -389,9 +284,12 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[8]).toHaveTextContent('N/A');
|
||||
});
|
||||
|
||||
test('render advanced data', () => {
|
||||
it('render advanced data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />,
|
||||
<>
|
||||
<TableChart {...transformProps(testData.advanced)} sticky={false} />
|
||||
,
|
||||
</>,
|
||||
);
|
||||
const secondColumnHeader = screen.getByText('Sum of Num');
|
||||
expect(secondColumnHeader).toBeInTheDocument();
|
||||
@@ -406,7 +304,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[4]).toHaveTextContent('2.47k');
|
||||
});
|
||||
|
||||
test('render advanced data with currencies', () => {
|
||||
it('render advanced data with currencies', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -426,7 +324,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[4]).toHaveTextContent('$ 2.47k');
|
||||
});
|
||||
|
||||
test('render data with a bigint value in a raw record mode', () => {
|
||||
it('render data with a bigint value in a raw record mode', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -447,7 +345,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[3]).toHaveTextContent('1234567890123456789');
|
||||
});
|
||||
|
||||
test('render raw data', () => {
|
||||
it('render raw data', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
@@ -464,7 +362,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[1]).toHaveTextContent('0');
|
||||
});
|
||||
|
||||
test('render raw data with currencies', () => {
|
||||
it('render raw data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
@@ -489,7 +387,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[2]).toHaveTextContent('$ 0');
|
||||
});
|
||||
|
||||
test('render small formatted data with currencies', () => {
|
||||
it('render small formatted data with currencies', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
@@ -531,14 +429,14 @@ describe('plugin-chart-table', () => {
|
||||
expect(cells[2]).toHaveTextContent('$ 0.61');
|
||||
});
|
||||
|
||||
test('render empty data', () => {
|
||||
it('render empty data', () => {
|
||||
render(
|
||||
<TableChart {...transformProps(testData.empty)} sticky={false} />,
|
||||
);
|
||||
expect(screen.getByText('No records found')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('render color with column color formatter', () => {
|
||||
it('render color with column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -568,8 +466,8 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByTitle('2467')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render cell without color', () => {
|
||||
const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]);
|
||||
it('render cell without color', () => {
|
||||
const dataWithEmptyCell = testData.advanced.queriesData[0];
|
||||
dataWithEmptyCell.data.push({
|
||||
__timestamp: null,
|
||||
name: 'Noah',
|
||||
@@ -609,7 +507,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
|
||||
});
|
||||
test('should display original label in grouped headers', () => {
|
||||
it('should display original label in grouped headers', () => {
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
render(<TableChart {...props} sticky={false} />);
|
||||
@@ -624,128 +522,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(hasMetricHeaders).toBe(true);
|
||||
});
|
||||
|
||||
test('should set meaningful header IDs for time-comparison columns', () => {
|
||||
// Test time-comparison columns have proper IDs
|
||||
// Uses originalLabel (e.g., "metric_1") which is sanitized for CSS safety
|
||||
const props = transformProps(testData.comparison);
|
||||
|
||||
render(<TableChart {...props} sticky={false} />);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
|
||||
// All headers should have IDs
|
||||
const headersWithIds = headers.filter(header => header.id);
|
||||
expect(headersWithIds.length).toBeGreaterThan(0);
|
||||
|
||||
// None should have "header-undefined"
|
||||
const undefinedHeaders = headersWithIds.filter(header =>
|
||||
header.id.includes('undefined'),
|
||||
);
|
||||
expect(undefinedHeaders).toHaveLength(0);
|
||||
|
||||
// Should have IDs based on sanitized originalLabel (e.g., "metric_1")
|
||||
const hasMetricHeaders = headersWithIds.some(
|
||||
header =>
|
||||
header.id.includes('metric_1') || header.id.includes('metric_2'),
|
||||
);
|
||||
expect(hasMetricHeaders).toBe(true);
|
||||
|
||||
// CRITICAL: Verify sanitization - no spaces or special chars in any header ID
|
||||
headersWithIds.forEach(header => {
|
||||
// IDs must not contain spaces (would break CSS selectors and ARIA)
|
||||
expect(header.id).not.toMatch(/\s/);
|
||||
// IDs must not contain special chars like %, #, △
|
||||
expect(header.id).not.toMatch(/[%#△]/);
|
||||
// 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);
|
||||
});
|
||||
|
||||
test('should set meaningful header IDs for regular table columns', () => {
|
||||
// Test regular (non-time-comparison) columns have proper IDs
|
||||
// Uses fallback to column.key since originalLabel is undefined
|
||||
const props = transformProps(testData.advanced);
|
||||
|
||||
const { container } = render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
|
||||
// Test 1: "name" column (regular string column)
|
||||
const nameHeader = headers.find(header =>
|
||||
header.textContent?.includes('name'),
|
||||
);
|
||||
expect(nameHeader).toBeDefined();
|
||||
expect(nameHeader?.id).toBe('header-name'); // Falls back to column.key
|
||||
|
||||
// Verify cells reference this header correctly
|
||||
const nameCells = container.querySelectorAll(
|
||||
'td[aria-labelledby="header-name"]',
|
||||
);
|
||||
expect(nameCells.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 2: "sum__num" column (metric with verbose map "Sum of Num")
|
||||
const sumHeader = headers.find(header =>
|
||||
header.textContent?.includes('Sum of Num'),
|
||||
);
|
||||
expect(sumHeader).toBeDefined();
|
||||
expect(sumHeader?.id).toBe('header-sum_num'); // Falls back to column.key, consecutive underscores collapsed
|
||||
|
||||
// Verify cells reference this header correctly
|
||||
const sumCells = container.querySelectorAll(
|
||||
'td[aria-labelledby="header-sum_num"]',
|
||||
);
|
||||
expect(sumCells.length).toBeGreaterThan(0);
|
||||
|
||||
// Test 3: Verify NO headers have "undefined" in their ID
|
||||
const undefinedHeaders = headers.filter(header =>
|
||||
header.id?.includes('undefined'),
|
||||
);
|
||||
expect(undefinedHeaders).toHaveLength(0);
|
||||
|
||||
// Test 4: Verify ALL headers have proper IDs (no missing IDs)
|
||||
const headersWithIds = headers.filter(header => header.id);
|
||||
expect(headersWithIds.length).toBe(headers.length);
|
||||
|
||||
// Test 5: Verify ALL header IDs are properly sanitized
|
||||
headersWithIds.forEach(header => {
|
||||
// IDs must not contain spaces
|
||||
expect(header.id).not.toMatch(/\s/);
|
||||
// IDs must not contain special chars like % (from %pct_nice column)
|
||||
expect(header.id).not.toMatch(/[%#△]/);
|
||||
// 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} />,
|
||||
}),
|
||||
);
|
||||
|
||||
expectValidAriaLabels(container);
|
||||
});
|
||||
|
||||
test('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
it('render cell bars properly, and only when it is toggled on in both regular and percent metrics', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: { ...testData.raw.rawFormData },
|
||||
@@ -795,7 +572,7 @@ describe('plugin-chart-table', () => {
|
||||
cells = document.querySelectorAll('td');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter(operator begins with)', () => {
|
||||
it('render color with string column color formatter(operator begins with)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -827,7 +604,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator ends with)', () => {
|
||||
it('render color with string column color formatter (operator ends with)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -856,7 +633,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator containing)', () => {
|
||||
it('render color with string column color formatter (operator containing)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -885,7 +662,7 @@ describe('plugin-chart-table', () => {
|
||||
expect(getComputedStyle(screen.getByText('Joe')).background).toBe('');
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator not containing)', () => {
|
||||
it('render color with string column color formatter (operator not containing)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -916,7 +693,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator =)', () => {
|
||||
it('render color with string column color formatter (operator =)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -947,7 +724,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with string column color formatter (operator None)', () => {
|
||||
it('render color with string column color formatter (operator None)', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -980,7 +757,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('render color with column color formatter to entire row', () => {
|
||||
it('render color with column color formatter to entire row', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -1016,7 +793,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('display text color using column color formatter', () => {
|
||||
it('display text color using column color formatter', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
@@ -1049,7 +826,7 @@ describe('plugin-chart-table', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('display text color using column color formatter for entire row', () => {
|
||||
it('display text color using column color formatter for entire row', () => {
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: (
|
||||
|
||||
@@ -950,13 +950,7 @@ export function mergeTable(table, query, prepend) {
|
||||
return { type: MERGE_TABLE, table, query, prepend };
|
||||
}
|
||||
|
||||
export function addTable(
|
||||
queryEditor,
|
||||
tableName,
|
||||
catalogName,
|
||||
schemaName,
|
||||
expanded = true,
|
||||
) {
|
||||
export function addTable(queryEditor, tableName, catalogName, schemaName) {
|
||||
return function (dispatch, getState) {
|
||||
const { dbId } = getUpToDateQuery(getState(), queryEditor, queryEditor.id);
|
||||
const table = {
|
||||
@@ -970,7 +964,7 @@ export function addTable(
|
||||
mergeTable({
|
||||
...table,
|
||||
id: nanoid(11),
|
||||
expanded,
|
||||
expanded: true,
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user