Compare commits
2 Commits
chore/fc-0
...
fix/mcp-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16d136ef8d | ||
|
|
c78658d852 |
2
.github/workflows/ephemeral-env-pr-close.yml
vendored
@@ -58,7 +58,7 @@ jobs:
|
||||
- name: Login to Amazon ECR
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
|
||||
- name: Delete ECR image tag
|
||||
if: steps.describe-services.outputs.active == 'true'
|
||||
|
||||
4
.github/workflows/ephemeral-env.yml
vendored
@@ -199,7 +199,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
|
||||
- name: Load, tag and push image to ECR
|
||||
id: push-image
|
||||
@@ -235,7 +235,7 @@ jobs:
|
||||
|
||||
- name: Login to Amazon ECR
|
||||
id: login-ecr
|
||||
uses: aws-actions/amazon-ecr-login@fa648b43de3d4d023bcb3f89ed6940096949c419 # v2
|
||||
uses: aws-actions/amazon-ecr-login@19d944daaa35f0fa1d3f7f8af1d3f2e5de25c5b7 # v2
|
||||
|
||||
- name: Check target image exists in ECR
|
||||
id: check-image
|
||||
|
||||
10
.github/workflows/superset-docs-deploy.yml
vendored
@@ -17,16 +17,6 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -56,33 +56,8 @@ def verify_sha512(filename: str) -> str:
|
||||
# Part 2: Verify RSA key - this is the same as running `gpg --verify {release}.asc {release}` and comparing the RSA key and email address against the KEYS file # noqa: E501
|
||||
|
||||
|
||||
KEYS_URL = "https://downloads.apache.org/superset/KEYS"
|
||||
|
||||
|
||||
def ensure_keys_imported() -> None:
|
||||
"""Import the Apache Superset KEYS file into the local GPG keyring.
|
||||
|
||||
Without this, `gpg --verify` returns "No public key" and the signature
|
||||
cannot actually be verified — only the key ID in the signature metadata
|
||||
is visible.
|
||||
"""
|
||||
try:
|
||||
keys = requests.get(KEYS_URL, timeout=30)
|
||||
except requests.RequestException as exc:
|
||||
print(f"Warning: could not fetch KEYS file for import: {exc}")
|
||||
return
|
||||
if keys.status_code != 200:
|
||||
print(f"Warning: could not fetch KEYS file (HTTP {keys.status_code})")
|
||||
return
|
||||
subprocess.run( # noqa: S603
|
||||
["gpg", "--import"], # noqa: S607
|
||||
input=keys.content,
|
||||
capture_output=True,
|
||||
)
|
||||
|
||||
|
||||
def get_gpg_info(filename: str) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Run the GPG verify command and extract RSA/EDDSA key and email address."""
|
||||
"""Run the GPG verify command and extract RSA key and email address."""
|
||||
asc_filename = filename + ".asc"
|
||||
result = subprocess.run( # noqa: S603
|
||||
["gpg", "--verify", asc_filename, filename], # noqa: S607
|
||||
@@ -90,50 +65,25 @@ def get_gpg_info(filename: str) -> tuple[Optional[str], Optional[str]]:
|
||||
)
|
||||
output = result.stderr.decode()
|
||||
|
||||
# If no public key was available, import KEYS and retry so that
|
||||
# `Good signature from "Name <email>"` appears in the output.
|
||||
if "No public key" in output:
|
||||
ensure_keys_imported()
|
||||
result = subprocess.run( # noqa: S603
|
||||
["gpg", "--verify", asc_filename, filename], # noqa: S607
|
||||
capture_output=True, # noqa: S607
|
||||
)
|
||||
output = result.stderr.decode()
|
||||
|
||||
rsa_key = re.search(r"RSA key ([0-9A-F]+)", output)
|
||||
eddsa_key = re.search(r"EDDSA key ([0-9A-F]+)", output)
|
||||
|
||||
# Try multiple patterns — `Good signature from` is the most reliable
|
||||
# source of the email; `issuer` is a fallback for older gpg output.
|
||||
email_patterns = (
|
||||
r'Good signature from ".*?<([^>]+)>"',
|
||||
r'aka ".*?<([^>]+)>"',
|
||||
r'issuer "([^"]+)"',
|
||||
)
|
||||
email_result: Optional[str] = None
|
||||
for pattern in email_patterns:
|
||||
match = re.search(pattern, output)
|
||||
if match:
|
||||
email_result = match.group(1)
|
||||
break
|
||||
email = re.search(r'issuer "([^"]+)"', output)
|
||||
|
||||
rsa_key_result = rsa_key.group(1) if rsa_key else None
|
||||
eddsa_key_result = eddsa_key.group(1) if eddsa_key else None
|
||||
email_result = email.group(1) if email else None
|
||||
|
||||
key_result = rsa_key_result or eddsa_key_result
|
||||
|
||||
# Debugging:
|
||||
if key_result:
|
||||
print("RSA or EDDSA Key found")
|
||||
else:
|
||||
print("Warning: No RSA or EDDSA key found in GPG verification output.")
|
||||
if email_result:
|
||||
print(f"Email found: {email_result}")
|
||||
print("email found")
|
||||
else:
|
||||
print("Warning: No email address found in GPG verification output.")
|
||||
if "No public key" in output:
|
||||
print(
|
||||
"Hint: public key is not in your keyring. Import it with:\n"
|
||||
f" curl -s {KEYS_URL} | gpg --import"
|
||||
)
|
||||
|
||||
return key_result, email_result
|
||||
|
||||
|
||||
@@ -81,87 +81,6 @@ SLACK_CACHE_TIMEOUT = int(timedelta(days=2).total_seconds())
|
||||
SLACK_API_RATE_LIMIT_RETRY_COUNT = 5
|
||||
```
|
||||
|
||||
### Webhook integration
|
||||
|
||||
Superset can send alert and report notifications to any HTTP endpoint — useful for chat platforms, incident management tools, or custom automation.
|
||||
|
||||
#### Enabling Webhooks
|
||||
|
||||
Enable the feature flag in `superset_config.py`:
|
||||
|
||||
```python
|
||||
FEATURE_FLAGS = {
|
||||
"ALERT_REPORTS": True,
|
||||
"ALERT_REPORT_WEBHOOK": True,
|
||||
}
|
||||
```
|
||||
|
||||
#### Configuring a Webhook Recipient
|
||||
|
||||
When creating or editing an alert or report, select **Webhook** as the notification method and enter your endpoint URL.
|
||||
|
||||
#### Payload Format
|
||||
|
||||
Superset sends an HTTP POST with `Content-Type: application/json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "My Alert",
|
||||
"header": {
|
||||
"notification_format": "JSON",
|
||||
"notification_type": "Alert",
|
||||
"notification_source": "Alert",
|
||||
"chart_id": 42,
|
||||
"dashboard_id": null
|
||||
},
|
||||
"text": "Alert condition met: value exceeded threshold",
|
||||
"description": "Monthly revenue dropped below target",
|
||||
"url": "https://your-superset-host/superset/dashboard/1/"
|
||||
}
|
||||
```
|
||||
|
||||
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
|
||||
```
|
||||
POST /webhook HTTP/1.1
|
||||
Content-Type: multipart/form-data; boundary=...
|
||||
|
||||
--...
|
||||
Content-Disposition: form-data; name="name"
|
||||
|
||||
My Alert
|
||||
--...
|
||||
Content-Disposition: form-data; name="header"
|
||||
|
||||
{"notification_format": "JSON", "notification_type": "Alert", ...}
|
||||
--...
|
||||
Content-Disposition: form-data; name="text"
|
||||
|
||||
Alert condition met: value exceeded threshold
|
||||
--...
|
||||
Content-Disposition: form-data; name="files"; filename="report.csv"
|
||||
Content-Type: text/csv
|
||||
|
||||
<file bytes>
|
||||
--...
|
||||
```
|
||||
|
||||
Webhook consumers should branch on `Content-Type`: parse the body as JSON when `application/json`, or read the individual form fields (decoding `header` as JSON) when `multipart/form-data`.
|
||||
|
||||
#### HTTPS Enforcement
|
||||
|
||||
To require HTTPS webhook URLs (recommended for production), set:
|
||||
|
||||
```python
|
||||
ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True
|
||||
```
|
||||
|
||||
When enabled, Superset rejects webhook configurations that use `http://` URLs.
|
||||
|
||||
#### Retry Behavior
|
||||
|
||||
Superset automatically retries webhook deliveries on `429 Too Many Requests` and `5xx` server errors using exponential backoff.
|
||||
|
||||
### Kubernetes-specific
|
||||
|
||||
- You must have a `celery beat` pod running. If you're using the chart included in the GitHub repository under [helm/superset](https://github.com/apache/superset/tree/master/helm/superset), you need to put `supersetCeleryBeat.enabled = true` in your values override.
|
||||
|
||||
@@ -472,38 +472,6 @@ FEATURE_FLAGS = {
|
||||
|
||||
A current list of feature flags can be found in the [Feature Flags](/admin-docs/configuration/feature-flags) documentation.
|
||||
|
||||
## Security Configuration
|
||||
|
||||
### HASH_ALGORITHM
|
||||
|
||||
Controls the hashing algorithm used for internal checksums and cache keys (thumbnails, cache keys, etc.). The default is `sha256`, which satisfies environments with stricter compliance requirements (e.g., FedRAMP). Set it to `md5` to retain the legacy behavior from older Superset deployments:
|
||||
|
||||
```python
|
||||
HASH_ALGORITHM = "sha256" # default; set to "md5" for legacy behavior
|
||||
```
|
||||
|
||||
A companion `HASH_ALGORITHM_FALLBACKS` list (default: `["md5"]`) lets UUID lookups fall back to older algorithms, which enables gradual migration without breaking existing entries. Set it to `[]` for strict mode (use only `HASH_ALGORITHM`).
|
||||
|
||||
:::note
|
||||
This setting affects internal Superset operations only, not user passwords or authentication tokens. Changing it in an existing deployment may invalidate cached values but does not require a database migration.
|
||||
:::
|
||||
|
||||
## SQL Lab Query History Pruning
|
||||
|
||||
SQL Lab query history is stored in the metadata database and is **not** pruned by default. To trim older rows, enable the `prune_query` Celery beat task by uncommenting it in `CELERY_BEAT_SCHEDULE` and choosing a retention window:
|
||||
|
||||
```python
|
||||
CELERY_BEAT_SCHEDULE = {
|
||||
"prune_query": {
|
||||
"task": "prune_query",
|
||||
"schedule": crontab(minute=0, hour=0, day_of_month=1),
|
||||
"kwargs": {"retention_period_days": 180},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Adjust `retention_period_days` to control how long query rows are kept. Companion opt-in tasks (`prune_logs`, `prune_tasks`) exist for pruning the logs and tasks tables; see the commented-out examples in `superset/config.py`. Without enabling these tasks, the metadata database will grow unbounded over time.
|
||||
|
||||
:::resources
|
||||
- [Blog: Feature Flags in Apache Superset](https://preset.io/blog/feature-flags-in-apache-superset-and-preset/)
|
||||
:::
|
||||
|
||||
@@ -122,17 +122,6 @@ When `ENABLE_UI_THEME_ADMINISTRATION = True`:
|
||||
3. Administrators can change system themes without restarting Superset
|
||||
4. Configuration file themes serve as fallbacks when no UI themes are set
|
||||
|
||||
### Theme Validation and Fallback
|
||||
|
||||
Superset validates theme JSON when it is saved, either through the UI or via configuration. If a theme contains invalid tokens or an unrecognized structure, Superset logs a warning and falls back to the built-in default theme rather than applying a broken configuration. This prevents a bad theme from rendering the application unusable.
|
||||
|
||||
The fallback order is:
|
||||
1. **UI-configured system theme** (highest priority, if `ENABLE_UI_THEME_ADMINISTRATION = True`)
|
||||
2. **`THEME_DEFAULT` / `THEME_DARK`** from `superset_config.py`
|
||||
3. **Built-in Superset default theme** (always present as a safety net)
|
||||
|
||||
If you see unexpected styling after a config change, check the Superset server logs for theme validation warnings.
|
||||
|
||||
### Copying Themes Between Systems
|
||||
|
||||
To export a theme for use in configuration files or another instance:
|
||||
@@ -154,11 +143,7 @@ Superset supports custom fonts through the theme configuration, allowing you to
|
||||
|
||||
### Default Fonts
|
||||
|
||||
By default, Superset uses **Inter** for UI text and **IBM Plex Mono** for code (SQL editors, JSON fields, and other monospace contexts). Both fonts are bundled with the application via `@fontsource` packages and work offline without any external network calls.
|
||||
|
||||
:::note
|
||||
IBM Plex Mono replaced Fira Code as the default code font in Superset 6.1. If you have an existing theme that explicitly sets `fontFamilyCode: "Fira Code, ..."`, you may want to update it.
|
||||
:::
|
||||
By default, Superset uses Inter and Fira Code fonts which are bundled with the application via `@fontsource` packages. These fonts work offline and require no external network calls.
|
||||
|
||||
### Configuring Custom Fonts
|
||||
|
||||
|
||||
@@ -205,57 +205,6 @@ FAB_ADD_SECURITY_API = True
|
||||
|
||||
Once configured, the documentation for additional "Security" endpoints will be visible in Swagger for you to explore.
|
||||
|
||||
### API Key Authentication
|
||||
|
||||
Superset supports long-lived API keys for service accounts, CI/CD pipelines, and programmatic integrations (including MCP clients).
|
||||
|
||||
#### Enabling API Key Authentication
|
||||
|
||||
API key authentication is **disabled by default**. To turn it on, set the Flask-AppBuilder config value in `superset_config.py` and also enable the matching feature flag so the management UI is exposed:
|
||||
|
||||
```python
|
||||
FAB_API_KEY_ENABLED = True
|
||||
|
||||
FEATURE_FLAGS = {
|
||||
"FAB_API_KEY_ENABLED": True,
|
||||
}
|
||||
```
|
||||
|
||||
The config value registers the `ApiKeyApi` blueprint on the backend; the feature flag controls whether the UI for managing keys appears for the user. See the [Feature Flags](/admin-docs/configuration/feature-flags) documentation for more on feature flag configuration.
|
||||
|
||||
#### Creating an API Key
|
||||
|
||||
Once enabled, each user manages their own keys from their profile page:
|
||||
|
||||
1. Open the user menu (top-right) and click **Info** to navigate to the User Info page
|
||||
2. Expand the **API Keys** section
|
||||
3. Click **+ API Key**
|
||||
4. Enter a name and (optionally) an expiration date
|
||||
5. Copy the generated token — it is shown only once
|
||||
|
||||
Only users with the `can_read` and `can_write` permissions on `ApiKey` (granted by default to Admins) can manage API keys.
|
||||
|
||||
#### Using an API Key
|
||||
|
||||
Pass the key as a Bearer token in the `Authorization` header:
|
||||
|
||||
```
|
||||
Authorization: Bearer <your-api-key>
|
||||
```
|
||||
|
||||
This works for all REST API endpoints and the MCP server. The request is executed with the permissions of the user who created the key.
|
||||
|
||||
#### Use Cases
|
||||
|
||||
- **CI/CD pipelines** — automated chart/dashboard exports and imports
|
||||
- **MCP integrations** — connect AI assistants without interactive login
|
||||
- **External services** — dashboards embedded in other applications
|
||||
- **Service accounts** — long-lived credentials that don't expire with session cookies
|
||||
|
||||
:::caution
|
||||
Store API keys securely. Anyone with a valid key can make requests on behalf of the creating user. Revoke keys promptly if they are compromised by deleting them from the **API Keys** section of your User Info page.
|
||||
:::
|
||||
|
||||
### Customizing Permissions
|
||||
|
||||
The permissions exposed by FAB are very granular and allow for a great level of
|
||||
|
||||
@@ -69,8 +69,8 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.33",
|
||||
"antd": "^6.3.7",
|
||||
"baseline-browser-mapping": "^2.10.29",
|
||||
"caniuse-lite": "^1.0.30001792",
|
||||
"baseline-browser-mapping": "^2.10.27",
|
||||
"caniuse-lite": "^1.0.30001791",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.1.1",
|
||||
@@ -106,7 +106,7 @@
|
||||
"globals": "^17.6.0",
|
||||
"prettier": "^3.8.3",
|
||||
"typescript": "~6.0.3",
|
||||
"typescript-eslint": "^8.59.2",
|
||||
"typescript-eslint": "^8.59.1",
|
||||
"webpack": "^5.106.2"
|
||||
},
|
||||
"browserslist": {
|
||||
|
||||
@@ -141,47 +141,6 @@ def eval_node(node):
|
||||
return "<f-string>"
|
||||
return None
|
||||
|
||||
def static_return_bool(func_node):
|
||||
"""
|
||||
Statically resolve a method's return value to a bool when possible.
|
||||
|
||||
Returns True/False for functions whose body is (effectively) a single
|
||||
\`return True\` / \`return False\` — allowing a leading docstring and
|
||||
ignoring pure-comment/pass statements. Returns None for anything more
|
||||
complex (conditional returns, computed values, no return, etc.).
|
||||
|
||||
Used by \`has_implicit_cancel\` handling: \`diagnose()\` in lib.py calls
|
||||
the method and checks the return value, so an override that explicitly
|
||||
returns False must NOT be treated as enabling query cancelation.
|
||||
"""
|
||||
returns = []
|
||||
other_logic = False
|
||||
docstring_skipped = False
|
||||
for stmt in func_node.body:
|
||||
# Skip docstring (only the FIRST expression statement that is a
|
||||
# string constant — later bare string literals are not docstrings
|
||||
# and should count as non-trivial logic).
|
||||
if (not docstring_skipped
|
||||
and isinstance(stmt, ast.Expr)
|
||||
and isinstance(stmt.value, ast.Constant)
|
||||
and isinstance(stmt.value.value, str)):
|
||||
docstring_skipped = True
|
||||
continue
|
||||
if isinstance(stmt, ast.Pass):
|
||||
continue
|
||||
if isinstance(stmt, ast.Return):
|
||||
returns.append(stmt)
|
||||
continue
|
||||
# Any other statement (if/for/assign/etc.) means control flow is
|
||||
# non-trivial; bail out to be conservative.
|
||||
other_logic = True
|
||||
break
|
||||
if other_logic or len(returns) != 1:
|
||||
return None
|
||||
val = eval_node(returns[0].value)
|
||||
return val if isinstance(val, bool) else None
|
||||
|
||||
|
||||
def deep_merge(base, override):
|
||||
"""Deep merge two dictionaries. Override values take precedence."""
|
||||
if base is None:
|
||||
@@ -227,55 +186,8 @@ if not os.path.isdir(specs_dir):
|
||||
print(json.dumps({"error": f"Directory not found: {specs_dir}", "cwd": os.getcwd()}))
|
||||
sys.exit(1)
|
||||
|
||||
# Capability flag attributes with their defaults from BaseEngineSpec
|
||||
CAP_ATTR_DEFAULTS = {
|
||||
'supports_dynamic_schema': False,
|
||||
'supports_catalog': False,
|
||||
'supports_dynamic_catalog': False,
|
||||
'disable_ssh_tunneling': False,
|
||||
'supports_file_upload': True,
|
||||
'allows_joins': True,
|
||||
'allows_subqueries': True,
|
||||
}
|
||||
|
||||
# Maps source capability attribute -> output field name used in databases.json.
|
||||
# When a cap attr is assigned an unevaluable expression (e.g.
|
||||
# allows_joins = is_feature_enabled("DRUID_JOINS")), the JS layer uses this
|
||||
# mapping to preserve the corresponding field from the previously-generated
|
||||
# JSON rather than silently inheriting an incorrect parent default.
|
||||
CAP_ATTR_TO_OUTPUT_FIELD = {
|
||||
'allows_joins': 'joins',
|
||||
'allows_subqueries': 'subqueries',
|
||||
'supports_dynamic_schema': 'supports_dynamic_schema',
|
||||
'supports_catalog': 'supports_catalog',
|
||||
'supports_dynamic_catalog': 'supports_dynamic_catalog',
|
||||
'disable_ssh_tunneling': 'ssh_tunneling',
|
||||
'supports_file_upload': 'supports_file_upload',
|
||||
}
|
||||
|
||||
# Methods that indicate a capability when overridden by a non-BaseEngineSpec class.
|
||||
# Mirrors the has_custom_method checks in superset/db_engine_specs/lib.py.
|
||||
# cancel_query / has_implicit_cancel -> query_cancelation
|
||||
# (diagnose() checks cancel_query override OR has_implicit_cancel() == True;
|
||||
# base has_implicit_cancel returns False, so overriding it is the static
|
||||
# equivalent of that method returning True. get_cancel_query_id is NOT
|
||||
# part of the diagnose() heuristic and is intentionally excluded.)
|
||||
# estimate_statement_cost / estimate_query_cost -> query_cost_estimation
|
||||
# impersonate_user / update_impersonation_config / get_url_for_impersonation -> user_impersonation
|
||||
# validate_sql -> sql_validation (not used yet; validation is engine-based)
|
||||
CAP_METHODS = {
|
||||
'cancel_query', 'has_implicit_cancel',
|
||||
'estimate_statement_cost', 'estimate_query_cost',
|
||||
'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation',
|
||||
'validate_sql',
|
||||
}
|
||||
|
||||
# Only the literal BaseEngineSpec is excluded from method-override tracking.
|
||||
# Intermediate base classes (e.g. PrestoBaseEngineSpec) do count as overrides.
|
||||
TRUE_BASE_CLASS = 'BaseEngineSpec'
|
||||
|
||||
# First pass: collect all class info (name, bases, metadata, cap_attrs, direct_methods)
|
||||
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str, ...}
|
||||
# First pass: collect all class info (name, bases, metadata)
|
||||
class_info = {} # class_name -> {bases: [], metadata: {}, engine_name: str, filename: str}
|
||||
|
||||
for filename in sorted(os.listdir(specs_dir)):
|
||||
if not filename.endswith('.py') or filename in ('__init__.py', 'lib.py', 'lint_metadata.py'):
|
||||
@@ -306,54 +218,30 @@ for filename in sorted(os.listdir(specs_dir)):
|
||||
|
||||
# Extract class attributes
|
||||
engine_name = None
|
||||
engine_attr = None
|
||||
metadata = None
|
||||
cap_attrs = {} # capability flag attributes defined directly in this class
|
||||
# Cap attrs assigned via expressions we can't statically resolve
|
||||
# (e.g. is_feature_enabled("FLAG")). Tracked so the JS layer can
|
||||
# fall back to the previously-generated databases.json value
|
||||
# rather than inherit a parent default that would be wrong.
|
||||
unresolved_cap_attrs = set()
|
||||
direct_methods = set() # capability methods defined directly in this class
|
||||
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if not isinstance(target, ast.Name):
|
||||
continue
|
||||
if target.id == 'engine_name':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_name = val
|
||||
elif target.id == 'engine':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_attr = val
|
||||
elif target.id == 'metadata':
|
||||
metadata = eval_node(item.value)
|
||||
elif target.id in CAP_ATTR_DEFAULTS:
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, bool):
|
||||
cap_attrs[target.id] = val
|
||||
else:
|
||||
# Unevaluable expression — defer to JS fallback.
|
||||
unresolved_cap_attrs.add(target.id)
|
||||
elif isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
||||
if item.name in CAP_METHODS:
|
||||
# has_implicit_cancel is special: diagnose() uses the
|
||||
# method's RETURN VALUE, not just its presence. If the
|
||||
# override statically returns False, treat it as if
|
||||
# the method weren't overridden so query_cancelation
|
||||
# matches diagnose(). Unresolvable / True / anything
|
||||
# else falls through as an override (conservative).
|
||||
if item.name == 'has_implicit_cancel':
|
||||
if static_return_bool(item) is False:
|
||||
continue
|
||||
direct_methods.add(item.name)
|
||||
if isinstance(target, ast.Name):
|
||||
if target.id == 'engine_name':
|
||||
val = eval_node(item.value)
|
||||
if isinstance(val, str):
|
||||
engine_name = val
|
||||
elif target.id == 'metadata':
|
||||
metadata = eval_node(item.value)
|
||||
|
||||
# Check for engine attribute with non-empty value to distinguish
|
||||
# true base classes from product classes like OceanBaseEngineSpec
|
||||
has_non_empty_engine = engine_attr is not None and bool(engine_attr)
|
||||
has_non_empty_engine = False
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.Assign):
|
||||
for target in item.targets:
|
||||
if isinstance(target, ast.Name) and target.id == 'engine':
|
||||
# Check if engine value is non-empty string
|
||||
if isinstance(item.value, ast.Constant):
|
||||
has_non_empty_engine = bool(item.value.value)
|
||||
break
|
||||
|
||||
# True base classes: end with BaseEngineSpec AND don't define engine
|
||||
# or have empty engine (like PostgresBaseEngineSpec with engine = "")
|
||||
@@ -366,18 +254,13 @@ for filename in sorted(os.listdir(specs_dir)):
|
||||
'bases': base_names,
|
||||
'metadata': metadata,
|
||||
'engine_name': engine_name,
|
||||
'engine': engine_attr,
|
||||
'filename': filename,
|
||||
'is_base_or_mixin': is_true_base,
|
||||
'cap_attrs': cap_attrs,
|
||||
'unresolved_cap_attrs': unresolved_cap_attrs,
|
||||
'direct_methods': direct_methods,
|
||||
}
|
||||
except Exception as e:
|
||||
errors.append(f"{filename}: {str(e)}")
|
||||
|
||||
# Second pass: resolve inheritance and build final metadata + capability flags
|
||||
|
||||
# Second pass: resolve inheritance and build final metadata
|
||||
def get_inherited_metadata(class_name, visited=None):
|
||||
"""Recursively get metadata from parent classes."""
|
||||
if visited is None:
|
||||
@@ -403,64 +286,6 @@ def get_inherited_metadata(class_name, visited=None):
|
||||
|
||||
return inherited
|
||||
|
||||
def get_resolved_caps(class_name, visited=None):
|
||||
"""
|
||||
Resolve capability flags and method overrides with inheritance.
|
||||
|
||||
Returns (attr_values, unresolved, methods):
|
||||
- attr_values: {attr: bool} for attrs where the nearest MRO assignment
|
||||
was a literal bool. Defaults are applied at the call site.
|
||||
- unresolved: attrs where the nearest MRO assignment was an unevaluable
|
||||
expression (e.g. is_feature_enabled("FLAG")). The JS layer falls
|
||||
back to the previously-generated JSON value for these.
|
||||
- methods: capability methods defined directly in some non-base ancestor,
|
||||
matching the has_custom_method() logic in db_engine_specs/lib.py.
|
||||
|
||||
attr_values and unresolved are disjoint — an attr is in at most one.
|
||||
"""
|
||||
if visited is None:
|
||||
visited = set()
|
||||
if class_name in visited:
|
||||
return {}, set(), set()
|
||||
visited.add(class_name)
|
||||
|
||||
info = class_info.get(class_name)
|
||||
if not info:
|
||||
return {}, set(), set()
|
||||
|
||||
attr_values = {}
|
||||
unresolved = set()
|
||||
resolved_methods = set()
|
||||
|
||||
# Collect from parents, iterating right-to-left so leftmost bases win
|
||||
# (matches Python MRO: for class C(A, B), A's attributes take precedence).
|
||||
for base_name in reversed(info['bases']):
|
||||
p_vals, p_unres, p_meth = get_resolved_caps(base_name, visited.copy())
|
||||
# A parent's literal assignments overwrite whatever we inherited so far.
|
||||
for attr, val in p_vals.items():
|
||||
attr_values[attr] = val
|
||||
unresolved.discard(attr)
|
||||
# A parent's unresolved assignments likewise take precedence.
|
||||
for attr in p_unres:
|
||||
unresolved.add(attr)
|
||||
attr_values.pop(attr, None)
|
||||
resolved_methods.update(p_meth)
|
||||
|
||||
# Apply this class's own assignments (override parents).
|
||||
for attr, val in info['cap_attrs'].items():
|
||||
attr_values[attr] = val
|
||||
unresolved.discard(attr)
|
||||
for attr in info['unresolved_cap_attrs']:
|
||||
unresolved.add(attr)
|
||||
attr_values.pop(attr, None)
|
||||
|
||||
# Accumulate method overrides, but skip the literal BaseEngineSpec
|
||||
# (its implementations are stubs; only non-base overrides count).
|
||||
if class_name != TRUE_BASE_CLASS:
|
||||
resolved_methods.update(info['direct_methods'])
|
||||
|
||||
return attr_values, unresolved, resolved_methods
|
||||
|
||||
for class_name, info in class_info.items():
|
||||
# Skip base classes and mixins
|
||||
if info['is_base_or_mixin']:
|
||||
@@ -485,14 +310,7 @@ for class_name, info in class_info.items():
|
||||
|
||||
if final_metadata and isinstance(final_metadata, dict) and display_name:
|
||||
debug_info["classes_with_metadata"] += 1
|
||||
|
||||
# Resolve capability flags from Python source
|
||||
attr_values, unresolved_caps, cap_methods = get_resolved_caps(class_name)
|
||||
cap_attrs = dict(CAP_ATTR_DEFAULTS)
|
||||
cap_attrs.update(attr_values)
|
||||
engine_attr = info.get('engine') or ''
|
||||
|
||||
entry = {
|
||||
databases[display_name] = {
|
||||
'engine': display_name.lower().replace(' ', '_'),
|
||||
'engine_name': display_name,
|
||||
'module': info['filename'][:-3], # Remove .py extension
|
||||
@@ -500,40 +318,19 @@ for class_name, info in class_info.items():
|
||||
'time_grains': {},
|
||||
'score': 0,
|
||||
'max_score': 0,
|
||||
# Capability flags read from engine spec class attributes/methods
|
||||
'joins': cap_attrs['allows_joins'],
|
||||
'subqueries': cap_attrs['allows_subqueries'],
|
||||
'supports_dynamic_schema': cap_attrs['supports_dynamic_schema'],
|
||||
'supports_catalog': cap_attrs['supports_catalog'],
|
||||
'supports_dynamic_catalog': cap_attrs['supports_dynamic_catalog'],
|
||||
'ssh_tunneling': not cap_attrs['disable_ssh_tunneling'],
|
||||
'supports_file_upload': cap_attrs['supports_file_upload'],
|
||||
# Method-based flags: True only when a non-base class overrides them.
|
||||
# Matches diagnose() in lib.py: cancel_query override OR
|
||||
# has_implicit_cancel() returning True (which, given the base
|
||||
# returns False, is equivalent to overriding has_implicit_cancel).
|
||||
'query_cancelation': bool({'cancel_query', 'has_implicit_cancel'} & cap_methods),
|
||||
'query_cost_estimation': bool({'estimate_statement_cost', 'estimate_query_cost'} & cap_methods),
|
||||
# SQL validation is implemented in external validator classes keyed by engine name
|
||||
'sql_validation': engine_attr in {'presto', 'postgresql'},
|
||||
'user_impersonation': bool(
|
||||
{'impersonate_user', 'update_impersonation_config', 'get_url_for_impersonation'} & cap_methods
|
||||
),
|
||||
'joins': True,
|
||||
'subqueries': True,
|
||||
'supports_dynamic_schema': False,
|
||||
'supports_catalog': False,
|
||||
'supports_dynamic_catalog': False,
|
||||
'ssh_tunneling': False,
|
||||
'query_cancelation': False,
|
||||
'supports_file_upload': False,
|
||||
'user_impersonation': False,
|
||||
'query_cost_estimation': False,
|
||||
'sql_validation': False,
|
||||
}
|
||||
|
||||
# Tell the JS layer which output fields were populated from the
|
||||
# BaseEngineSpec default because the source assignment was an
|
||||
# unevaluable expression; those get overridden from existing JSON.
|
||||
unresolved_fields = sorted(
|
||||
CAP_ATTR_TO_OUTPUT_FIELD[attr]
|
||||
for attr in unresolved_caps
|
||||
if attr in CAP_ATTR_TO_OUTPUT_FIELD
|
||||
)
|
||||
if unresolved_fields:
|
||||
entry['_unresolved_cap_fields'] = unresolved_fields
|
||||
|
||||
databases[display_name] = entry
|
||||
|
||||
if errors and not databases:
|
||||
print(json.dumps({"error": "Parse errors", "details": errors, "debug": debug_info}), file=sys.stderr)
|
||||
|
||||
@@ -1054,52 +851,24 @@ function loadExistingData() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fall back to the previously-generated databases.json for capability flags
|
||||
* whose source assignment couldn't be statically resolved (e.g.
|
||||
* `allows_joins = is_feature_enabled("DRUID_JOINS")`). The Python extractor
|
||||
* flags these via the internal `_unresolved_cap_fields` marker; without this
|
||||
* fallback those fields would silently inherit the BaseEngineSpec default
|
||||
* and disagree with runtime behavior. The marker is stripped before output.
|
||||
*/
|
||||
function fallbackUnresolvedCaps(newDatabases, existingData) {
|
||||
for (const [name, db] of Object.entries(newDatabases)) {
|
||||
const unresolved = db._unresolved_cap_fields;
|
||||
if (!unresolved || unresolved.length === 0) {
|
||||
delete db._unresolved_cap_fields;
|
||||
continue;
|
||||
}
|
||||
const existingDb = existingData?.databases?.[name];
|
||||
if (existingDb) {
|
||||
for (const field of unresolved) {
|
||||
if (existingDb[field] !== undefined) {
|
||||
db[field] = existingDb[field];
|
||||
}
|
||||
}
|
||||
}
|
||||
delete db._unresolved_cap_fields;
|
||||
}
|
||||
return newDatabases;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge new documentation with existing diagnostics
|
||||
* Preserves score, max_score, and time_grains from existing data (these require
|
||||
* Flask context to generate and cannot be derived from static source analysis).
|
||||
* Capability flags (joins, supports_catalog, etc.) are NOT preserved here — they
|
||||
* are read fresh from the Python engine spec source by extractEngineSpecMetadata(),
|
||||
* with a separate fallback for expression-based assignments (see fallbackUnresolvedCaps).
|
||||
* Preserves score, time_grains, and feature flags from existing data
|
||||
*/
|
||||
function mergeWithExistingDiagnostics(newDatabases, existingData) {
|
||||
if (!existingData?.databases) return newDatabases;
|
||||
|
||||
// Only preserve fields that require Flask/runtime context to generate
|
||||
const diagnosticFields = ['score', 'max_score', 'time_grains'];
|
||||
const diagnosticFields = [
|
||||
'score', 'max_score', 'time_grains', 'joins', 'subqueries',
|
||||
'supports_dynamic_schema', 'supports_catalog', 'supports_dynamic_catalog',
|
||||
'ssh_tunneling', 'query_cancelation', 'supports_file_upload',
|
||||
'user_impersonation', 'query_cost_estimation', 'sql_validation'
|
||||
];
|
||||
|
||||
for (const [name, db] of Object.entries(newDatabases)) {
|
||||
const existingDb = existingData.databases[name];
|
||||
if (existingDb && existingDb.score > 0) {
|
||||
// Preserve score/time_grain diagnostics from existing data
|
||||
// Preserve diagnostics from existing data
|
||||
for (const field of diagnosticFields) {
|
||||
if (existingDb[field] !== undefined) {
|
||||
db[field] = existingDb[field];
|
||||
@@ -1110,7 +879,7 @@ function mergeWithExistingDiagnostics(newDatabases, existingData) {
|
||||
|
||||
const preserved = Object.values(newDatabases).filter(d => d.score > 0).length;
|
||||
if (preserved > 0) {
|
||||
console.log(`Preserved score/time_grains for ${preserved} databases from existing data`);
|
||||
console.log(`Preserved diagnostics for ${preserved} databases from existing data`);
|
||||
}
|
||||
|
||||
return newDatabases;
|
||||
@@ -1158,12 +927,6 @@ async function main() {
|
||||
databases = mergeWithExistingDiagnostics(databases, existingData);
|
||||
}
|
||||
|
||||
// For cap flags assigned via unevaluable expressions (e.g.
|
||||
// `is_feature_enabled(...)`), prefer the value from a previously-generated
|
||||
// JSON. Runs regardless of scores since it addresses static-analysis gaps,
|
||||
// not missing Flask diagnostics. Always strips the internal marker.
|
||||
databases = fallbackUnresolvedCaps(databases, existingData);
|
||||
|
||||
// Extract and merge custom_errors for troubleshooting documentation
|
||||
const customErrors = extractCustomErrors();
|
||||
mergeCustomErrors(databases, customErrors);
|
||||
|
||||
BIN
docs/static/img/screenshots/dashboard.jpg
vendored
|
Before Width: | Height: | Size: 132 KiB After Width: | Height: | Size: 134 KiB |
BIN
docs/static/img/screenshots/explore.jpg
vendored
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 104 KiB |
BIN
docs/static/img/screenshots/gallery.jpg
vendored
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
BIN
docs/static/img/screenshots/sql_lab.jpg
vendored
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 99 KiB |
BIN
docs/static/img/tutorial/create_pivot.png
vendored
|
Before Width: | Height: | Size: 240 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 131 KiB After Width: | Height: | Size: 51 KiB |
BIN
docs/static/img/tutorial/tutorial_chart_resize.png
vendored
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 85 KiB |
BIN
docs/static/img/tutorial/tutorial_edit_button.png
vendored
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 97 KiB |
BIN
docs/static/img/tutorial/tutorial_save_slice.png
vendored
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 141 KiB |
236
docs/yarn.lock
@@ -261,15 +261,6 @@
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/code-frame@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
|
||||
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0":
|
||||
version "7.28.0"
|
||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
|
||||
@@ -312,17 +303,6 @@
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/generator@^7.29.0":
|
||||
version "7.29.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50"
|
||||
integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/types" "^7.29.0"
|
||||
"@jridgewell/gen-mapping" "^0.3.12"
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
|
||||
version "7.27.3"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
|
||||
@@ -424,11 +404,6 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz"
|
||||
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
|
||||
|
||||
"@babel/helper-plugin-utils@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
|
||||
integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
|
||||
|
||||
"@babel/helper-remap-async-to-generator@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz"
|
||||
@@ -460,6 +435,11 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
|
||||
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz"
|
||||
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.28.5":
|
||||
version "7.28.5"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
|
||||
@@ -494,13 +474,6 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/parser@^7.29.0":
|
||||
version "7.29.3"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e"
|
||||
integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==
|
||||
dependencies:
|
||||
"@babel/types" "^7.29.0"
|
||||
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz"
|
||||
@@ -785,14 +758,14 @@
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-modules-systemjs@^7.27.1":
|
||||
version "7.29.4"
|
||||
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20"
|
||||
integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz"
|
||||
integrity sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==
|
||||
dependencies:
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helper-plugin-utils" "^7.28.6"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
"@babel/traverse" "^7.29.0"
|
||||
"@babel/helper-module-transforms" "^7.27.1"
|
||||
"@babel/helper-plugin-utils" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.27.1"
|
||||
"@babel/traverse" "^7.27.1"
|
||||
|
||||
"@babel/plugin-transform-modules-umd@^7.27.1":
|
||||
version "7.27.1"
|
||||
@@ -1190,19 +1163,6 @@
|
||||
"@babel/types" "^7.28.6"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/traverse@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a"
|
||||
integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.29.0"
|
||||
"@babel/generator" "^7.29.0"
|
||||
"@babel/helper-globals" "^7.28.0"
|
||||
"@babel/parser" "^7.29.0"
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/types" "^7.29.0"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
|
||||
@@ -1211,14 +1171,6 @@
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@babel/types@^7.29.0":
|
||||
version "7.29.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
|
||||
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@braintree/sanitize-url@^7.0.4":
|
||||
version "7.1.1"
|
||||
resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz"
|
||||
@@ -5088,100 +5040,100 @@
|
||||
dependencies:
|
||||
"@types/yargs-parser" "*"
|
||||
|
||||
"@typescript-eslint/eslint-plugin@8.59.2", "@typescript-eslint/eslint-plugin@^8.52.0":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz#f37b2c189a0177141fe3de3b08f2a83991bfdbfa"
|
||||
integrity sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==
|
||||
"@typescript-eslint/eslint-plugin@8.59.1", "@typescript-eslint/eslint-plugin@^8.52.0":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz#781bc6f9002982cfaf75a185240e24ad7276628a"
|
||||
integrity sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==
|
||||
dependencies:
|
||||
"@eslint-community/regexpp" "^4.12.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.2"
|
||||
"@typescript-eslint/type-utils" "8.59.2"
|
||||
"@typescript-eslint/utils" "8.59.2"
|
||||
"@typescript-eslint/visitor-keys" "8.59.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/type-utils" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
ignore "^7.0.5"
|
||||
natural-compare "^1.4.0"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/parser@8.59.2", "@typescript-eslint/parser@^8.59.0":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.2.tgz#e2fd0084baa5dd0c24cd789af1c72cbc3a7a1c62"
|
||||
integrity sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==
|
||||
"@typescript-eslint/parser@8.59.1", "@typescript-eslint/parser@^8.59.0":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.59.1.tgz#835d20a62350659a082a1ae2a60b822c40488905"
|
||||
integrity sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==
|
||||
dependencies:
|
||||
"@typescript-eslint/scope-manager" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/typescript-estree" "8.59.2"
|
||||
"@typescript-eslint/visitor-keys" "8.59.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/project-service@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.2.tgz#f8b8cbf8692e3a51c2c394acf8cf6900f7e755af"
|
||||
integrity sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==
|
||||
"@typescript-eslint/project-service@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.59.1.tgz#49efe87c37ef84262f23df8bf62fdc56698ca6fe"
|
||||
integrity sha512-+MuHQlHiEr00Of/IQbE/MmEoi44znZHbR/Pz7Opq4HryUOlRi+/44dro9Ycy8Fyo+/024IWtw8m4JUMCGTYxDg==
|
||||
dependencies:
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.2"
|
||||
"@typescript-eslint/types" "^8.59.2"
|
||||
"@typescript-eslint/tsconfig-utils" "^8.59.1"
|
||||
"@typescript-eslint/types" "^8.59.1"
|
||||
debug "^4.4.3"
|
||||
|
||||
"@typescript-eslint/scope-manager@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz#63cbd0af2e3180949d6be81122cc555bc71e736d"
|
||||
integrity sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==
|
||||
"@typescript-eslint/scope-manager@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz#ed90d054fc3db2d0c81464db3a953a94fb85bb58"
|
||||
integrity sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/visitor-keys" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
|
||||
"@typescript-eslint/tsconfig-utils@8.59.2", "@typescript-eslint/tsconfig-utils@^8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz#6e92bc412083753185a79c9f1431e78169d9232f"
|
||||
integrity sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==
|
||||
"@typescript-eslint/tsconfig-utils@8.59.1", "@typescript-eslint/tsconfig-utils@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.1.tgz#ba2a779a444f1d5cb92a606f9b209d239fd4cab1"
|
||||
integrity sha512-/0nEyPbX7gRsk0Uwfe4ALwwgxuA66d/l2mhRDNlAvaj4U3juhUtJNq0DsY8M2AYwwb9rEq2hrC3IcIcEt++iJA==
|
||||
|
||||
"@typescript-eslint/type-utils@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz#a60a1192a804fa472a92c41656853ac6a9ba7176"
|
||||
integrity sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==
|
||||
"@typescript-eslint/type-utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz#9c83d3f2ed9187a815e8120f72c08317e513e409"
|
||||
integrity sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/typescript-estree" "8.59.2"
|
||||
"@typescript-eslint/utils" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/types@8.59.2", "@typescript-eslint/types@^8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.2.tgz#01caabcd7e4715c33ad5e11cab260829714d6b9c"
|
||||
integrity sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==
|
||||
"@typescript-eslint/types@8.59.1", "@typescript-eslint/types@^8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.59.1.tgz#c1d014d3f03a97e0113a8899fc9d4e45a7fb0ca9"
|
||||
integrity sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==
|
||||
|
||||
"@typescript-eslint/typescript-estree@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz#6a217ef65b18dbd12c718fc86a675d1d7a1414cc"
|
||||
integrity sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==
|
||||
"@typescript-eslint/typescript-estree@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz#4391fadf98a22c869c5b6522dbf4e491e53e351a"
|
||||
integrity sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==
|
||||
dependencies:
|
||||
"@typescript-eslint/project-service" "8.59.2"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/visitor-keys" "8.59.2"
|
||||
"@typescript-eslint/project-service" "8.59.1"
|
||||
"@typescript-eslint/tsconfig-utils" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/visitor-keys" "8.59.1"
|
||||
debug "^4.4.3"
|
||||
minimatch "^10.2.2"
|
||||
semver "^7.7.3"
|
||||
tinyglobby "^0.2.15"
|
||||
ts-api-utils "^2.5.0"
|
||||
|
||||
"@typescript-eslint/utils@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.2.tgz#ff619a6a3075f4017fa91b8610b752a8ca3366aa"
|
||||
integrity sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==
|
||||
"@typescript-eslint/utils@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd"
|
||||
integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==
|
||||
dependencies:
|
||||
"@eslint-community/eslint-utils" "^4.9.1"
|
||||
"@typescript-eslint/scope-manager" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/typescript-estree" "8.59.2"
|
||||
"@typescript-eslint/scope-manager" "8.59.1"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
|
||||
"@typescript-eslint/visitor-keys@8.59.2":
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz#5ccc486913cd347883d69158836b1189a660bfe6"
|
||||
integrity sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==
|
||||
"@typescript-eslint/visitor-keys@8.59.1":
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz#b5cba576287a3eeb0b400b62813189abcc3f976a"
|
||||
integrity sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==
|
||||
dependencies:
|
||||
"@typescript-eslint/types" "8.59.2"
|
||||
"@typescript-eslint/types" "8.59.1"
|
||||
eslint-visitor-keys "^5.0.0"
|
||||
|
||||
"@ungap/structured-clone@^1.0.0":
|
||||
@@ -5842,10 +5794,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
|
||||
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
|
||||
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
|
||||
|
||||
baseline-browser-mapping@^2.10.29, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.29"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
|
||||
integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
|
||||
baseline-browser-mapping@^2.10.27, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.27"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
|
||||
integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -6083,10 +6035,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.30001759, caniuse-lite@^1.0.30001792:
|
||||
version "1.0.30001792"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz#ca8bb9be244835a335e2018272ce7223691873c5"
|
||||
integrity sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001791:
|
||||
version "1.0.30001791"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz#dfb93d85c40ad380c57123e72e10f3c575786b51"
|
||||
integrity sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -8166,9 +8118,9 @@ fast-safe-stringify@^2.0.7:
|
||||
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
|
||||
|
||||
fast-uri@^3.0.1:
|
||||
version "3.1.2"
|
||||
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
|
||||
integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
|
||||
version "3.0.6"
|
||||
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz"
|
||||
integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==
|
||||
|
||||
fastq@^1.6.0:
|
||||
version "1.19.1"
|
||||
@@ -14763,15 +14715,15 @@ types-ramda@^0.30.1:
|
||||
dependencies:
|
||||
ts-toolbelt "^9.6.0"
|
||||
|
||||
typescript-eslint@^8.59.2:
|
||||
version "8.59.2"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.2.tgz#e24b4f7232e20112e40572dba162a829a738ce98"
|
||||
integrity sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==
|
||||
typescript-eslint@^8.59.1:
|
||||
version "8.59.1"
|
||||
resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.59.1.tgz#244a9fcbf27057ebbc2281d408239f1861b55b78"
|
||||
integrity sha512-xqDcFVBmlrltH64lklOVp1wYxgJr6LVdg3NamBgH2OOQDLFdTKfIZXF5PfghrnXQKXZGTQs8tr1vL7fJvq8CTQ==
|
||||
dependencies:
|
||||
"@typescript-eslint/eslint-plugin" "8.59.2"
|
||||
"@typescript-eslint/parser" "8.59.2"
|
||||
"@typescript-eslint/typescript-estree" "8.59.2"
|
||||
"@typescript-eslint/utils" "8.59.2"
|
||||
"@typescript-eslint/eslint-plugin" "8.59.1"
|
||||
"@typescript-eslint/parser" "8.59.1"
|
||||
"@typescript-eslint/typescript-estree" "8.59.1"
|
||||
"@typescript-eslint/utils" "8.59.1"
|
||||
|
||||
typescript@~6.0.3:
|
||||
version "6.0.3"
|
||||
|
||||
@@ -71,7 +71,7 @@ dependencies = [
|
||||
"marshmallow>=3.0, <4",
|
||||
"marshmallow-union>=0.1",
|
||||
"msgpack>=1.0.0, <1.2",
|
||||
"nh3>=0.2.11, <0.4",
|
||||
"nh3>=0.2.11, <0.3",
|
||||
"numpy>1.23.5, <2.3",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
@@ -131,10 +131,10 @@ d1 = [
|
||||
]
|
||||
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
|
||||
databricks = [
|
||||
"databricks-sql-connector==4.2.6",
|
||||
"databricks-sql-connector==4.1.2",
|
||||
"databricks-sqlalchemy==1.0.5",
|
||||
]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
|
||||
db2 = ["ibm-db-sa>0.3.8, <=0.4.0"]
|
||||
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
|
||||
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
|
||||
drill = ["sqlalchemy-drill>=1.1.4, <2"]
|
||||
@@ -142,17 +142,11 @@ druid = ["pydruid>=0.6.5,<0.7"]
|
||||
duckdb = ["duckdb>=1.4.2,<2", "duckdb-engine>=0.17.0"]
|
||||
dynamodb = ["pydynamodb>=0.4.2"]
|
||||
solr = ["sqlalchemy-solr >= 0.2.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.13, <0.3.0"]
|
||||
elasticsearch = ["elasticsearch-dbapi>=0.2.12, <0.3.0"]
|
||||
exasol = ["sqlalchemy-exasol >= 2.4.0, <3.0"]
|
||||
excel = ["xlrd>=1.2.0, <1.3"]
|
||||
fastmcp = [
|
||||
"fastmcp>=3.2.4,<4.0",
|
||||
# tiktoken backs the response-size-guard token estimator. Without
|
||||
# it, the middleware falls back to a coarser character-based
|
||||
# heuristic that under-counts JSON-heavy MCP responses.
|
||||
"tiktoken>=0.7.0,<1.0",
|
||||
]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
|
||||
fastmcp = ["fastmcp>=3.2.4,<4.0"]
|
||||
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
|
||||
gevent = ["gevent>=23.9.1"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
@@ -179,7 +173,7 @@ ocient = [
|
||||
]
|
||||
oracle = ["cx-Oracle>8.0.0, <8.4"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <6.0.0"]
|
||||
playwright = ["playwright>=1.37.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
@@ -224,7 +218,7 @@ development = [
|
||||
"progress>=1.5,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=4.0.2,<6",
|
||||
"pyinstrument>=4.0.2,<5",
|
||||
"pylint",
|
||||
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
|
||||
"pytest-asyncio",
|
||||
@@ -383,7 +377,6 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"superset/mcp_service/app.py" = ["S608", "E501"] # LLM instruction text: SQL examples (S608) and long lines in multiline string (E501)
|
||||
"superset/mcp_service/*/tool/list_*.py" = ["E501"] # LLM docstring examples show full request shapes which exceed line length
|
||||
"scripts/*" = ["TID251"]
|
||||
"setup.py" = ["TID251"]
|
||||
"superset/config.py" = ["TID251"]
|
||||
|
||||
@@ -183,9 +183,7 @@ idna==3.10
|
||||
# trio
|
||||
# url-normalize
|
||||
isodate==0.7.2
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
# via apache-superset (pyproject.toml)
|
||||
itsdangerous==2.2.0
|
||||
# via
|
||||
# flask
|
||||
@@ -298,7 +296,6 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# apache-superset-core
|
||||
pyasn1==0.6.3
|
||||
# via
|
||||
# pyasn1-modules
|
||||
|
||||
@@ -442,7 +442,6 @@ isodate==0.7.2
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
isort==6.0.1
|
||||
# via pylint
|
||||
itsdangerous==2.2.0
|
||||
@@ -716,7 +715,6 @@ pyarrow==20.0.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
# apache-superset-core
|
||||
# db-dtypes
|
||||
# pandas-gbq
|
||||
pyasn1==0.6.3
|
||||
@@ -868,8 +866,6 @@ referencing==0.36.2
|
||||
# jsonschema
|
||||
# jsonschema-path
|
||||
# jsonschema-specifications
|
||||
regex==2026.4.4
|
||||
# via tiktoken
|
||||
requests==2.33.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
@@ -882,7 +878,6 @@ requests==2.33.0
|
||||
# requests-cache
|
||||
# requests-oauthlib
|
||||
# shillelagh
|
||||
# tiktoken
|
||||
# trino
|
||||
requests-cache==1.2.1
|
||||
# via
|
||||
@@ -1008,8 +1003,6 @@ tabulate==0.9.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
tiktoken==0.12.0
|
||||
# via apache-superset
|
||||
tomli-w==1.2.0
|
||||
# via apache-superset-extensions-cli
|
||||
tomlkit==0.13.3
|
||||
|
||||
@@ -66,7 +66,7 @@ export type EmbedDashboardParams = {
|
||||
iframeTitle?: string;
|
||||
/** additional iframe sandbox attributes ex (allow-top-navigation, allow-popups-to-escape-sandbox) **/
|
||||
iframeSandboxExtras?: string[];
|
||||
/** Additional Permissions Policy features for the iframe's `allow` attribute (e.g., ['camera', 'microphone']). `fullscreen` and `clipboard-write` are granted by default. **/
|
||||
/** iframe allow attribute for Permissions Policy (e.g., ['clipboard-write', 'fullscreen']) **/
|
||||
iframeAllowExtras?: string[];
|
||||
/** force a specific refererPolicy to be used in the iframe request **/
|
||||
referrerPolicy?: ReferrerPolicy;
|
||||
@@ -233,14 +233,9 @@ export async function embedDashboard({
|
||||
iframe.src = `${supersetDomain}/embedded/${id}${urlParamsString}`;
|
||||
iframe.title = iframeTitle;
|
||||
iframe.style.background = 'transparent';
|
||||
// Permissions Policy features the embedded dashboard relies on. Modern
|
||||
// browsers gate these APIs on the iframe's `allow` attribute regardless
|
||||
// of sandbox flags, so we include them by default. Host apps can extend
|
||||
// the list via `iframeAllowExtras`.
|
||||
const allowFeatures = Array.from(
|
||||
new Set(['fullscreen', 'clipboard-write', ...iframeAllowExtras]),
|
||||
);
|
||||
iframe.setAttribute('allow', allowFeatures.join('; '));
|
||||
if (iframeAllowExtras.length > 0) {
|
||||
iframe.setAttribute('allow', iframeAllowExtras.join('; '));
|
||||
}
|
||||
//@ts-ignore
|
||||
mountPoint.replaceChildren(iframe);
|
||||
log('placed the iframe');
|
||||
|
||||
353
superset-frontend/package-lock.json
generated
@@ -96,7 +96,7 @@
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.5.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -222,7 +222,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -290,7 +290,7 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.6",
|
||||
"wait-on": "^9.0.5",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
@@ -14358,17 +14358,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
|
||||
"integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
|
||||
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.2",
|
||||
"@typescript-eslint/type-utils": "8.59.2",
|
||||
"@typescript-eslint/utils": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/type-utils": "8.59.1",
|
||||
"@typescript-eslint/utils": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@@ -14381,42 +14381,20 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.59.2",
|
||||
"@typescript-eslint/parser": "^8.59.1",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
|
||||
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.2",
|
||||
"@typescript-eslint/types": "^8.59.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
|
||||
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14426,27 +14404,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
|
||||
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
|
||||
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14458,16 +14419,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
|
||||
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14486,16 +14447,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
|
||||
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/typescript-estree": "8.59.2"
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14510,13 +14471,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
|
||||
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14538,9 +14499,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14590,16 +14551,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
|
||||
"integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
|
||||
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/typescript-estree": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14614,37 +14575,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
|
||||
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.2",
|
||||
"@typescript-eslint/types": "^8.59.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
|
||||
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14654,27 +14593,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
|
||||
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
|
||||
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14686,16 +14608,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
|
||||
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14714,13 +14636,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
|
||||
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -14742,9 +14664,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14855,15 +14777,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
|
||||
"integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
|
||||
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/typescript-estree": "8.59.2",
|
||||
"@typescript-eslint/utils": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1",
|
||||
"@typescript-eslint/utils": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@@ -14879,37 +14801,15 @@
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
|
||||
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.59.2",
|
||||
"@typescript-eslint/types": "^8.59.2",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
|
||||
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
|
||||
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2"
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -14919,27 +14819,10 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
|
||||
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
|
||||
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
|
||||
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -14951,16 +14834,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
|
||||
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
|
||||
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.59.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/visitor-keys": "8.59.2",
|
||||
"@typescript-eslint/project-service": "8.59.1",
|
||||
"@typescript-eslint/tsconfig-utils": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/visitor-keys": "8.59.1",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@@ -14979,16 +14862,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
|
||||
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
|
||||
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/typescript-estree": "8.59.2"
|
||||
"@typescript-eslint/scope-manager": "8.59.1",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"@typescript-eslint/typescript-estree": "8.59.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@@ -15003,13 +14886,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.59.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
|
||||
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
|
||||
"version": "8.59.1",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
|
||||
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.59.2",
|
||||
"@typescript-eslint/types": "8.59.1",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -15031,9 +14914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
|
||||
"version": "5.0.6",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
|
||||
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
|
||||
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -16999,13 +16882,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/axios": {
|
||||
"version": "1.16.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
|
||||
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
|
||||
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"follow-redirects": "^1.16.0",
|
||||
"follow-redirects": "^1.15.11",
|
||||
"form-data": "^4.0.5",
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
@@ -24877,9 +24760,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/geostyler": {
|
||||
"version": "18.5.1",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
|
||||
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
|
||||
"version": "18.5.0",
|
||||
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.0.tgz",
|
||||
"integrity": "sha512-azjLMEhrTQot+pU3phfSrUZI7CdetyAl7JNAnxrGaPA/E/5mmyoPQugZso3CfIuIBwOtFLmfB36SLE/FeGFakA==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.1",
|
||||
@@ -31694,9 +31577,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/joi": {
|
||||
"version": "18.2.1",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
|
||||
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
|
||||
"version": "18.1.2",
|
||||
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
|
||||
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
@@ -47975,14 +47858,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/wait-on": {
|
||||
"version": "9.0.6",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.6.tgz",
|
||||
"integrity": "sha512-KR+Te+NBg6DmPVil4anyIO72mpt/QDHjRo3nVFkwRgb26oweUp3DDW2szO3EeUY4cqafWy4rQuOOeEk4n+7Oeg==",
|
||||
"version": "9.0.5",
|
||||
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
|
||||
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"axios": "^1.16.0",
|
||||
"joi": "^18.2.1",
|
||||
"axios": "^1.15.0",
|
||||
"joi": "^18.1.2",
|
||||
"lodash": "^4.18.1",
|
||||
"minimist": "^1.2.8",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -51010,7 +50893,7 @@
|
||||
"@deck.gl/extensions": "~9.2.9",
|
||||
"@deck.gl/geo-layers": "~9.2.5",
|
||||
"@deck.gl/layers": "~9.2.5",
|
||||
"@deck.gl/mapbox": "~9.3.2",
|
||||
"@deck.gl/mapbox": "^9.3.2",
|
||||
"@deck.gl/mesh-layers": "~9.2.5",
|
||||
"@luma.gl/constants": "~9.2.5",
|
||||
"@luma.gl/core": "~9.2.5",
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.1",
|
||||
"geostyler": "^18.5.0",
|
||||
"geostyler-data": "^1.1.0",
|
||||
"geostyler-openlayers-parser": "^5.7.0",
|
||||
"geostyler-style": "11.0.2",
|
||||
@@ -303,7 +303,7 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||
"@typescript-eslint/eslint-plugin": "^8.59.1",
|
||||
"@typescript-eslint/parser": "^8.58.2",
|
||||
"babel-jest": "^30.0.2",
|
||||
"babel-loader": "^10.1.1",
|
||||
@@ -371,7 +371,7 @@
|
||||
"typescript": "5.4.5",
|
||||
"unzipper": "^0.12.3",
|
||||
"vm-browserify": "^1.1.2",
|
||||
"wait-on": "^9.0.6",
|
||||
"wait-on": "^9.0.5",
|
||||
"webpack": "^5.106.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
|
||||
@@ -38,17 +38,9 @@ import {
|
||||
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
|
||||
|
||||
export class Theme {
|
||||
// Forward-compat: TS 6.0 enforces strictPropertyInitialization here;
|
||||
// both fields are assigned via setConfig() during construction, so we
|
||||
// use a definite-assignment assertion rather than hoisting the logic
|
||||
// out of setConfig().
|
||||
//
|
||||
// Assigned via setConfig() in the constructor; TypeScript 6.0's
|
||||
// strictPropertyInitialization can't trace that call chain, so we use
|
||||
// a definite-assignment assertion.
|
||||
theme!: SupersetTheme;
|
||||
theme: SupersetTheme;
|
||||
|
||||
private antdConfig!: AntdThemeConfig;
|
||||
private antdConfig: AntdThemeConfig;
|
||||
|
||||
private constructor({ config }: { config?: AnyThemeConfig }) {
|
||||
this.SupersetThemeProvider = this.SupersetThemeProvider.bind(this);
|
||||
|
||||
@@ -20,10 +20,3 @@
|
||||
* Stub for the untyped jed module.
|
||||
*/
|
||||
declare module 'jed';
|
||||
|
||||
/**
|
||||
* CSS side-effect imports from @fontsource packages. These are bundler-only
|
||||
* artifacts and carry no type information at runtime; declaring them here
|
||||
* silences TS2882 under TypeScript 6.0's stricter module-resolution rules.
|
||||
*/
|
||||
declare module '@fontsource/*';
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
/* eslint react/sort-comp: 'off' */
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import {
|
||||
SupersetClientInterface,
|
||||
RequestConfig,
|
||||
@@ -66,112 +67,103 @@ export type ChartDataProviderState = {
|
||||
error?: ProvidedProps['error'];
|
||||
};
|
||||
|
||||
function ChartDataProvider({
|
||||
children,
|
||||
client,
|
||||
formData,
|
||||
sliceId,
|
||||
loadDatasource,
|
||||
onError,
|
||||
onLoaded,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
}: ChartDataProviderProps): JSX.Element | null {
|
||||
const [state, setState] = useState<ChartDataProviderState>({
|
||||
status: 'uninitialized',
|
||||
});
|
||||
class ChartDataProvider extends PureComponent<
|
||||
ChartDataProviderProps,
|
||||
ChartDataProviderState
|
||||
> {
|
||||
readonly chartClient: ChartClient;
|
||||
|
||||
const chartClient = useMemo(() => new ChartClient({ client }), [client]);
|
||||
constructor(props: ChartDataProviderProps) {
|
||||
super(props);
|
||||
this.state = { status: 'uninitialized' };
|
||||
this.chartClient = new ChartClient({ client: props.client });
|
||||
}
|
||||
|
||||
const extractSliceIdAndFormData = useCallback(
|
||||
(): SliceIdAndOrFormData =>
|
||||
formData ? { formData } : { sliceId: sliceId as number },
|
||||
[formData, sliceId],
|
||||
);
|
||||
componentDidMount() {
|
||||
this.handleFetchData();
|
||||
}
|
||||
|
||||
const handleReceiveData = useCallback(
|
||||
(payload?: Payload) => {
|
||||
if (onLoaded) onLoaded(payload);
|
||||
setState({ payload, status: 'loaded' });
|
||||
},
|
||||
[onLoaded],
|
||||
);
|
||||
|
||||
const handleError = useCallback(
|
||||
(error: ProvidedProps['error']) => {
|
||||
if (onError) onError(error);
|
||||
setState({ error, status: 'error' });
|
||||
},
|
||||
[onError],
|
||||
);
|
||||
|
||||
const handleFetchData = useCallback(() => {
|
||||
setState({ status: 'loading' });
|
||||
try {
|
||||
chartClient
|
||||
.loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
|
||||
.then(loadedFormData =>
|
||||
Promise.all([
|
||||
loadDatasource
|
||||
? chartClient.loadDatasource(
|
||||
loadedFormData.datasource,
|
||||
datasourceRequestOptions,
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
chartClient.loadQueryData(loadedFormData, queryRequestOptions),
|
||||
]).then(
|
||||
([datasource, queriesData]) =>
|
||||
({
|
||||
datasource,
|
||||
formData: loadedFormData,
|
||||
queriesData,
|
||||
}) as Payload,
|
||||
),
|
||||
)
|
||||
.then(handleReceiveData)
|
||||
.catch(handleError);
|
||||
} catch (error) {
|
||||
handleError(error as Error);
|
||||
componentDidUpdate(prevProps: ChartDataProviderProps) {
|
||||
const { formData, sliceId } = this.props;
|
||||
if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
|
||||
this.handleFetchData();
|
||||
}
|
||||
}, [
|
||||
chartClient,
|
||||
extractSliceIdAndFormData,
|
||||
formDataRequestOptions,
|
||||
loadDatasource,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
handleReceiveData,
|
||||
handleError,
|
||||
]);
|
||||
}
|
||||
|
||||
// Fetch on mount and only refetch when formData or sliceId changes.
|
||||
// This preserves the original class component's componentDidUpdate
|
||||
// semantics (which compared only formData and sliceId). Other
|
||||
// fetch-related inputs referenced by handleFetchData (callbacks and
|
||||
// request option props) are intentionally excluded from the dependency
|
||||
// array, so the exhaustive-deps rule is suppressed here.
|
||||
useEffect(() => {
|
||||
handleFetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, sliceId]);
|
||||
private extractSliceIdAndFormData() {
|
||||
const { formData, sliceId } = this.props;
|
||||
return formData ? { formData } : { sliceId: sliceId as number };
|
||||
}
|
||||
|
||||
const { status, payload, error } = state;
|
||||
private handleFetchData = () => {
|
||||
const {
|
||||
loadDatasource,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
} = this.props;
|
||||
|
||||
// Wrap the children result in a Fragment so the component's return type
|
||||
// stays `JSX.Element | null` (which TypeScript requires for JSX components)
|
||||
// while still letting consumers return any ReactNode (strings, fragments,
|
||||
// arrays, null, etc.) from the render prop.
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <>{children({ loading: true })}</>;
|
||||
case 'loaded':
|
||||
return <>{children({ payload })}</>;
|
||||
case 'error':
|
||||
return <>{children({ error })}</>;
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
this.setState({ status: 'loading' }, () => {
|
||||
try {
|
||||
this.chartClient
|
||||
.loadFormData(
|
||||
this.extractSliceIdAndFormData(),
|
||||
formDataRequestOptions,
|
||||
)
|
||||
.then(formData =>
|
||||
Promise.all([
|
||||
loadDatasource
|
||||
? this.chartClient.loadDatasource(
|
||||
formData.datasource,
|
||||
datasourceRequestOptions,
|
||||
)
|
||||
: Promise.resolve(undefined),
|
||||
this.chartClient.loadQueryData(formData, queryRequestOptions),
|
||||
]).then(
|
||||
([datasource, queriesData]) =>
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
|
||||
({
|
||||
datasource,
|
||||
formData,
|
||||
queriesData,
|
||||
}) as Payload,
|
||||
),
|
||||
)
|
||||
.then(this.handleReceiveData)
|
||||
.catch(this.handleError);
|
||||
} catch (error) {
|
||||
this.handleError(error as Error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
private handleReceiveData = (payload?: Payload) => {
|
||||
const { onLoaded } = this.props;
|
||||
if (onLoaded) onLoaded(payload);
|
||||
this.setState({ payload, status: 'loaded' });
|
||||
};
|
||||
|
||||
private handleError = (error: ProvidedProps['error']) => {
|
||||
const { onError } = this.props;
|
||||
if (onError) onError(error);
|
||||
this.setState({ error, status: 'error' });
|
||||
};
|
||||
|
||||
render() {
|
||||
const { children } = this.props;
|
||||
const { status, payload, error } = this.state;
|
||||
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return children({ loading: true });
|
||||
case 'loaded':
|
||||
return children({ payload });
|
||||
case 'error':
|
||||
return children({ error });
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,8 @@ import {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
ComponentType,
|
||||
PureComponent,
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -35,19 +32,23 @@ import {
|
||||
} from 'react-error-boundary';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { withTheme } from '@emotion/react';
|
||||
import { parseLength, Dimension } from '../../dimension';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import SuperChartCore, {
|
||||
Props as SuperChartCoreProps,
|
||||
SuperChartCoreRef,
|
||||
} from './SuperChartCore';
|
||||
import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
|
||||
import DefaultFallbackComponent from './FallbackComponent';
|
||||
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
|
||||
import NoResultsComponent from './NoResultsComponent';
|
||||
import { isMatrixifyEnabled } from '../types/matrixify';
|
||||
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
|
||||
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
const defaultProps = {
|
||||
FallbackComponent: DefaultFallbackComponent,
|
||||
height: 400 as string | number,
|
||||
width: '100%' as string | number,
|
||||
enableNoResults: true,
|
||||
};
|
||||
|
||||
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
|
||||
|
||||
export type WrapperProps = Dimension & {
|
||||
@@ -55,9 +56,7 @@ export type WrapperProps = Dimension & {
|
||||
};
|
||||
|
||||
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
Omit<ChartPropsConfig, 'width' | 'height' | 'theme'> & {
|
||||
/** Theme object (optional, falls back to ThemeProvider context) */
|
||||
theme?: SupersetTheme;
|
||||
Omit<ChartPropsConfig, 'width' | 'height'> & {
|
||||
/**
|
||||
* Set this to true to disable error boundary built-in in SuperChart
|
||||
* and let the error propagate to upper level
|
||||
@@ -103,269 +102,215 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
inContextMenu?: boolean;
|
||||
};
|
||||
|
||||
function SuperChart({
|
||||
id,
|
||||
className,
|
||||
chartType,
|
||||
preTransformProps,
|
||||
overrideTransformProps,
|
||||
postTransformProps,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
disableErrorBoundary,
|
||||
FallbackComponent = DefaultFallbackComponent,
|
||||
onErrorBoundary,
|
||||
Wrapper,
|
||||
queriesData,
|
||||
enableNoResults = true,
|
||||
noResults,
|
||||
theme: themeProp,
|
||||
debounceTime,
|
||||
height = 400,
|
||||
width = '100%',
|
||||
...rest
|
||||
}: Props): JSX.Element {
|
||||
type PropsWithDefault = Props & Readonly<typeof defaultProps>;
|
||||
|
||||
class SuperChart extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* SuperChart's core ref
|
||||
* SuperChart's core
|
||||
*/
|
||||
const coreRef = useRef<SuperChartCoreRef | null>(null);
|
||||
core?: SuperChartCore | null;
|
||||
|
||||
// Use theme from prop if provided, otherwise from context.
|
||||
// When no ThemeProvider is present, useTheme() returns an empty object,
|
||||
// so we fall back to the default supersetTheme to avoid passing an invalid theme downstream.
|
||||
const themeFromContext = useTheme() as Partial<SupersetTheme>;
|
||||
const theme =
|
||||
themeProp ??
|
||||
(Object.keys(themeFromContext).length > 0
|
||||
? (themeFromContext as SupersetTheme)
|
||||
: supersetTheme);
|
||||
private createChartProps = ChartProps.createSelector();
|
||||
|
||||
const createChartProps = useMemo(() => ChartProps.createSelector(), []);
|
||||
|
||||
const parseDimension = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
({ width: w }: { width: string | number; height: string | number }) =>
|
||||
w,
|
||||
({
|
||||
height: h,
|
||||
}: {
|
||||
width: string | number;
|
||||
height: string | number;
|
||||
}) => h,
|
||||
],
|
||||
(w, h) => {
|
||||
// Parse them in case they are % or 'auto'
|
||||
const widthInfo = parseLength(w);
|
||||
const heightInfo = parseLength(h);
|
||||
const boxHeight = heightInfo.isDynamic
|
||||
? `${heightInfo.multiplier * 100}%`
|
||||
: heightInfo.value;
|
||||
const boxWidth = widthInfo.isDynamic
|
||||
? `${widthInfo.multiplier * 100}%`
|
||||
: widthInfo.value;
|
||||
const style = {
|
||||
height: boxHeight,
|
||||
width: boxWidth,
|
||||
};
|
||||
|
||||
// bounding box will ensure that when one dimension is not dynamic
|
||||
// e.g. height = 300
|
||||
// the auto size will be bound to that value instead of being 100% by default
|
||||
// e.g. height: 300 instead of height: '100%'
|
||||
const BoundingBox =
|
||||
widthInfo.isDynamic &&
|
||||
heightInfo.isDynamic &&
|
||||
widthInfo.multiplier === 1 &&
|
||||
heightInfo.multiplier === 1
|
||||
? Fragment
|
||||
: ({ children }: { children: ReactNode }) => (
|
||||
<div style={style}>{children}</div>
|
||||
);
|
||||
|
||||
return { BoundingBox, heightInfo, widthInfo };
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const setRef = useCallback((core: SuperChartCoreRef | null) => {
|
||||
coreRef.current = core;
|
||||
}, []);
|
||||
|
||||
const getQueryCount = useCallback(
|
||||
() => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
|
||||
[chartType],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(chartWidth: number, chartHeight: number) => {
|
||||
const chartProps = createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height: chartHeight,
|
||||
width: chartWidth,
|
||||
theme,
|
||||
});
|
||||
|
||||
// Check if Matrixify is enabled - use rawFormData (snake_case)
|
||||
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
|
||||
|
||||
if (matrixifyEnabled) {
|
||||
// When matrixify is enabled, queriesData is expected to be empty
|
||||
// since each cell fetches its own data via StatefulChart
|
||||
const matrixifyChart = (
|
||||
<MatrixifyGridRenderer
|
||||
formData={chartProps.rawFormData}
|
||||
datasource={chartProps.datasource}
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
hooks={chartProps.hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply wrapper if provided
|
||||
const wrappedChart = Wrapper ? (
|
||||
<Wrapper width={chartWidth} height={chartHeight}>
|
||||
{matrixifyChart}
|
||||
</Wrapper>
|
||||
) : (
|
||||
matrixifyChart
|
||||
);
|
||||
|
||||
// Include error boundary unless disabled
|
||||
return disableErrorBoundary === true ? (
|
||||
wrappedChart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{wrappedChart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for no results only for non-matrixified charts
|
||||
const noResultQueries =
|
||||
enableNoResults &&
|
||||
(!queriesData ||
|
||||
queriesData
|
||||
.slice(0, getQueryCount())
|
||||
.every(
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart: JSX.Element;
|
||||
if (noResultQueries) {
|
||||
chart = noResults ? (
|
||||
<>{noResults}</>
|
||||
) : (
|
||||
<NoResultsComponent
|
||||
id={id}
|
||||
className={className}
|
||||
height={chartHeight}
|
||||
width={chartWidth}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const chartWithoutWrapper = (
|
||||
<SuperChartCore
|
||||
ref={setRef}
|
||||
id={id}
|
||||
className={className}
|
||||
chartType={chartType}
|
||||
chartProps={chartProps}
|
||||
preTransformProps={preTransformProps}
|
||||
overrideTransformProps={overrideTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
/>
|
||||
);
|
||||
chart = Wrapper ? (
|
||||
<Wrapper width={chartWidth} height={chartHeight}>
|
||||
{chartWithoutWrapper}
|
||||
</Wrapper>
|
||||
) : (
|
||||
chartWithoutWrapper
|
||||
);
|
||||
}
|
||||
// Include the error boundary by default unless it is specifically disabled.
|
||||
return disableErrorBoundary === true ? (
|
||||
chart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent
|
||||
width={chartWidth}
|
||||
height={chartHeight}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{chart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
},
|
||||
private parseDimension = createSelector(
|
||||
[
|
||||
createChartProps,
|
||||
rest,
|
||||
queriesData,
|
||||
theme,
|
||||
Wrapper,
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
enableNoResults,
|
||||
getQueryCount,
|
||||
noResults,
|
||||
({ width }: { width: string | number; height: string | number }) => width,
|
||||
({ height }) => height,
|
||||
],
|
||||
(width, height) => {
|
||||
// Parse them in case they are % or 'auto'
|
||||
const widthInfo = parseLength(width);
|
||||
const heightInfo = parseLength(height);
|
||||
const boxHeight = heightInfo.isDynamic
|
||||
? `${heightInfo.multiplier * 100}%`
|
||||
: heightInfo.value;
|
||||
const boxWidth = widthInfo.isDynamic
|
||||
? `${widthInfo.multiplier * 100}%`
|
||||
: widthInfo.value;
|
||||
const style = {
|
||||
height: boxHeight,
|
||||
width: boxWidth,
|
||||
};
|
||||
|
||||
// bounding box will ensure that when one dimension is not dynamic
|
||||
// e.g. height = 300
|
||||
// the auto size will be bound to that value instead of being 100% by default
|
||||
// e.g. height: 300 instead of height: '100%'
|
||||
const BoundingBox =
|
||||
widthInfo.isDynamic &&
|
||||
heightInfo.isDynamic &&
|
||||
widthInfo.multiplier === 1 &&
|
||||
heightInfo.multiplier === 1
|
||||
? Fragment
|
||||
: ({ children }: { children: ReactNode }) => (
|
||||
<div style={style}>{children}</div>
|
||||
);
|
||||
|
||||
return { BoundingBox, heightInfo, widthInfo };
|
||||
},
|
||||
);
|
||||
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
private setRef = (core: SuperChartCore | null) => {
|
||||
this.core = core;
|
||||
};
|
||||
|
||||
private getQueryCount = () =>
|
||||
getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
|
||||
|
||||
renderChart(width: number, height: number) {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
setRef,
|
||||
chartType,
|
||||
preTransformProps,
|
||||
overrideTransformProps,
|
||||
postTransformProps,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
],
|
||||
);
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
Wrapper,
|
||||
queriesData,
|
||||
enableNoResults,
|
||||
noResults,
|
||||
theme,
|
||||
...rest
|
||||
} = this.props as PropsWithDefault;
|
||||
|
||||
const { heightInfo, widthInfo, BoundingBox } = parseDimension({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const chartProps = this.createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height,
|
||||
width,
|
||||
theme,
|
||||
});
|
||||
|
||||
// If any of the dimension is dynamic, get parent's dimension
|
||||
if (widthInfo.isDynamic || heightInfo.isDynamic) {
|
||||
return (
|
||||
<BoundingBox>
|
||||
<ParentSize debounceTime={debounceTime}>
|
||||
{({ width: parentWidth, height: parentHeight }) =>
|
||||
renderChart(
|
||||
widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
|
||||
heightInfo.isDynamic
|
||||
? Math.floor(parentHeight)
|
||||
: heightInfo.value,
|
||||
)
|
||||
}
|
||||
</ParentSize>
|
||||
</BoundingBox>
|
||||
// Check if Matrixify is enabled - use rawFormData (snake_case)
|
||||
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
|
||||
|
||||
if (matrixifyEnabled) {
|
||||
// When matrixify is enabled, queriesData is expected to be empty
|
||||
// since each cell fetches its own data via StatefulChart
|
||||
const matrixifyChart = (
|
||||
<MatrixifyGridRenderer
|
||||
formData={chartProps.rawFormData}
|
||||
datasource={chartProps.datasource}
|
||||
width={width}
|
||||
height={height}
|
||||
hooks={chartProps.hooks}
|
||||
/>
|
||||
);
|
||||
|
||||
// Apply wrapper if provided
|
||||
const wrappedChart = Wrapper ? (
|
||||
<Wrapper width={width} height={height}>
|
||||
{matrixifyChart}
|
||||
</Wrapper>
|
||||
) : (
|
||||
matrixifyChart
|
||||
);
|
||||
|
||||
// Include error boundary unless disabled
|
||||
return disableErrorBoundary === true ? (
|
||||
wrappedChart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent width={width} height={height} {...props} />
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{wrappedChart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
// Check for no results only for non-matrixified charts
|
||||
const noResultQueries =
|
||||
enableNoResults &&
|
||||
(!queriesData ||
|
||||
queriesData
|
||||
.slice(0, this.getQueryCount())
|
||||
.every(
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart;
|
||||
if (noResultQueries) {
|
||||
chart = noResults || (
|
||||
<NoResultsComponent
|
||||
id={id}
|
||||
className={className}
|
||||
height={height}
|
||||
width={width}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
const chartWithoutWrapper = (
|
||||
<SuperChartCore
|
||||
ref={this.setRef}
|
||||
id={id}
|
||||
className={className}
|
||||
chartType={chartType}
|
||||
chartProps={chartProps}
|
||||
preTransformProps={preTransformProps}
|
||||
overrideTransformProps={overrideTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
/>
|
||||
);
|
||||
chart = Wrapper ? (
|
||||
<Wrapper width={width} height={height}>
|
||||
{chartWithoutWrapper}
|
||||
</Wrapper>
|
||||
) : (
|
||||
chartWithoutWrapper
|
||||
);
|
||||
}
|
||||
// Include the error boundary by default unless it is specifically disabled.
|
||||
return disableErrorBoundary === true ? (
|
||||
chart
|
||||
) : (
|
||||
<ErrorBoundary
|
||||
FallbackComponent={props => (
|
||||
<FallbackComponent width={width} height={height} {...props} />
|
||||
)}
|
||||
onError={onErrorBoundary}
|
||||
>
|
||||
{chart}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return renderChart(widthInfo.value, heightInfo.value);
|
||||
render() {
|
||||
const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
|
||||
this.props as PropsWithDefault,
|
||||
);
|
||||
|
||||
// If any of the dimension is dynamic, get parent's dimension
|
||||
if (widthInfo.isDynamic || heightInfo.isDynamic) {
|
||||
const { debounceTime } = this.props;
|
||||
|
||||
return (
|
||||
<BoundingBox>
|
||||
<ParentSize debounceTime={debounceTime}>
|
||||
{({ width, height }) =>
|
||||
this.renderChart(
|
||||
widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
|
||||
heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
|
||||
)
|
||||
}
|
||||
</ParentSize>
|
||||
</BoundingBox>
|
||||
);
|
||||
}
|
||||
|
||||
return this.renderChart(widthInfo.value, heightInfo.value);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in memo to preserve the shallow-prop-comparison behavior
|
||||
// of the original PureComponent implementation.
|
||||
export default memo(SuperChart);
|
||||
export default withTheme(SuperChart);
|
||||
|
||||
@@ -17,13 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
/* eslint-disable react/jsx-sort-default-props */
|
||||
import { PureComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { createSelector } from 'reselect';
|
||||
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
|
||||
@@ -44,6 +39,16 @@ function IDENTITY<T>(x: T) {
|
||||
|
||||
const EMPTY = () => null;
|
||||
|
||||
const defaultProps = {
|
||||
id: '',
|
||||
className: '',
|
||||
preTransformProps: IDENTITY,
|
||||
overrideTransformProps: undefined,
|
||||
postTransformProps: IDENTITY,
|
||||
onRenderSuccess() {},
|
||||
onRenderFailure() {},
|
||||
};
|
||||
|
||||
interface LoadingProps {
|
||||
error: { toString(): string };
|
||||
}
|
||||
@@ -73,231 +78,174 @@ export type Props = {
|
||||
onRenderFailure?: HandlerFunction;
|
||||
};
|
||||
|
||||
export interface SuperChartCoreRef {
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
export default class SuperChartCore extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* The HTML element that wraps all chart content
|
||||
*/
|
||||
container?: HTMLElement | null;
|
||||
|
||||
const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
function SuperChartCore(
|
||||
{
|
||||
id = '',
|
||||
className = '',
|
||||
chartProps = BLANK_CHART_PROPS,
|
||||
chartType,
|
||||
preTransformProps = IDENTITY,
|
||||
overrideTransformProps,
|
||||
postTransformProps = IDENTITY,
|
||||
onRenderSuccess = () => {},
|
||||
onRenderFailure = () => {},
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
// Expose container via ref
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
get container() {
|
||||
return containerRef.current;
|
||||
},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
const preSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.preTransformProps,
|
||||
],
|
||||
(inputChartProps, pre = IDENTITY) => pre(inputChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
const transformSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
transformProps?: TransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.transformProps,
|
||||
],
|
||||
(preprocessedChartProps, transform = IDENTITY) =>
|
||||
transform(preprocessedChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
const postSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(transformedChartProps, post = IDENTITY) =>
|
||||
post(transformedChartProps),
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Using each memoized function to retrieve the computed chartProps
|
||||
*/
|
||||
const processChartProps = useCallback(
|
||||
({
|
||||
chartProps: inputChartProps,
|
||||
preTransformProps: pre,
|
||||
transformProps,
|
||||
postTransformProps: post,
|
||||
}: {
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
preSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
}) => input.chartProps,
|
||||
input => input.preTransformProps,
|
||||
],
|
||||
(chartProps, pre = IDENTITY) => pre(chartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
transformSelector = createSelector(
|
||||
[
|
||||
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
|
||||
input.chartProps,
|
||||
input => input.transformProps,
|
||||
],
|
||||
(preprocessedChartProps, transform = IDENTITY) =>
|
||||
transform(preprocessedChartProps),
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of the input arguments have changed.
|
||||
*/
|
||||
postSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
postSelector({
|
||||
chartProps: transformSelector({
|
||||
chartProps: preSelector({
|
||||
chartProps: inputChartProps,
|
||||
preTransformProps: pre,
|
||||
}),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps: post,
|
||||
}),
|
||||
[preSelector, transformSelector, postSelector],
|
||||
);
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
|
||||
);
|
||||
|
||||
const renderLoading = useCallback(
|
||||
(loadingProps: LoadingProps, loadingChartType: string) => {
|
||||
const { error } = loadingProps;
|
||||
/**
|
||||
* Using each memoized function to retrieve the computed chartProps
|
||||
*/
|
||||
processChartProps = ({
|
||||
chartProps,
|
||||
preTransformProps,
|
||||
transformProps,
|
||||
postTransformProps,
|
||||
}: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
this.postSelector({
|
||||
chartProps: this.transformSelector({
|
||||
chartProps: this.preSelector({ chartProps, preTransformProps }),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(loaded: LoadedModules, props: RenderProps) => {
|
||||
const { Chart, transformProps } = loaded;
|
||||
const {
|
||||
chartProps: renderChartProps,
|
||||
preTransformProps: pre,
|
||||
postTransformProps: post,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Chart
|
||||
{...processChartProps({
|
||||
chartProps: renderChartProps,
|
||||
preTransformProps: pre,
|
||||
transformProps,
|
||||
postTransformProps: post,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[processChartProps],
|
||||
);
|
||||
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
* unless one of
|
||||
* - chartType
|
||||
* - overrideTransformProps
|
||||
* is changed.
|
||||
*/
|
||||
const createLoadableRendererSelector = useMemo(
|
||||
() =>
|
||||
createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartType: string;
|
||||
overrideTransformProps?: TransformProps;
|
||||
}) => input.chartType,
|
||||
input => input.overrideTransformProps,
|
||||
],
|
||||
(selectorChartType, selectorOverrideTransformProps) => {
|
||||
if (selectorChartType) {
|
||||
const Renderer = createLoadableRenderer({
|
||||
loader: {
|
||||
Chart: () =>
|
||||
getChartComponentRegistry().getAsPromise(selectorChartType),
|
||||
transformProps: selectorOverrideTransformProps
|
||||
? () => Promise.resolve(selectorOverrideTransformProps)
|
||||
: () =>
|
||||
getChartTransformPropsRegistry().getAsPromise(
|
||||
selectorChartType,
|
||||
),
|
||||
},
|
||||
loading: (loadingProps: LoadingProps) =>
|
||||
renderLoading(loadingProps, selectorChartType),
|
||||
render: renderChart,
|
||||
});
|
||||
|
||||
// Trigger preloading.
|
||||
Renderer.preload();
|
||||
|
||||
return Renderer;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
/**
|
||||
* memoized function so it will not recompute
|
||||
* and return previous value
|
||||
* unless one of
|
||||
* - chartType
|
||||
* - overrideTransformProps
|
||||
* is changed.
|
||||
*/
|
||||
private createLoadableRenderer = createSelector(
|
||||
[
|
||||
(input: { chartType: string; overrideTransformProps?: TransformProps }) =>
|
||||
input.chartType,
|
||||
input => input.overrideTransformProps,
|
||||
],
|
||||
(chartType, overrideTransformProps) => {
|
||||
if (chartType) {
|
||||
const Renderer = createLoadableRenderer({
|
||||
loader: {
|
||||
Chart: () => getChartComponentRegistry().getAsPromise(chartType),
|
||||
transformProps: overrideTransformProps
|
||||
? () => Promise.resolve(overrideTransformProps)
|
||||
: () => getChartTransformPropsRegistry().getAsPromise(chartType),
|
||||
},
|
||||
),
|
||||
[renderLoading, renderChart],
|
||||
);
|
||||
loading: (loadingProps: LoadingProps) =>
|
||||
this.renderLoading(loadingProps, chartType),
|
||||
render: this.renderChart,
|
||||
});
|
||||
|
||||
const setRef = useCallback((container: HTMLElement | null) => {
|
||||
containerRef.current = container;
|
||||
}, []);
|
||||
// Trigger preloading.
|
||||
Renderer.preload();
|
||||
|
||||
return Renderer;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
},
|
||||
);
|
||||
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
private renderChart = (loaded: LoadedModules, props: RenderProps) => {
|
||||
const { Chart, transformProps } = loaded;
|
||||
const { chartProps, preTransformProps, postTransformProps } = props;
|
||||
|
||||
return (
|
||||
<Chart
|
||||
{...this.processChartProps({
|
||||
chartProps,
|
||||
preTransformProps,
|
||||
transformProps,
|
||||
postTransformProps,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoading = (loadingProps: LoadingProps, chartType: string) => {
|
||||
const { error } = loadingProps;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{chartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
private setRef = (container: HTMLElement | null) => {
|
||||
this.container = container;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
preTransformProps,
|
||||
postTransformProps,
|
||||
chartProps = BLANK_CHART_PROPS,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
} = this.props;
|
||||
|
||||
// Create LoadableRenderer and start preloading
|
||||
// the lazy-loaded Chart components
|
||||
const Renderer = createLoadableRendererSelector({
|
||||
chartType,
|
||||
overrideTransformProps,
|
||||
});
|
||||
const Renderer = this.createLoadableRenderer(this.props);
|
||||
|
||||
// Do not render if chartProps is set to null.
|
||||
// but the pre-loading has been started in createLoadableRendererSelector
|
||||
// but the pre-loading has been started in this.createLoadableRenderer
|
||||
// to prepare for rendering once chartProps becomes available.
|
||||
if (chartProps === null) {
|
||||
return null;
|
||||
@@ -315,7 +263,7 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...containerProps} ref={setRef}>
|
||||
<div {...containerProps} ref={this.setRef}>
|
||||
<Renderer
|
||||
preTransformProps={preTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
@@ -325,7 +273,5 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SuperChartCore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1160,7 +1160,7 @@ test('does not fire onChange if the same value is selected in single mode', asyn
|
||||
|
||||
// Reference for the bug this tests: https://github.com/apache/superset/pull/33043#issuecomment-2809419640
|
||||
test('typing and deleting the last character for a new option displays correctly', async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
render(<Select {...defaultProps} allowNewOptions />);
|
||||
|
||||
await open();
|
||||
|
||||
@@ -24,7 +24,6 @@ import { triggerResizeObserver } from 'resize-observer-polyfill';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { promiseTimeout, SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { WrapperProps } from '../../../src/chart/components/SuperChart';
|
||||
|
||||
import {
|
||||
@@ -119,7 +118,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -140,7 +138,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
FallbackComponent={CustomFallbackComponent}
|
||||
/>,
|
||||
);
|
||||
@@ -157,7 +154,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={handleError}
|
||||
/>,
|
||||
);
|
||||
@@ -182,7 +178,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={inactiveErrorHandler}
|
||||
/>
|
||||
</ErrorBoundary>,
|
||||
@@ -210,7 +205,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -291,7 +285,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -339,7 +332,6 @@ describe('SuperChart', () => {
|
||||
queriesData={DEFAULT_QUERIES_DATA}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -355,12 +347,7 @@ describe('SuperChart', () => {
|
||||
describe('supports NoResultsComponent', () => {
|
||||
test('renders NoResultsComponent when queriesData is missing', () => {
|
||||
render(
|
||||
<SuperChart
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
@@ -373,7 +360,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[{ data: null }]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -401,7 +387,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={100}
|
||||
height={100}
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -426,7 +411,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
Wrapper={MyWrapper}
|
||||
/>
|
||||
</div>,
|
||||
@@ -491,7 +475,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -517,7 +500,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: null }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -545,7 +527,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
noResults={<CustomNoResults />}
|
||||
@@ -575,7 +556,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
onErrorBoundary={onErrorBoundary}
|
||||
|
||||
@@ -227,28 +227,15 @@ describe('SuperChartCore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('processChartProps behavior', () => {
|
||||
test('applies identity pre/post transforms so chartProps reach overrideTransformProps unchanged', async () => {
|
||||
// When pre/post transform props are not specified, identity functions are used,
|
||||
// so the original chartProps should reach overrideTransformProps unchanged.
|
||||
// overrideTransformProps is used here as a probe to read the final chartProps;
|
||||
// it's not part of what's being tested for identity behavior.
|
||||
const chartProps2 = new ChartProps({
|
||||
queriesData: [{ message: 'identity-test' }],
|
||||
theme: supersetTheme,
|
||||
describe('.processChartProps()', () => {
|
||||
test('use identity functions for unspecified transforms', () => {
|
||||
const chart = new SuperChartCore({
|
||||
chartType: ChartKeys.DILIGENT,
|
||||
});
|
||||
|
||||
render(
|
||||
<SuperChartCore
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
chartProps={chartProps2}
|
||||
overrideTransformProps={props => props.queriesData[0]}
|
||||
/>,
|
||||
const chartProps2 = new ChartProps();
|
||||
expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
|
||||
chartProps2,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('identity-test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,108 +25,22 @@
|
||||
*
|
||||
* Run locally:
|
||||
* cd superset-frontend
|
||||
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
|
||||
* npm run docs:screenshots
|
||||
*
|
||||
* Or directly:
|
||||
* npx playwright test --config=playwright/generators/playwright.config.ts docs/
|
||||
*
|
||||
* Screenshots are saved under docs/static/img/.
|
||||
* As new screenshots are scripted, entries are removed from screenshot-manifest.yaml
|
||||
* and the output path moves from that manifest into the test below.
|
||||
* Screenshots are saved to docs/static/img/screenshots/.
|
||||
*/
|
||||
|
||||
import path from 'path';
|
||||
import { Page } from '@playwright/test';
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { URL } from '../../utils/urls';
|
||||
import { apiDelete, apiGet } from '../../helpers/api/requests';
|
||||
|
||||
const DOCS_STATIC = path.resolve(__dirname, '../../../../docs/static/img');
|
||||
const SCREENSHOTS_DIR = path.join(DOCS_STATIC, 'screenshots');
|
||||
const TUTORIAL_DIR = path.join(DOCS_STATIC, 'tutorial');
|
||||
|
||||
/**
|
||||
* Waits for animations and async renders to settle before taking a screenshot.
|
||||
* ECharts entry animations, image lazy-loading, and other async UI updates
|
||||
* require a short pause that can't be expressed as a deterministic wait condition.
|
||||
*/
|
||||
async function settle(page: Page, ms = 1000): Promise<void> {
|
||||
await page.waitForTimeout(ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Sales Dashboard (from example data) and waits for charts
|
||||
* to finish rendering. Used by several tutorial screenshots that show the
|
||||
* dashboard in view or edit mode.
|
||||
*/
|
||||
async function openSalesDashboard(page: Page): Promise<void> {
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Sales Dashboard');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
const dashboardLink = page.getByRole('link', { name: /sales dashboard/i });
|
||||
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
|
||||
await dashboardLink.click();
|
||||
|
||||
const dashboardWrapper = page.locator(
|
||||
'[data-test="dashboard-content-wrapper"]',
|
||||
);
|
||||
await expect(dashboardWrapper).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
dashboardWrapper.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all dashboards matching the given exact title, along with the
|
||||
* charts attached to them. Used by the save-flow test to clean up after
|
||||
* itself and to recover from prior failed runs (idempotent pre-cleanup).
|
||||
*
|
||||
* Only safe because the title is unique to the test ("Superset Duper
|
||||
* Sales Dashboard"); don't reuse this against titles that could match
|
||||
* example-data dashboards.
|
||||
*/
|
||||
async function deleteDashboardByTitle(
|
||||
page: Page,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
const filter = `(filters:!((col:dashboard_title,opr:eq,value:'${title}')))`;
|
||||
const resp = await apiGet(page, 'api/v1/dashboard/', {
|
||||
params: { q: filter },
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
if (!resp.ok()) return;
|
||||
const body = await resp.json();
|
||||
const dashboards: { id: number }[] = body.result || [];
|
||||
|
||||
for (const dash of dashboards) {
|
||||
const chartsResp = await apiGet(
|
||||
page,
|
||||
`api/v1/dashboard/${dash.id}/charts`,
|
||||
{ failOnStatusCode: false },
|
||||
);
|
||||
const chartIds: number[] = chartsResp.ok()
|
||||
? ((await chartsResp.json()).result || [])
|
||||
.map((c: { id?: number }) => c.id)
|
||||
.filter((id: unknown): id is number => typeof id === 'number')
|
||||
: [];
|
||||
|
||||
await apiDelete(page, `api/v1/dashboard/${dash.id}`, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
for (const id of chartIds) {
|
||||
await apiDelete(page, `api/v1/chart/${id}`, { failOnStatusCode: false });
|
||||
}
|
||||
}
|
||||
}
|
||||
const SCREENSHOTS_DIR = path.resolve(
|
||||
__dirname,
|
||||
'../../../../docs/static/img/screenshots',
|
||||
);
|
||||
|
||||
test('chart gallery screenshot', async ({ page }) => {
|
||||
await page.goto(URL.CHART_ADD);
|
||||
@@ -144,7 +58,6 @@ test('chart gallery screenshot', async ({ page }) => {
|
||||
vizGallery.locator('[data-test="viztype-selector-container"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
await settle(page);
|
||||
await vizGallery.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'gallery.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -152,7 +65,36 @@ test('chart gallery screenshot', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('dashboard screenshot', async ({ page }) => {
|
||||
await openSalesDashboard(page);
|
||||
// Navigate to Sales Dashboard via the dashboard list (slug is null)
|
||||
await page.goto(URL.DASHBOARD_LIST);
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Sales Dashboard');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
// Click the Sales Dashboard link
|
||||
const dashboardLink = page.getByRole('link', { name: /sales dashboard/i });
|
||||
await expect(dashboardLink).toBeVisible({ timeout: 10000 });
|
||||
await dashboardLink.click();
|
||||
|
||||
// Wait for dashboard to fully render
|
||||
const dashboardWrapper = page.locator(
|
||||
'[data-test="dashboard-content-wrapper"]',
|
||||
);
|
||||
await expect(dashboardWrapper).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Wait for chart holders to appear, then wait for all loading spinners to clear
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
dashboardWrapper.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 30000 });
|
||||
|
||||
// Wait for at least one chart to finish rendering (ECharts renders to canvas)
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Open the filter bar (collapsed by default)
|
||||
const expandButton = page.locator('[data-test="filter-bar__expand-button"]');
|
||||
@@ -167,8 +109,6 @@ test('dashboard screenshot', async ({ page }) => {
|
||||
).toBeVisible({ timeout: 5000 });
|
||||
}
|
||||
|
||||
// Allow ECharts entry animations to finish before capturing
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'dashboard.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -203,7 +143,6 @@ test('chart editor screenshot', async ({ page }) => {
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'explore.jpg'),
|
||||
type: 'jpeg',
|
||||
@@ -212,7 +151,7 @@ test('chart editor screenshot', async ({ page }) => {
|
||||
});
|
||||
|
||||
test('SQL Lab screenshot', async ({ page }) => {
|
||||
// SQL Lab has many interactive steps — allow extra time
|
||||
// SQL Lab has many interactive steps (schema, table, query, results) — allow extra time
|
||||
test.setTimeout(90000);
|
||||
await page.goto(URL.SQLLAB);
|
||||
|
||||
@@ -229,7 +168,34 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
}
|
||||
await expect(aceEditor).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Click the active query tab to ensure focus is on the editor pane
|
||||
// Select the "public" schema so we can pick a table from the left panel
|
||||
const schemaSelect = page.locator('#select-schema');
|
||||
await expect(schemaSelect).toBeEnabled({ timeout: 10000 });
|
||||
await schemaSelect.click({ force: true });
|
||||
await schemaSelect.fill('public');
|
||||
await page.getByRole('option', { name: 'public' }).click();
|
||||
|
||||
// Wait for table list to load after schema change, then select birth_names
|
||||
const tableSelectWrapper = page
|
||||
.locator('.ant-select')
|
||||
.filter({ has: page.locator('#select-table') });
|
||||
await expect(tableSelectWrapper).toBeVisible({ timeout: 10000 });
|
||||
await tableSelectWrapper.click();
|
||||
await page.keyboard.type('birth_names');
|
||||
// Wait for the filtered option to appear in the DOM, then select it
|
||||
const tableOption = page
|
||||
.locator('.ant-select-dropdown [role="option"]')
|
||||
.filter({ hasText: 'birth_names' });
|
||||
await expect(tableOption).toBeAttached({ timeout: 10000 });
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Wait for table schema to load and show columns in the left panel
|
||||
await expect(page.locator('[data-test="col-name"]').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Close the table dropdown by clicking elsewhere, then switch to the query tab
|
||||
await page.locator('[data-test="sql-editor-tabs"]').first().click();
|
||||
await page.getByText('Untitled Query').first().click();
|
||||
|
||||
// Write a multi-line SELECT with explicit columns to fill the editor
|
||||
@@ -239,8 +205,8 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
'SELECT\n ds,\n name,\n gender,\n state,\n num\nFROM birth_names\nLIMIT 100',
|
||||
);
|
||||
|
||||
// Run the query — use the stable data-test attribute on the action button
|
||||
const runButton = page.locator('[data-test="run-query-action"]');
|
||||
// Run the query
|
||||
const runButton = page.getByText('Run', { exact: true });
|
||||
await expect(runButton).toBeVisible();
|
||||
await runButton.click();
|
||||
|
||||
@@ -256,352 +222,9 @@ test('SQL Lab screenshot', async ({ page }) => {
|
||||
await page.mouse.move(0, 0);
|
||||
await expect(page.getByRole('tooltip')).toHaveCount(0, { timeout: 2000 });
|
||||
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(SCREENSHOTS_DIR, 'sql_lab.jpg'),
|
||||
type: 'jpeg',
|
||||
fullPage: true,
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tutorial screenshots
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test('datasets list screenshot', async ({ page }) => {
|
||||
await page.goto(URL.DATASET_LIST);
|
||||
|
||||
const table = page.locator('[data-test="listview-table"]');
|
||||
await expect(table).toBeVisible({ timeout: 15000 });
|
||||
// Wait for at least one visible data row (skip ant-table-measure-row which is always hidden)
|
||||
await expect(
|
||||
table.locator('tbody tr:not(.ant-table-measure-row)').first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Viewport screenshot (not fullPage) captures the SubMenu — showing the
|
||||
// "Datasets" nav item, Bulk Select button, and + Dataset button — plus the
|
||||
// top of the table. This is more informative than screenshotting the table alone.
|
||||
await settle(page);
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_08_sources_tables.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('chart type picker screenshot', async ({ page }) => {
|
||||
await page.goto(URL.CHART_ADD);
|
||||
|
||||
// Wait for the dataset step to appear (step title is first match; placeholder is second)
|
||||
await expect(page.getByText('Choose a dataset').first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
});
|
||||
|
||||
// Open the dataset selector and choose birth_names
|
||||
await page.getByTestId('Dataset').click();
|
||||
await page.keyboard.type('birth_names');
|
||||
// The dataset select uses a hidden ARIA listbox — the visible popup is a portal.
|
||||
// Wait for the first option to appear in the DOM, then select it via keyboard.
|
||||
await expect(
|
||||
page.locator('[role="listbox"] [role="option"]').first(),
|
||||
).toBeAttached({ timeout: 10000 });
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
// Open the chart gallery and wait for thumbnails to render
|
||||
await expect(page.getByText('Choose chart type')).toBeVisible({
|
||||
timeout: 10000,
|
||||
});
|
||||
await page.getByRole('tab', { name: 'All charts' }).click();
|
||||
const vizGallery = page.locator('.viz-gallery');
|
||||
await expect(vizGallery).toBeVisible();
|
||||
await expect(
|
||||
vizGallery.locator('[data-test="viztype-selector-container"]').first(),
|
||||
).toBeVisible();
|
||||
|
||||
// Select the Pivot Table chart type
|
||||
await vizGallery
|
||||
.locator('[data-test="viztype-selector-container"]')
|
||||
.filter({ hasText: 'Pivot Table' })
|
||||
.first()
|
||||
.click();
|
||||
|
||||
// Allow thumbnails to finish loading and selection state to render
|
||||
await settle(page);
|
||||
|
||||
// Viewport screenshot shows the dataset step (birth_names selected) and
|
||||
// the chart type gallery (Pivot Table highlighted)
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'create_pivot.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('publish button dashboard screenshot', async ({ page }) => {
|
||||
// Toggle Sales Dashboard to Draft, hover the label so the tooltip renders,
|
||||
// then capture the header area plus enough room below for the tooltip.
|
||||
// Always restores the dashboard to Published at the end.
|
||||
await openSalesDashboard(page);
|
||||
|
||||
const publishedLabel = page.getByText('Published', { exact: true }).first();
|
||||
await expect(publishedLabel).toBeVisible({ timeout: 10000 });
|
||||
await publishedLabel.click();
|
||||
|
||||
const draftLabel = page.getByText('Draft', { exact: true }).first();
|
||||
await expect(draftLabel).toBeVisible({ timeout: 10000 });
|
||||
|
||||
try {
|
||||
await draftLabel.hover();
|
||||
await expect(page.getByRole('tooltip')).toBeVisible({ timeout: 5000 });
|
||||
await settle(page, 500);
|
||||
|
||||
const headerBox = await page
|
||||
.locator('[data-test="dashboard-header-container"]')
|
||||
.boundingBox();
|
||||
if (!headerBox) {
|
||||
throw new Error('Could not locate dashboard header container');
|
||||
}
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'publish_button_dashboard.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: headerBox.x,
|
||||
y: headerBox.y,
|
||||
width: headerBox.width,
|
||||
height: headerBox.height + 140,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
// Restore: click Draft to re-publish so other runs start from a clean state
|
||||
await page.mouse.move(0, 0);
|
||||
await draftLabel.click();
|
||||
await expect(
|
||||
page.getByText('Published', { exact: true }).first(),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
});
|
||||
|
||||
test('edit button screenshot', async ({ page }) => {
|
||||
// Capture the right-side action buttons (Edit dashboard + "..." menu)
|
||||
// rather than the edit button in isolation.
|
||||
await openSalesDashboard(page);
|
||||
await settle(page);
|
||||
|
||||
const rightPanel = page.locator('.right-button-panel');
|
||||
await expect(rightPanel).toBeVisible({ timeout: 5000 });
|
||||
await rightPanel.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_edit_button.png'),
|
||||
type: 'png',
|
||||
});
|
||||
});
|
||||
|
||||
test('chart resize screenshot', async ({ page }) => {
|
||||
// Enter edit mode, start a resize drag on the right-edge handle, then
|
||||
// screenshot the chart mid-drag. While `DashboardGrid` is in the resizing
|
||||
// state it renders vertical `grid-column-guide` overlays across the grid
|
||||
// and the chart gets a blue `--resizing` outline — that's the state the
|
||||
// original tutorial screenshot was capturing.
|
||||
await openSalesDashboard(page);
|
||||
|
||||
const editButton = page.locator('[data-test="edit-dashboard-button"]');
|
||||
await expect(editButton).toBeVisible();
|
||||
await editButton.click();
|
||||
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-builder-sidepane"]'),
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const chart = page.locator('.dashboard-component-chart-holder').first();
|
||||
await expect(chart).toBeVisible();
|
||||
const chartBox = await chart.boundingBox();
|
||||
if (!chartBox) {
|
||||
throw new Error('Could not locate chart bounding box');
|
||||
}
|
||||
|
||||
// Hover over the chart so the on-hover action buttons (drag/trash/settings)
|
||||
// and resize handles become visible.
|
||||
await page.mouse.move(
|
||||
chartBox.x + chartBox.width / 2,
|
||||
chartBox.y + chartBox.height / 2,
|
||||
);
|
||||
await settle(page, 200);
|
||||
|
||||
// The right-edge handle is a `<span>` added by re-resizable with our
|
||||
// custom class. Locating it by class is more reliable than computing
|
||||
// coordinates from the chart-holder (which isn't the full resizable box).
|
||||
const rightHandle = page
|
||||
.locator('.resizable-container-handle--right')
|
||||
.first();
|
||||
await expect(rightHandle).toBeVisible();
|
||||
const handleBox = await rightHandle.boundingBox();
|
||||
if (!handleBox) {
|
||||
throw new Error('Could not locate right-edge resize handle');
|
||||
}
|
||||
const handleX = handleBox.x + handleBox.width / 2;
|
||||
const handleY = handleBox.y + handleBox.height / 2;
|
||||
|
||||
await page.mouse.move(handleX, handleY);
|
||||
await page.mouse.down();
|
||||
// Move far enough to snap at least one grid column, which puts
|
||||
// DashboardGrid into isResizing=true so the column guides render.
|
||||
await page.mouse.move(handleX + 80, handleY, { steps: 10 });
|
||||
await settle(page, 500);
|
||||
|
||||
// Clip to the chart area plus a left gutter for the hover action rail
|
||||
// and right padding that reaches past the dragged handle position.
|
||||
const leftGutter = 32;
|
||||
const rightPadding = 100;
|
||||
const topPadding = 16;
|
||||
const bottomPadding = 24;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_chart_resize.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: Math.max(0, chartBox.x - leftGutter),
|
||||
y: Math.max(0, chartBox.y - topPadding),
|
||||
width: chartBox.width + leftGutter + rightPadding,
|
||||
height: chartBox.height + topPadding + bottomPadding,
|
||||
},
|
||||
});
|
||||
|
||||
// Release back at the start to avoid persisting a size change. Edit-mode
|
||||
// changes aren't saved (we never click the dashboard Save button).
|
||||
await page.mouse.move(handleX, handleY, { steps: 6 });
|
||||
await page.mouse.up();
|
||||
});
|
||||
|
||||
test('save flow and first dashboard screenshots', async ({ page }) => {
|
||||
// Captures two linked tutorial screenshots in a single flow so the second
|
||||
// faithfully shows the dashboard the user just created:
|
||||
// 1. tutorial_save_slice.png — Save modal with the "Add to dashboard"
|
||||
// dropdown surfacing a creatable option for a new dashboard.
|
||||
// 2. tutorial_first_dashboard.png — the freshly-created dashboard with
|
||||
// the single saved chart (matches the tutorial narrative).
|
||||
//
|
||||
// Creates and then deletes a "Superset Duper Sales Dashboard" dashboard
|
||||
// plus the duplicate chart it owns. Pre-cleans in case a prior run failed.
|
||||
const NEW_DASHBOARD_NAME = 'Superset Duper Sales Dashboard';
|
||||
await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME);
|
||||
|
||||
// 1100px is wide enough to show the full "Superset Duper Sales Dashboard"
|
||||
// title alongside the header actions without truncation.
|
||||
await page.setViewportSize({ width: 1100, height: 800 });
|
||||
await page.goto(URL.CHART_LIST);
|
||||
|
||||
const searchInput = page.getByPlaceholder('Type a value');
|
||||
await expect(searchInput).toBeVisible({ timeout: 15000 });
|
||||
await searchInput.fill('Scatter Plot');
|
||||
await searchInput.press('Enter');
|
||||
|
||||
const chartLink = page.getByRole('link', { name: /scatter plot/i });
|
||||
await expect(chartLink).toBeVisible({ timeout: 10000 });
|
||||
await chartLink.click();
|
||||
|
||||
await page.waitForURL('**/explore/**', { timeout: 15000 });
|
||||
const sliceContainer = page.locator('[data-test="slice-container"]');
|
||||
await expect(sliceContainer).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
sliceContainer.locator('[data-test="loading-indicator"]'),
|
||||
).toHaveCount(0, { timeout: 15000 });
|
||||
|
||||
const saveButton = page.locator('[data-test="query-save-button"]');
|
||||
await expect(saveButton).toBeVisible({ timeout: 10000 });
|
||||
await saveButton.click();
|
||||
|
||||
const modal = page.locator('.ant-modal-content').filter({
|
||||
has: page.locator('[data-test="save-modal-body"]'),
|
||||
});
|
||||
await expect(modal).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Open the "Add to dashboard" select and type a new dashboard name so
|
||||
// the dropdown surfaces the creatable option.
|
||||
const dashboardSelect = page.getByRole('combobox', {
|
||||
name: /select a dashboard/i,
|
||||
});
|
||||
await dashboardSelect.click();
|
||||
await page.keyboard.type(NEW_DASHBOARD_NAME);
|
||||
|
||||
// Ant Design portals the visible dropdown with the class
|
||||
// `.ant-select-item-option` on each option (distinct from the hidden
|
||||
// ARIA listbox options rendered inside the combobox itself).
|
||||
const createOption = page
|
||||
.locator('.ant-select-item-option')
|
||||
.filter({ hasText: NEW_DASHBOARD_NAME });
|
||||
await expect(createOption).toBeVisible({ timeout: 10000 });
|
||||
await settle(page);
|
||||
|
||||
try {
|
||||
// Screenshot 1: save modal + portaled dropdown.
|
||||
const modalBox = await modal.boundingBox();
|
||||
const optionBox = await createOption.boundingBox();
|
||||
if (!modalBox || !optionBox) {
|
||||
throw new Error('Could not locate save modal or create-option');
|
||||
}
|
||||
const padding = 16;
|
||||
const top = Math.max(0, modalBox.y - padding);
|
||||
const bottom = optionBox.y + optionBox.height + padding;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_save_slice.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: Math.max(0, modalBox.x - padding),
|
||||
y: top,
|
||||
width: modalBox.width + padding * 2,
|
||||
height: bottom - top,
|
||||
},
|
||||
});
|
||||
|
||||
// Pick the creatable option, then click "Save & go to dashboard" so the
|
||||
// backend creates the dashboard + slice and redirects us to the new one.
|
||||
await createOption.click();
|
||||
const saveAndGotoBtn = page.locator('#btn_modal_save_goto_dash');
|
||||
await expect(saveAndGotoBtn).toBeEnabled({ timeout: 5000 });
|
||||
await saveAndGotoBtn.click();
|
||||
|
||||
await page.waitForURL(/\/dashboard\/[^/]+\/?/, { timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('[data-test="dashboard-content-wrapper"]'),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder').first(),
|
||||
).toBeVisible({ timeout: 30000 });
|
||||
await expect(
|
||||
page.locator('.dashboard-component-chart-holder canvas').first(),
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Dismiss the "Chart [X] has been saved" toast so it doesn't appear in
|
||||
// the screenshot. The close button is inside the toast container.
|
||||
const toast = page.locator('[data-test="toast-container"]').first();
|
||||
if (await toast.isVisible().catch(() => false)) {
|
||||
await toast.locator('.toast__close').click();
|
||||
await expect(toast).toBeHidden({ timeout: 5000 });
|
||||
}
|
||||
await settle(page);
|
||||
|
||||
// Screenshot 2: the newly-created single-chart dashboard (title + chart).
|
||||
const headerBox = await page
|
||||
.locator('[data-test="dashboard-header-wrapper"]')
|
||||
.boundingBox();
|
||||
const chartBox = await page
|
||||
.locator('.dashboard-component-chart-holder')
|
||||
.first()
|
||||
.boundingBox();
|
||||
if (!headerBox || !chartBox) {
|
||||
throw new Error('Could not locate dashboard header or chart');
|
||||
}
|
||||
// Trim right edge to just past the chart so the screenshot isn't padded
|
||||
// with empty grid space.
|
||||
const rightPadding = 16;
|
||||
await page.screenshot({
|
||||
path: path.join(TUTORIAL_DIR, 'tutorial_first_dashboard.png'),
|
||||
type: 'png',
|
||||
clip: {
|
||||
x: 0,
|
||||
y: headerBox.y,
|
||||
width: Math.min(1100, chartBox.x + chartBox.width + rightPadding),
|
||||
height: chartBox.y + chartBox.height - headerBox.y + 16,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await deleteDashboardByTitle(page, NEW_DASHBOARD_NAME);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -64,7 +64,6 @@ export default defineConfig({
|
||||
name: 'docs-generators',
|
||||
use: {
|
||||
browserName: 'chromium',
|
||||
baseURL, // explicit here so globalSetup can read it from config.projects[0].use.baseURL
|
||||
testIdAttribute: 'data-test',
|
||||
storageState: path.resolve(__dirname, '../.auth/user.json'),
|
||||
},
|
||||
|
||||
@@ -1,220 +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 { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
const FILTER_COLUMN = 'gender';
|
||||
|
||||
async function findDatasetIdByName(page: any, name: string): Promise<number> {
|
||||
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
testWithAssets(
|
||||
'Clear all filters waits for Apply (sc-105059)',
|
||||
async ({ page, testAssets }) => {
|
||||
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
|
||||
|
||||
// Create a chart that the dashboard filter will target
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'big_number_total',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
header_font_size: 0.4,
|
||||
subheader_font_size: 0.15,
|
||||
};
|
||||
const chartResp = await apiPost(page, 'api/v1/chart/', {
|
||||
slice_name: `clear_all_repro_${Date.now()}`,
|
||||
viz_type: 'big_number_total',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chart = await chartResp.json();
|
||||
const chartId: number = chart.id ?? chart.result?.id;
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
// Create dashboard with chart in position_json and a native filter in json_metadata
|
||||
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
|
||||
const chartLayoutKey = `CHART-${chartId}`;
|
||||
const positionJson = {
|
||||
DASHBOARD_VERSION_KEY: 'v2',
|
||||
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
|
||||
GRID_ID: {
|
||||
type: 'GRID',
|
||||
id: 'GRID_ID',
|
||||
children: ['ROW-1'],
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
'ROW-1': {
|
||||
type: 'ROW',
|
||||
id: 'ROW-1',
|
||||
children: [chartLayoutKey],
|
||||
parents: ['ROOT_ID', 'GRID_ID'],
|
||||
meta: { background: 'BACKGROUND_TRANSPARENT' },
|
||||
},
|
||||
[chartLayoutKey]: {
|
||||
type: 'CHART',
|
||||
id: chartLayoutKey,
|
||||
children: [],
|
||||
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
|
||||
meta: {
|
||||
chartId,
|
||||
width: 6,
|
||||
height: 50,
|
||||
sliceName: 'clear_all_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const jsonMetadata = {
|
||||
native_filter_configuration: [
|
||||
{
|
||||
id: filterId,
|
||||
name: 'Gender',
|
||||
filterType: 'filter_select',
|
||||
type: 'NATIVE_FILTER',
|
||||
targets: [
|
||||
{
|
||||
datasetId,
|
||||
column: { name: FILTER_COLUMN },
|
||||
},
|
||||
],
|
||||
controlValues: {
|
||||
multiSelect: false,
|
||||
enableEmptyFilter: false,
|
||||
defaultToFirstItem: false,
|
||||
inverseSelection: false,
|
||||
searchAllOptions: false,
|
||||
},
|
||||
defaultDataMask: { filterState: {}, extraFormData: {} },
|
||||
cascadeParentIds: [],
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
],
|
||||
chart_configuration: {},
|
||||
cross_filters_enabled: false,
|
||||
global_chart_configuration: {
|
||||
scope: { rootPath: ['ROOT_ID'], excluded: [] },
|
||||
chartsInScope: [chartId],
|
||||
},
|
||||
};
|
||||
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `clear_all_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify(jsonMetadata),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
// Associate chart with the dashboard so it actually renders
|
||||
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
expect(linkResp.ok()).toBe(true);
|
||||
|
||||
// Visit dashboard
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// The Gender select should be visible in the filter bar
|
||||
const filterCombobox = page
|
||||
.locator('[data-test="form-item-value"]')
|
||||
.first()
|
||||
.locator('[role="combobox"]');
|
||||
await filterCombobox.click();
|
||||
await page
|
||||
.locator('.ant-select-item-option', { hasText: /^boy$/ })
|
||||
.first()
|
||||
.click();
|
||||
// Close the dropdown
|
||||
await page.keyboard.press('Escape');
|
||||
|
||||
const applyBtn = page.locator(
|
||||
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
|
||||
);
|
||||
|
||||
// Wait for chart data to come back after Apply
|
||||
const firstApplyResponse = page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/api/v1/chart/data') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await firstApplyResponse;
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
// Now track POST /api/v1/chart/data requests around Clear All
|
||||
const postsAfterClearAll: string[] = [];
|
||||
const handler = (req: any) => {
|
||||
if (
|
||||
req.url().includes('/api/v1/chart/data') &&
|
||||
req.method() === 'POST'
|
||||
) {
|
||||
postsAfterClearAll.push(req.url());
|
||||
}
|
||||
};
|
||||
page.on('request', handler);
|
||||
|
||||
const clearBtn = page.locator('[data-test="filter-bar__clear-button"]');
|
||||
await clearBtn.click();
|
||||
|
||||
// Allow time for any debounced reload to fire if the bug is present
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
page.off('request', handler);
|
||||
|
||||
// BUG: on master, the Clear All triggers an immediate dispatch which
|
||||
// re-runs the chart query before the user clicks Apply. After the fix,
|
||||
// no chart/data request should fire until Apply is clicked.
|
||||
expect(
|
||||
postsAfterClearAll,
|
||||
'Clear All must not reload charts until Apply is clicked',
|
||||
).toEqual([]);
|
||||
|
||||
// After Apply, the chart should reload
|
||||
const applyAfterClearPromise = page.waitForResponse(
|
||||
r =>
|
||||
r.url().includes('/api/v1/chart/data') &&
|
||||
r.request().method() === 'POST',
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
await applyBtn.first().click();
|
||||
await applyAfterClearPromise;
|
||||
},
|
||||
);
|
||||
@@ -494,12 +494,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
@@ -593,12 +587,18 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars for all columns'),
|
||||
label: t('Show cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -612,7 +612,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'align_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Align +/- for all columns'),
|
||||
label: t('Align +/-'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t(
|
||||
@@ -626,7 +626,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/- for all columns'),
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
|
||||
@@ -1,83 +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 { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import htmlTextFilterValueGetter, {
|
||||
htmlTextComparator,
|
||||
} from './htmlTextFilterValueGetter';
|
||||
|
||||
const makeParams = (value: unknown): ValueGetterParams =>
|
||||
({
|
||||
data: { foo: value },
|
||||
colDef: { field: 'foo' },
|
||||
}) as unknown as ValueGetterParams;
|
||||
|
||||
test('htmlTextFilterValueGetter extracts visible text from HTML anchor', () => {
|
||||
expect(
|
||||
htmlTextFilterValueGetter(
|
||||
makeParams(
|
||||
'<a href="https://jira.example.com/123/S18_3232">S18_3232</a>',
|
||||
),
|
||||
),
|
||||
).toBe('S18_3232');
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter strips nested HTML markup', () => {
|
||||
expect(
|
||||
htmlTextFilterValueGetter(
|
||||
makeParams('<div><strong>Hello</strong> <em>World</em></div>'),
|
||||
),
|
||||
).toBe('Hello World');
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter passes plain strings through', () => {
|
||||
expect(htmlTextFilterValueGetter(makeParams('plain value'))).toBe(
|
||||
'plain value',
|
||||
);
|
||||
});
|
||||
|
||||
test('htmlTextFilterValueGetter passes non-string values through', () => {
|
||||
expect(htmlTextFilterValueGetter(makeParams(42))).toBe(42);
|
||||
expect(htmlTextFilterValueGetter(makeParams(null))).toBeNull();
|
||||
expect(htmlTextFilterValueGetter(makeParams(undefined))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('htmlTextComparator orders by visible text, not raw HTML', () => {
|
||||
// URL prefixes (zzz vs bbb) would flip the order under raw-HTML sort,
|
||||
// but the visible labels (S700_4002 vs S72_3212) sort the other way.
|
||||
const left = '<a href="https://jira.example.com/zzz/S700_4002">S700_4002</a>';
|
||||
const right = '<a href="https://jira.example.com/bbb/S72_3212">S72_3212</a>';
|
||||
expect(htmlTextComparator(left, right)).toBeLessThan(0);
|
||||
});
|
||||
|
||||
test('htmlTextComparator handles nulls and numbers', () => {
|
||||
expect(htmlTextComparator(null, null)).toBe(0);
|
||||
expect(htmlTextComparator(null, 'x')).toBeLessThan(0);
|
||||
expect(htmlTextComparator('x', null)).toBeGreaterThan(0);
|
||||
expect(htmlTextComparator(1, 2)).toBeLessThan(0);
|
||||
expect(htmlTextComparator(2, 1)).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('htmlTextComparator preserves default codepoint ordering for plain strings', () => {
|
||||
// AG Grid's default string comparator orders by codepoint, so 'Z' (90)
|
||||
// sorts before 'a' (97). A locale-aware comparator would flip this —
|
||||
// verify we match the default so plain string columns are unaffected.
|
||||
expect(htmlTextComparator('Z', 'a')).toBeLessThan(0);
|
||||
expect(htmlTextComparator('a', 'Z')).toBeGreaterThan(0);
|
||||
expect(htmlTextComparator('apple', 'banana')).toBeLessThan(0);
|
||||
});
|
||||
@@ -1,74 +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 { isProbablyHTML, sanitizeHtml } from '@superset-ui/core';
|
||||
import { ValueGetterParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
|
||||
const stripHtmlToText = (html: string): string => {
|
||||
const doc = new DOMParser().parseFromString(sanitizeHtml(html), 'text/html');
|
||||
return (doc.body.textContent || '').trim();
|
||||
};
|
||||
|
||||
// Cache the comparator-ready form per raw string. Both the HTML-detection
|
||||
// step (`isProbablyHTML`, which itself invokes DOMParser for HTML-looking
|
||||
// values) and the extraction step (`stripHtmlToText`, also DOMParser) are
|
||||
// expensive; sort runs `O(n log n)` comparator calls against the same set
|
||||
// of cell values. Memoizing the combined detection + extraction means each
|
||||
// unique cell value pays the cost once per session. Module-level scope;
|
||||
// bounded by the count of unique string cell values seen.
|
||||
const comparableTextCache = new Map<string, string>();
|
||||
|
||||
const toComparableText = (raw: string): string => {
|
||||
const cached = comparableTextCache.get(raw);
|
||||
if (cached !== undefined) return cached;
|
||||
const normalized = isProbablyHTML(raw) ? stripHtmlToText(raw) : raw;
|
||||
comparableTextCache.set(raw, normalized);
|
||||
return normalized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the visible-text representation of an HTML cell value so AG Grid
|
||||
* filters and sort operate on what the user sees, not the underlying markup.
|
||||
* Pass-through for non-HTML values.
|
||||
*/
|
||||
const htmlTextFilterValueGetter = (params: ValueGetterParams) => {
|
||||
const raw = params.data?.[params.colDef.field as string];
|
||||
return typeof raw === 'string' ? toComparableText(raw) : raw;
|
||||
};
|
||||
|
||||
/**
|
||||
* Comparator that mirrors AG Grid's default string comparator (codepoint
|
||||
* order, nulls first), but extracts visible text from HTML values first
|
||||
* so HTML cells sort by their displayed label. Plain (non-HTML) values
|
||||
* pass through unchanged, preserving default ordering — e.g. 'Z' still
|
||||
* sorts before 'a' as it does under the default comparator.
|
||||
*/
|
||||
export const htmlTextComparator = (a: unknown, b: unknown): number => {
|
||||
const toText = (v: unknown) =>
|
||||
typeof v === 'string' ? toComparableText(v) : v;
|
||||
const aT = toText(a);
|
||||
const bT = toText(b);
|
||||
if (aT == null && bT == null) return 0;
|
||||
if (aT == null) return -1;
|
||||
if (bT == null) return 1;
|
||||
if (typeof aT === 'number' && typeof bT === 'number') return aT - bT;
|
||||
if (aT === bT) return 0;
|
||||
return aT < bT ? -1 : 1;
|
||||
};
|
||||
|
||||
export default htmlTextFilterValueGetter;
|
||||
@@ -32,9 +32,6 @@ import {
|
||||
} from '../types';
|
||||
import getCellClass from './getCellClass';
|
||||
import filterValueGetter from './filterValueGetter';
|
||||
import htmlTextFilterValueGetter, {
|
||||
htmlTextComparator,
|
||||
} from './htmlTextFilterValueGetter';
|
||||
import dateFilterComparator from './dateFilterComparator';
|
||||
import DateWithFormatter from './DateWithFormatter';
|
||||
import { getAggFunc } from './getAggFunc';
|
||||
@@ -320,24 +317,6 @@ export const useColDefs = ({
|
||||
...(isPercentMetric && {
|
||||
filterValueGetter,
|
||||
}),
|
||||
...(dataType === GenericDataType.String &&
|
||||
!serverPagination && {
|
||||
// HTML cells (e.g. anchor markup) are rendered by TextCellRenderer
|
||||
// via dangerouslySetInnerHTML; without these the filter and sort
|
||||
// operate on raw HTML so the URL inside the markup dictates order
|
||||
// and the "Contains" filter matches against the raw HTML string.
|
||||
//
|
||||
// Gated on !serverPagination: in server-pagination mode sort and
|
||||
// filter are both delegated to the backend (which sees raw HTML
|
||||
// in the database), so applying the visible-text getter only on
|
||||
// the client would create a mismatch where the typed filter
|
||||
// value is stripped client-side but the server query still
|
||||
// operates on the raw HTML. Server-paginated tables with HTML
|
||||
// columns are out of scope for this fix and would require
|
||||
// server-side handling.
|
||||
filterValueGetter: htmlTextFilterValueGetter,
|
||||
comparator: htmlTextComparator,
|
||||
}),
|
||||
...(dataType === GenericDataType.Temporal && {
|
||||
// Use dateFilterValueGetter so AG Grid correctly identifies null dates for blank filter
|
||||
filterValueGetter: dateFilterValueGetter,
|
||||
|
||||
@@ -56,7 +56,6 @@ jest.mock('@superset-ui/chart-controls', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
BRAND_COLOR: '#00A699',
|
||||
GenericDataType: { Temporal: 2, String: 1 },
|
||||
extractTimegrain: jest.fn(() => 'P1D'),
|
||||
getMetricLabel: jest.fn(metric => metric),
|
||||
@@ -281,30 +280,4 @@ describe('BigNumberWithTrendline transformProps', () => {
|
||||
expect(result.bigNumber).toBe(360);
|
||||
expect(result.subheader).toBe('50.0% WoW');
|
||||
});
|
||||
|
||||
test('should not crash and should return undefined mainColor when colorPicker is null', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 1, value: 100 },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: ['TEMPORAL', 'NUMERIC'],
|
||||
},
|
||||
],
|
||||
formData: { ...baseFormData, colorPicker: null },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.mainColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
BRAND_COLOR,
|
||||
extractTimegrain,
|
||||
getNumberFormatter,
|
||||
NumberFormats,
|
||||
@@ -141,9 +140,8 @@ export default function transformProps(
|
||||
const compareLag = Number(compareLag_) || 0;
|
||||
let formattedSubheader = subheader;
|
||||
|
||||
const mainColor = colorPicker
|
||||
? `rgb(${colorPicker.r}, ${colorPicker.g}, ${colorPicker.b})`
|
||||
: undefined;
|
||||
const { r, g, b } = colorPicker;
|
||||
const mainColor = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
const xAxisLabel = getXAxisLabel(rawFormData) as string;
|
||||
let trendLineData: TimeSeriesDatum[] | undefined;
|
||||
@@ -292,12 +290,12 @@ export default function transformProps(
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
showSymbol: false,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import { TableRenderer } from './TableRenderers';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type PivotTableProps = ComponentProps<typeof TableRenderer>;
|
||||
|
||||
function PivotTable(props: PivotTableProps) {
|
||||
return <TableRenderer {...props} />;
|
||||
class PivotTable extends PureComponent<PivotTableProps> {
|
||||
render() {
|
||||
return <TableRenderer {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(PivotTable);
|
||||
export default PivotTable;
|
||||
|
||||
@@ -552,12 +552,6 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'column_config',
|
||||
@@ -654,12 +648,18 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
},
|
||||
],
|
||||
],
|
||||
},
|
||||
{
|
||||
label: t('Visual formatting'),
|
||||
expanded: true,
|
||||
controlSetRows: [
|
||||
[
|
||||
{
|
||||
name: 'show_cell_bars',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Show cell bars for all columns'),
|
||||
label: t('Show cell bars'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -673,7 +673,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'align_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Align +/- for all columns'),
|
||||
label: t('Align +/-'),
|
||||
renderTrigger: true,
|
||||
default: false,
|
||||
description: t(
|
||||
@@ -687,7 +687,7 @@ const config: ControlPanelConfig = {
|
||||
name: 'color_pn',
|
||||
config: {
|
||||
type: 'CheckboxControl',
|
||||
label: t('Add colors to cell bars for +/- for all columns'),
|
||||
label: t('Add colors to cell bars for +/-'),
|
||||
renderTrigger: true,
|
||||
default: true,
|
||||
description: t(
|
||||
@@ -796,63 +796,45 @@ const config: ControlPanelConfig = {
|
||||
},
|
||||
);
|
||||
}
|
||||
const { colnames: queryColnames, coltypes: queryColtypes } =
|
||||
const { colnames, coltypes } =
|
||||
chart?.queriesResponse?.[0] ?? {};
|
||||
const hasQueryColumns =
|
||||
Array.isArray(queryColnames) &&
|
||||
Array.isArray(queryColtypes) &&
|
||||
queryColnames.length > 0;
|
||||
|
||||
// Fall back to datasource columns when query results are empty
|
||||
const datasourceColumns = ensureIsArray(
|
||||
(explore?.datasource as Dataset)?.columns,
|
||||
);
|
||||
const colnames = hasQueryColumns
|
||||
? queryColnames
|
||||
: datasourceColumns.map((col: ColumnMeta) => col.column_name);
|
||||
const coltypes = hasQueryColumns
|
||||
? queryColtypes
|
||||
: datasourceColumns.map(
|
||||
(col: ColumnMeta) =>
|
||||
col.type_generic ?? GenericDataType.String,
|
||||
);
|
||||
|
||||
const hasColumns = colnames.length > 0;
|
||||
const allColumns = hasColumns
|
||||
? [
|
||||
{
|
||||
value: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
label: t('entire row'),
|
||||
dataType: GenericDataType.String,
|
||||
},
|
||||
...colnames.map((colname: string, index: number) => ({
|
||||
value: colname,
|
||||
label: Array.isArray(verboseMap)
|
||||
? colname
|
||||
: (verboseMap[colname] ?? colname),
|
||||
dataType: coltypes[index],
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
const numericColumns = hasColumns
|
||||
? colnames.reduce((acc, colname, index) => {
|
||||
if (
|
||||
coltypes[index] === GenericDataType.Numeric ||
|
||||
(!hasTimeComparison &&
|
||||
(coltypes[index] === GenericDataType.String ||
|
||||
coltypes[index] === GenericDataType.Boolean))
|
||||
) {
|
||||
acc.push({
|
||||
const allColumns =
|
||||
Array.isArray(colnames) && Array.isArray(coltypes)
|
||||
? [
|
||||
{
|
||||
value: ObjectFormattingEnum.ENTIRE_ROW,
|
||||
label: t('entire row'),
|
||||
dataType: GenericDataType.String,
|
||||
},
|
||||
...colnames.map((colname: string, index: number) => ({
|
||||
value: colname,
|
||||
label: Array.isArray(verboseMap)
|
||||
? colname
|
||||
: (verboseMap[colname] ?? colname),
|
||||
dataType: coltypes[index],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
: [];
|
||||
})),
|
||||
]
|
||||
: [];
|
||||
const numericColumns =
|
||||
Array.isArray(colnames) && Array.isArray(coltypes)
|
||||
? colnames.reduce((acc, colname, index) => {
|
||||
if (
|
||||
coltypes[index] === GenericDataType.Numeric ||
|
||||
(!hasTimeComparison &&
|
||||
(coltypes[index] === GenericDataType.String ||
|
||||
coltypes[index] === GenericDataType.Boolean))
|
||||
) {
|
||||
acc.push({
|
||||
value: colname,
|
||||
label: Array.isArray(verboseMap)
|
||||
? colname
|
||||
: (verboseMap[colname] ?? colname),
|
||||
dataType: coltypes[index],
|
||||
});
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
: [];
|
||||
const columnOptions = hasTimeComparison
|
||||
? processComparisonColumns(
|
||||
numericColumns || [],
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
ControlPanelState,
|
||||
ControlState,
|
||||
ColorSchemeEnum,
|
||||
ObjectFormattingEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import config from '../src/controlPanel';
|
||||
|
||||
@@ -56,12 +55,11 @@ const createMockControlState = (value: string[] | undefined): ControlState => ({
|
||||
|
||||
const createMockExplore = (
|
||||
timeCompareValue: string[] | undefined,
|
||||
datasourceColumns: Partial<Dataset>['columns'] = [],
|
||||
): ControlPanelState => ({
|
||||
slice: { slice_id: 123 },
|
||||
datasource: {
|
||||
verbose_map: { col1: 'Column 1', col2: 'Column 2' },
|
||||
columns: datasourceColumns,
|
||||
columns: [],
|
||||
} as Partial<Dataset> as Dataset,
|
||||
controls: {
|
||||
time_compare: createMockControlState(timeCompareValue),
|
||||
@@ -208,144 +206,3 @@ test('static extraColorChoices removed from config', () => {
|
||||
|
||||
expect(controlConfig?.extraColorChoices).toBeUndefined();
|
||||
});
|
||||
|
||||
test('columnOptions falls back to datasource columns when queriesResponse is empty', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const datasourceColumns = [
|
||||
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
|
||||
{ column_name: 'name', type_generic: GenericDataType.String },
|
||||
];
|
||||
const explore = createMockExplore(undefined, datasourceColumns);
|
||||
const chart = { chartStatus: 'success' as const, queriesResponse: null };
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.columnOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'revenue' }),
|
||||
expect.objectContaining({ value: 'name' }),
|
||||
]),
|
||||
);
|
||||
expect(result.allColumns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'revenue' }),
|
||||
expect.objectContaining({ value: 'name' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('columnOptions prefers queriesResponse over datasource columns', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const datasourceColumns = [
|
||||
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
|
||||
{ column_name: 'extra_col', type_generic: GenericDataType.String },
|
||||
];
|
||||
const explore = createMockExplore(undefined, datasourceColumns);
|
||||
const chart = createMockChart();
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.columnOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: 'col1' }),
|
||||
expect.objectContaining({ value: 'col2' }),
|
||||
]),
|
||||
);
|
||||
expect(result.columnOptions).not.toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'extra_col' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('columnOptions falls back to datasource when queriesResponse has empty colnames', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const datasourceColumns = [
|
||||
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
|
||||
];
|
||||
const explore = createMockExplore(undefined, datasourceColumns);
|
||||
const chart = {
|
||||
chartStatus: 'success' as const,
|
||||
queriesResponse: [{ colnames: [], coltypes: [] }],
|
||||
};
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.columnOptions).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'revenue' })]),
|
||||
);
|
||||
});
|
||||
|
||||
test('columnOptions returns empty when both queriesResponse and datasource have no columns', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const explore = createMockExplore(undefined, []);
|
||||
const chart = { chartStatus: 'success' as const, queriesResponse: null };
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.columnOptions).toEqual([]);
|
||||
expect(result.allColumns).toEqual([]);
|
||||
});
|
||||
|
||||
test('allColumns includes ENTIRE_ROW when falling back to datasource columns', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const datasourceColumns = [
|
||||
{ column_name: 'revenue', type_generic: GenericDataType.Numeric },
|
||||
];
|
||||
const explore = createMockExplore(undefined, datasourceColumns);
|
||||
const chart = { chartStatus: 'success' as const, queriesResponse: null };
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.allColumns).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ value: ObjectFormattingEnum.ENTIRE_ROW }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
test('columnOptions defaults type_generic to String when missing from datasource columns', () => {
|
||||
const controlConfig = findConditionalFormattingControl();
|
||||
expect(controlConfig).toBeTruthy();
|
||||
|
||||
const datasourceColumns = [{ column_name: 'untyped_col' }];
|
||||
const explore = createMockExplore(undefined, datasourceColumns);
|
||||
const chart = { chartStatus: 'success' as const, queriesResponse: null };
|
||||
const result = controlConfig!.mapStateToProps!(
|
||||
explore,
|
||||
createMockControlStateForConditionalFormatting(),
|
||||
chart,
|
||||
);
|
||||
|
||||
expect(result.columnOptions).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
value: 'untyped_col',
|
||||
dataType: GenericDataType.String,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -856,7 +856,7 @@ export function loadQueryEditor(queryEditor: QueryEditor): SqlLabAction {
|
||||
return { type: LOAD_QUERY_EDITOR, queryEditor };
|
||||
}
|
||||
|
||||
export interface TableSchema {
|
||||
interface TableSchema {
|
||||
description: {
|
||||
columns: unknown[];
|
||||
selectStar: string;
|
||||
@@ -1284,7 +1284,7 @@ export function addTable(
|
||||
};
|
||||
}
|
||||
|
||||
export interface NewTable {
|
||||
interface NewTable {
|
||||
id?: string;
|
||||
dbId: number | string;
|
||||
catalog?: string | null;
|
||||
@@ -1346,7 +1346,7 @@ export function runTablePreviewQuery(
|
||||
};
|
||||
}
|
||||
|
||||
export interface TableMetaData {
|
||||
interface TableMetaData {
|
||||
columns?: unknown[];
|
||||
selectStar?: string;
|
||||
primaryKey?: unknown;
|
||||
@@ -1660,7 +1660,7 @@ export function createDatasourceFailed(err: string): SqlLabAction {
|
||||
return { type: CREATE_DATASOURCE_FAILED, err };
|
||||
}
|
||||
|
||||
export interface VizOptions {
|
||||
interface VizOptions {
|
||||
dbId: number;
|
||||
catalog?: string | null;
|
||||
schema: string;
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('QueryAutoRefresh', () => {
|
||||
const refreshApi = 'glob:*/api/v1/query/updated_since?*';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { user, testQuery, mockdatasets } from 'src/SqlLab/fixtures';
|
||||
import { FeatureFlag, SupersetClient } from '@superset-ui/core';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
|
||||
const mockedProps = {
|
||||
visible: true,
|
||||
@@ -45,7 +45,7 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
|
||||
dataset_count: 3,
|
||||
});
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
|
||||
// Mock the user
|
||||
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
|
||||
@@ -354,131 +354,6 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupOverwriteFlow = async () => {
|
||||
// Select the "Overwrite existing" radio
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', { name: /overwrite existing/i }),
|
||||
);
|
||||
// Open the select to load existing-dataset options
|
||||
await userEvent.click(
|
||||
screen.getByRole('combobox', { name: /existing dataset/i }),
|
||||
);
|
||||
// Advance timers to flush debounced fetches in AsyncSelect
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
// Wait for the loading indicator to clear
|
||||
await waitFor(() => {
|
||||
const loading = screen.queryByText('Loading...');
|
||||
expect(loading === null || !loading.checkVisibility()).toBe(true);
|
||||
});
|
||||
// Pick an existing dataset (use the listbox item, not the input mirror)
|
||||
const options = await screen.findAllByText('coolest table 0');
|
||||
await userEvent.click(options[1]);
|
||||
// First overwrite click → confirmation screen
|
||||
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
|
||||
// Wait for the confirmation screen to render
|
||||
await screen.findByText(/are you sure you want to overwrite this dataset/i);
|
||||
// Second overwrite click → triggers the PUT
|
||||
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
|
||||
};
|
||||
|
||||
test('sends template_params when overwriting a dataset with include template parameters checked', async () => {
|
||||
// @ts-expect-error
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await setupOverwriteFlow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
putSpy.mock.calls.some(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
)!;
|
||||
const [req] = datasetPutCall;
|
||||
expect(req.endpoint).toContain('override_columns=true');
|
||||
const body = JSON.parse(req.body as string);
|
||||
// _filters should be stripped, but my_param should be preserved
|
||||
expect(body.template_params).toEqual(JSON.stringify({ my_param: 12 }));
|
||||
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not send template_params when overwriting a dataset with include template parameters unchecked', async () => {
|
||||
// @ts-expect-error
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
await setupOverwriteFlow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
putSpy.mock.calls.some(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
)!;
|
||||
const [req] = datasetPutCall;
|
||||
const body = JSON.parse(req.body as string);
|
||||
expect(body.template_params).toBeUndefined();
|
||||
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('clears dataset cache when creating new dataset', async () => {
|
||||
const clearDatasetCache = jest.spyOn(
|
||||
require('src/utils/cachedSupersetGet'),
|
||||
|
||||
@@ -149,25 +149,14 @@ const Styles = styled.div`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
type UpdateDatasetPayload = {
|
||||
dbId: number;
|
||||
datasetId: number;
|
||||
sql: string;
|
||||
columns: Array<Record<string, any>>;
|
||||
owners: number[];
|
||||
overrideColumns: boolean;
|
||||
templateParams?: string;
|
||||
};
|
||||
|
||||
const updateDataset = async ({
|
||||
dbId,
|
||||
datasetId,
|
||||
sql,
|
||||
columns,
|
||||
owners,
|
||||
overrideColumns,
|
||||
templateParams,
|
||||
}: UpdateDatasetPayload) => {
|
||||
const updateDataset = async (
|
||||
dbId: number,
|
||||
datasetId: number,
|
||||
sql: string,
|
||||
columns: Array<Record<string, any>>,
|
||||
owners: [number],
|
||||
overrideColumns: boolean,
|
||||
) => {
|
||||
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({
|
||||
@@ -175,7 +164,6 @@ const updateDataset = async ({
|
||||
columns,
|
||||
owners,
|
||||
database_id: dbId,
|
||||
...(templateParams !== undefined && { template_params: templateParams }),
|
||||
});
|
||||
|
||||
const data: JsonResponse = await SupersetClient.put({
|
||||
@@ -191,26 +179,6 @@ const updateDataset = async ({
|
||||
|
||||
const UNTITLED = t('Untitled Dataset');
|
||||
|
||||
// The filters param is only used to test jinja templates.
|
||||
// Remove the special filters entry from the templateParams
|
||||
// before saving the dataset.
|
||||
const sanitizeTemplateParams = (
|
||||
templateParams: string | object | null | undefined,
|
||||
): string | undefined => {
|
||||
if (typeof templateParams !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(templateParams) as Record<string, unknown>;
|
||||
// Remove the special _filters entry — it is only used to test jinja templates.
|
||||
const { _filters: _ignored, ...clean } = parsed;
|
||||
return JSON.stringify(clean);
|
||||
} catch (e) {
|
||||
// malformed templateParams, do not include it
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const SaveDatasetModal = ({
|
||||
visible,
|
||||
onHide,
|
||||
@@ -264,27 +232,22 @@ export const SaveDatasetModal = ({
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const templateParams = includeTemplateParameters
|
||||
? sanitizeTemplateParams(datasource?.templateParams)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const [, key] = await Promise.all([
|
||||
updateDataset({
|
||||
dbId: datasource?.dbId,
|
||||
datasetId: datasetToOverwrite?.datasetid,
|
||||
sql: datasource?.sql,
|
||||
columns: datasource?.columns?.map(
|
||||
updateDataset(
|
||||
datasource?.dbId,
|
||||
datasetToOverwrite?.datasetid,
|
||||
datasource?.sql,
|
||||
datasource?.columns?.map(
|
||||
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
|
||||
column_name: d.column_name,
|
||||
type: d.type,
|
||||
is_dttm: d.is_dttm,
|
||||
}),
|
||||
),
|
||||
owners: datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
|
||||
overrideColumns: true,
|
||||
templateParams,
|
||||
}),
|
||||
datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
|
||||
true,
|
||||
),
|
||||
postFormData(datasetToOverwrite.datasetid, 'table', {
|
||||
...formDataWithDefaults,
|
||||
datasource: `${datasetToOverwrite.datasetid}__table`,
|
||||
@@ -356,9 +319,27 @@ export const SaveDatasetModal = ({
|
||||
setLoading(true);
|
||||
const selectedColumns = datasource?.columns ?? [];
|
||||
|
||||
const templateParams = includeTemplateParameters
|
||||
? sanitizeTemplateParams(datasource?.templateParams)
|
||||
: undefined;
|
||||
// The filters param is only used to test jinja templates.
|
||||
// Remove the special filters entry from the templateParams
|
||||
// before saving the dataset.
|
||||
let templateParams;
|
||||
if (
|
||||
typeof datasource?.templateParams === 'string' &&
|
||||
includeTemplateParameters
|
||||
) {
|
||||
try {
|
||||
const p = JSON.parse(datasource.templateParams);
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
if (p._filters) {
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
delete p._filters;
|
||||
}
|
||||
templateParams = JSON.stringify(p);
|
||||
} catch (e) {
|
||||
// malformed templateParams, do not include it
|
||||
templateParams = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(
|
||||
createDatasource({
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
*/
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -314,7 +314,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
}, [sortedTreeData, sortedTables]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
const debouncedSearchTerm = useDebounceValue(searchTerm);
|
||||
const handleSearchChange = useCallback(
|
||||
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
|
||||
[],
|
||||
@@ -372,9 +372,9 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
|
||||
// Check if any nodes match the search term
|
||||
const hasMatchingNodes = useMemo(() => {
|
||||
if (!deferredSearchTerm) return true;
|
||||
if (!debouncedSearchTerm) return true;
|
||||
|
||||
const lowerTerm = deferredSearchTerm.toLowerCase();
|
||||
const lowerTerm = debouncedSearchTerm.toLowerCase();
|
||||
|
||||
const checkNode = (node: TreeNodeData): boolean => {
|
||||
if (node.type === 'empty') return false;
|
||||
@@ -386,7 +386,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
};
|
||||
|
||||
return displayTreeData.some(node => checkNode(node));
|
||||
}, [deferredSearchTerm, displayTreeData]);
|
||||
}, [debouncedSearchTerm, displayTreeData]);
|
||||
|
||||
// Node renderer for react-arborist
|
||||
const renderNode = useCallback(
|
||||
@@ -395,7 +395,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
{...props}
|
||||
manuallyOpenedNodes={manuallyOpenedNodes}
|
||||
loadingNodes={loadingNodes}
|
||||
searchTerm={deferredSearchTerm}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
catalog={catalog}
|
||||
pinnedTableKeys={pinnedTableKeys}
|
||||
pinnedSchemas={pinnedSchemas}
|
||||
@@ -425,7 +425,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
toggleSortColumns,
|
||||
loadingNodes,
|
||||
manuallyOpenedNodes,
|
||||
deferredSearchTerm,
|
||||
debouncedSearchTerm,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -484,7 +484,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
|
||||
if (deferredSearchTerm && !hasMatchingNodes) {
|
||||
if (debouncedSearchTerm && !hasMatchingNodes) {
|
||||
return (
|
||||
<Empty
|
||||
description={t('No matching results found')}
|
||||
@@ -501,7 +501,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
height={height || 500}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
indent={16}
|
||||
searchTerm={deferredSearchTerm}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
searchMatch={searchMatch}
|
||||
disableDrag
|
||||
disableDrop
|
||||
@@ -527,7 +527,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
// react-arborist marks all schemas as open (isOpen=true) even before any
|
||||
// user interaction. Using treeRef in that case would treat every first
|
||||
// click as a close action, so fall back to manuallyOpenedNodes instead.
|
||||
const wasOpen = deferredSearchTerm
|
||||
const wasOpen = debouncedSearchTerm
|
||||
? (treeRef.current?.get(id)?.isOpen ??
|
||||
manuallyOpenedNodes[id] ??
|
||||
false)
|
||||
|
||||
@@ -16,9 +16,9 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ErrorInfo, useCallback, useEffect, useRef } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ErrorInfo, PureComponent } from 'react';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
ensureIsArray,
|
||||
FeatureFlag,
|
||||
@@ -60,7 +60,7 @@ export interface ChartProps {
|
||||
sharedLabelColors?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
setControlValue?: (name: string, value: unknown) => void;
|
||||
setControlValue: (name: string, value: unknown) => void;
|
||||
timeout?: number;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
@@ -69,7 +69,7 @@ export interface ChartProps {
|
||||
chartAlert?: string;
|
||||
chartStatus?: ChartStatus;
|
||||
chartStackTrace?: string;
|
||||
queriesResponse?: ChartState['queriesResponse'];
|
||||
queriesResponse: ChartState['queriesResponse'];
|
||||
latestQueryFormData?: ChartState['latestQueryFormData'];
|
||||
triggerQuery?: boolean;
|
||||
chartIsStale?: boolean;
|
||||
@@ -126,6 +126,19 @@ const NONEXISTENT_DATASET = t(
|
||||
'The dataset associated with this chart no longer exists',
|
||||
);
|
||||
|
||||
const defaultProps: Partial<ChartProps> = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue: () => BLANK,
|
||||
triggerRender: false,
|
||||
dashboardId: undefined,
|
||||
chartStackTrace: undefined,
|
||||
force: false,
|
||||
isInView: true,
|
||||
};
|
||||
|
||||
const Styles = styled.div<{ height: number; width?: number }>`
|
||||
min-height: ${p => p.height}px;
|
||||
position: relative;
|
||||
@@ -173,321 +186,252 @@ const MessageSpan = styled.span`
|
||||
color: ${({ theme }) => theme.colorText};
|
||||
`;
|
||||
|
||||
function Chart({
|
||||
addFilter = () => BLANK,
|
||||
onFilterMenuOpen = () => BLANK,
|
||||
onFilterMenuClose = () => BLANK,
|
||||
initialValues = BLANK,
|
||||
setControlValue = () => BLANK,
|
||||
triggerRender = false,
|
||||
dashboardId,
|
||||
chartStackTrace,
|
||||
force = false,
|
||||
isInView = true,
|
||||
...restProps
|
||||
}: ChartProps): JSX.Element {
|
||||
const {
|
||||
actions,
|
||||
chartId,
|
||||
datasource,
|
||||
formData,
|
||||
timeout,
|
||||
ownState,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
queriesResponse = [],
|
||||
errorMessage,
|
||||
chartIsStale,
|
||||
width,
|
||||
height,
|
||||
datasetsStatus,
|
||||
onQuery,
|
||||
annotationData,
|
||||
vizType,
|
||||
latestQueryFormData,
|
||||
triggerQuery,
|
||||
postTransformProps,
|
||||
emitCrossFilters,
|
||||
onChartStateChange,
|
||||
suppressLoadingSpinner,
|
||||
filterState,
|
||||
} = restProps;
|
||||
class Chart extends PureComponent<ChartProps, {}> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
const renderStartTimeRef = useRef<number>(Logger.getTimestamp());
|
||||
// Update on each render to accurately track render duration
|
||||
renderStartTimeRef.current = Logger.getTimestamp();
|
||||
renderStartTime: number;
|
||||
|
||||
const shouldRenderChart = useCallback(
|
||||
() =>
|
||||
isInView ||
|
||||
constructor(props: ChartProps) {
|
||||
super(props);
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
this.handleRenderContainerFailure =
|
||||
this.handleRenderContainerFailure.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.triggerQuery) {
|
||||
this.runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this.props.triggerQuery) {
|
||||
this.runQuery();
|
||||
}
|
||||
}
|
||||
|
||||
shouldRenderChart() {
|
||||
return (
|
||||
this.props.isInView ||
|
||||
!isFeatureEnabled(FeatureFlag.DashboardVirtualization) ||
|
||||
isCurrentUserBot(),
|
||||
[isInView],
|
||||
);
|
||||
isCurrentUserBot()
|
||||
);
|
||||
}
|
||||
|
||||
const runQuery = useCallback(() => {
|
||||
runQuery() {
|
||||
if (
|
||||
isFeatureEnabled(FeatureFlag.DashboardVirtualizationDeferData) &&
|
||||
!shouldRenderChart()
|
||||
!this.shouldRenderChart()
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Create chart with POST request
|
||||
actions.postChartFormData(
|
||||
formData,
|
||||
Boolean(force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
|
||||
timeout,
|
||||
chartId,
|
||||
dashboardId,
|
||||
ownState,
|
||||
this.props.actions.postChartFormData(
|
||||
this.props.formData,
|
||||
Boolean(this.props.force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
|
||||
this.props.timeout,
|
||||
this.props.chartId,
|
||||
this.props.dashboardId,
|
||||
this.props.ownState,
|
||||
);
|
||||
}, [
|
||||
actions,
|
||||
chartId,
|
||||
dashboardId,
|
||||
formData,
|
||||
force,
|
||||
ownState,
|
||||
shouldRenderChart,
|
||||
timeout,
|
||||
]);
|
||||
}
|
||||
|
||||
const handleRenderContainerFailure = useCallback(
|
||||
(error: Error, info: ErrorInfo) => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info?.componentStack ?? null,
|
||||
);
|
||||
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
// componentDidMount and componentDidUpdate combined
|
||||
useEffect(() => {
|
||||
if (triggerQuery) {
|
||||
runQuery();
|
||||
}
|
||||
}, [triggerQuery, runQuery]);
|
||||
|
||||
const renderErrorMessage = useCallback(
|
||||
(queryResponse: ChartErrorType) => {
|
||||
const error = queryResponse?.errors?.[0];
|
||||
const message = chartAlert || queryResponse?.message;
|
||||
|
||||
// if datasource is still loading, don't render JS errors
|
||||
// but always show backend API errors (which have an errors array)
|
||||
// so users can see real issues like auth failures
|
||||
if (
|
||||
!error &&
|
||||
chartAlert !== undefined &&
|
||||
chartAlert !== NONEXISTENT_DATASET &&
|
||||
datasource === PLACEHOLDER_DATASOURCE &&
|
||||
datasetsStatus !== ResourceStatus.Error
|
||||
) {
|
||||
return (
|
||||
<Styles
|
||||
key={chartId}
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
>
|
||||
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
|
||||
</Styles>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChartErrorMessage
|
||||
key={chartId}
|
||||
chartId={chartId}
|
||||
error={error}
|
||||
subtitle={message}
|
||||
link={queryResponse ? queryResponse.link : undefined}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[
|
||||
chartAlert,
|
||||
handleRenderContainerFailure(error: Error, info: ErrorInfo) {
|
||||
const { actions, chartId } = this.props;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info?.componentStack ?? null,
|
||||
);
|
||||
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
|
||||
renderErrorMessage(queryResponse: ChartErrorType) {
|
||||
const {
|
||||
chartId,
|
||||
chartAlert,
|
||||
chartStackTrace,
|
||||
dashboardId,
|
||||
datasetsStatus,
|
||||
datasource,
|
||||
dashboardId,
|
||||
height,
|
||||
],
|
||||
);
|
||||
|
||||
const renderSpinner = useCallback(
|
||||
(databaseName: string | undefined) => {
|
||||
const message = databaseName
|
||||
? t('Waiting on %s', databaseName)
|
||||
: t('Waiting on database...');
|
||||
datasetsStatus,
|
||||
} = this.props;
|
||||
const error = queryResponse?.errors?.[0];
|
||||
const message = chartAlert || queryResponse?.message;
|
||||
|
||||
// if datasource is still loading, don't render JS errors
|
||||
// but always show backend API errors (which have an errors array)
|
||||
// so users can see real issues like auth failures
|
||||
if (
|
||||
!error &&
|
||||
chartAlert !== undefined &&
|
||||
chartAlert !== NONEXISTENT_DATASET &&
|
||||
datasource === PLACEHOLDER_DATASOURCE &&
|
||||
datasetsStatus !== ResourceStatus.Error
|
||||
) {
|
||||
return (
|
||||
<LoadingDiv>
|
||||
<Styles
|
||||
key={chartId}
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
>
|
||||
<Loading
|
||||
position="inline-centered"
|
||||
size={dashboardId ? 's' : 'm'}
|
||||
muted={!!dashboardId}
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
<MessageSpan>{message}</MessageSpan>
|
||||
</LoadingDiv>
|
||||
</Styles>
|
||||
);
|
||||
},
|
||||
[dashboardId],
|
||||
);
|
||||
}
|
||||
|
||||
const renderChartContainer = useCallback(
|
||||
() => (
|
||||
return (
|
||||
<ChartErrorMessage
|
||||
key={chartId}
|
||||
chartId={chartId}
|
||||
error={error}
|
||||
subtitle={message}
|
||||
link={queryResponse ? queryResponse.link : undefined}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
stackTrace={chartStackTrace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderSpinner(databaseName: string | undefined) {
|
||||
const message = databaseName
|
||||
? t('Waiting on %s', databaseName)
|
||||
: t('Waiting on database...');
|
||||
|
||||
return (
|
||||
<LoadingDiv>
|
||||
<Loading
|
||||
position="inline-centered"
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
<MessageSpan>{message}</MessageSpan>
|
||||
</LoadingDiv>
|
||||
);
|
||||
}
|
||||
|
||||
renderChartContainer() {
|
||||
return (
|
||||
<div className="slice_container" data-test="slice-container">
|
||||
{shouldRenderChart() ? (
|
||||
{this.shouldRenderChart() ? (
|
||||
<ChartRenderer
|
||||
annotationData={annotationData}
|
||||
actions={actions}
|
||||
chartId={chartId}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={formData}
|
||||
height={height}
|
||||
width={width}
|
||||
setControlValue={setControlValue}
|
||||
vizType={vizType}
|
||||
triggerRender={triggerRender}
|
||||
chartAlert={chartAlert}
|
||||
chartStatus={chartStatus}
|
||||
queriesResponse={queriesResponse}
|
||||
triggerQuery={triggerQuery}
|
||||
chartIsStale={chartIsStale}
|
||||
addFilter={addFilter}
|
||||
onFilterMenuOpen={onFilterMenuOpen}
|
||||
onFilterMenuClose={onFilterMenuClose}
|
||||
ownState={ownState}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
onChartStateChange={onChartStateChange}
|
||||
latestQueryFormData={latestQueryFormData}
|
||||
filterState={filterState}
|
||||
suppressLoadingSpinner={suppressLoadingSpinner}
|
||||
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
|
||||
{...this.props}
|
||||
source={
|
||||
this.props.dashboardId
|
||||
? ChartSource.Dashboard
|
||||
: ChartSource.Explore
|
||||
}
|
||||
data-test={this.props.vizType}
|
||||
/>
|
||||
) : (
|
||||
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
|
||||
<Loading
|
||||
size={this.props.dashboardId ? 's' : 'm'}
|
||||
muted={!!this.props.dashboardId}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
[
|
||||
actions,
|
||||
addFilter,
|
||||
annotationData,
|
||||
chartAlert,
|
||||
chartId,
|
||||
chartIsStale,
|
||||
chartStatus,
|
||||
dashboardId,
|
||||
datasource,
|
||||
emitCrossFilters,
|
||||
filterState,
|
||||
formData,
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {
|
||||
height,
|
||||
initialValues,
|
||||
latestQueryFormData,
|
||||
onChartStateChange,
|
||||
onFilterMenuClose,
|
||||
onFilterMenuOpen,
|
||||
ownState,
|
||||
postTransformProps,
|
||||
queriesResponse,
|
||||
setControlValue,
|
||||
shouldRenderChart,
|
||||
suppressLoadingSpinner,
|
||||
triggerQuery,
|
||||
triggerRender,
|
||||
vizType,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
datasource,
|
||||
errorMessage,
|
||||
chartIsStale,
|
||||
queriesResponse = [],
|
||||
width,
|
||||
],
|
||||
);
|
||||
} = this.props;
|
||||
|
||||
const databaseName =
|
||||
datasource?.parent?.name ??
|
||||
(datasource?.database?.name as string | undefined);
|
||||
const databaseName =
|
||||
datasource?.parent?.name ??
|
||||
(datasource?.database?.name as string | undefined);
|
||||
|
||||
const isLoading = chartStatus === 'loading';
|
||||
// Suppress spinner during auto-refresh to avoid visual flicker
|
||||
const showSpinner = isLoading && !suppressLoadingSpinner;
|
||||
const isLoading = chartStatus === 'loading';
|
||||
// Suppress spinner during auto-refresh to avoid visual flicker
|
||||
const showSpinner = isLoading && !this.props.suppressLoadingSpinner;
|
||||
|
||||
if (chartStatus === 'failed') {
|
||||
return (
|
||||
<ErrorContainer height={height}>
|
||||
{queriesResponse?.map(item =>
|
||||
renderErrorMessage(item as ChartErrorType),
|
||||
)}
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
if (chartStatus === 'failed') {
|
||||
return (
|
||||
<ErrorContainer height={height}>
|
||||
{queriesResponse?.map(item =>
|
||||
this.renderErrorMessage(item as ChartErrorType),
|
||||
)}
|
||||
</ErrorContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Add required control values to preview chart')}
|
||||
description={getChartRequiredFieldsMissingMessage(true)}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoading &&
|
||||
!chartAlert &&
|
||||
!errorMessage &&
|
||||
chartIsStale &&
|
||||
ensureIsArray(queriesResponse).length === 0
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Your chart is ready to go!')}
|
||||
description={
|
||||
<span>
|
||||
{t(
|
||||
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
|
||||
)}{' '}
|
||||
<span role="button" tabIndex={0} onClick={onQuery}>
|
||||
{t('click here')}
|
||||
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Add required control values to preview chart')}
|
||||
description={getChartRequiredFieldsMissingMessage(true)}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (
|
||||
!isLoading &&
|
||||
!chartAlert &&
|
||||
!errorMessage &&
|
||||
chartIsStale &&
|
||||
ensureIsArray(queriesResponse).length === 0
|
||||
) {
|
||||
return (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={t('Your chart is ready to go!')}
|
||||
description={
|
||||
<span>
|
||||
{t(
|
||||
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
|
||||
)}{' '}
|
||||
<span role="button" tabIndex={0} onClick={this.props.onQuery}>
|
||||
{t('click here')}
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
.
|
||||
</span>
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
}
|
||||
image="chart.svg"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary
|
||||
onError={this.handleRenderContainerFailure}
|
||||
showMessage={false}
|
||||
>
|
||||
<Styles
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
{showSpinner
|
||||
? this.renderSpinner(databaseName)
|
||||
: this.renderChartContainer()}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
|
||||
<Styles
|
||||
data-ui-anchor="chart"
|
||||
className="chart-container"
|
||||
data-test="chart-container"
|
||||
height={height}
|
||||
width={width}
|
||||
>
|
||||
{showSpinner ? renderSpinner(databaseName) : renderChartContainer()}
|
||||
</Styles>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
export default Chart;
|
||||
|
||||
@@ -394,9 +394,7 @@ test('renders chart during loading when suppressLoadingSpinner has valid data',
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
@@ -413,9 +411,7 @@ test('does not mark chart as refreshing when loading is not in progress', () =>
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
'false',
|
||||
@@ -431,9 +427,7 @@ test('does not mark chart as refreshing when spinner suppression is disabled', (
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
'false',
|
||||
@@ -449,8 +443,6 @@ test('does not render chart during loading when last data has errors', () => {
|
||||
queriesResponse: [{ error: 'bad' }],
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
const { queryByTestId } = render(<ChartRenderer {...props} />);
|
||||
expect(queryByTestId('mock-super-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -16,17 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { snakeCase, cloneDeep } from 'lodash';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
useMemo,
|
||||
MouseEvent,
|
||||
ReactNode,
|
||||
memo,
|
||||
} from 'react';
|
||||
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
||||
import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
|
||||
import {
|
||||
SuperChart,
|
||||
Behavior,
|
||||
@@ -46,7 +37,6 @@ import {
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
@@ -147,6 +137,14 @@ export interface ChartRendererProps {
|
||||
suppressLoadingSpinner?: boolean;
|
||||
}
|
||||
|
||||
// State interface
|
||||
interface ChartRendererState {
|
||||
showContextMenu: boolean;
|
||||
inContextMenu: boolean;
|
||||
legendState: LegendState | undefined;
|
||||
legendIndex: number;
|
||||
}
|
||||
|
||||
// Hooks interface
|
||||
interface ChartHooks {
|
||||
onAddFilter: (
|
||||
@@ -177,370 +175,402 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
||||
|
||||
const behaviors = [Behavior.InteractiveChart];
|
||||
|
||||
interface ChartRendererState {
|
||||
showContextMenu: boolean;
|
||||
inContextMenu: boolean;
|
||||
legendState: LegendState | undefined;
|
||||
legendIndex: number;
|
||||
}
|
||||
const defaultProps: Partial<ChartRendererProps> = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue: () => {},
|
||||
triggerRender: false,
|
||||
};
|
||||
|
||||
function ChartRendererComponent({
|
||||
addFilter = () => BLANK,
|
||||
onFilterMenuOpen = () => BLANK,
|
||||
onFilterMenuClose = () => BLANK,
|
||||
initialValues = BLANK,
|
||||
setControlValue = () => {},
|
||||
triggerRender = false,
|
||||
...restProps
|
||||
}: ChartRendererProps): JSX.Element | null {
|
||||
const {
|
||||
annotationData,
|
||||
actions,
|
||||
chartId,
|
||||
datasource,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
height,
|
||||
width,
|
||||
vizType: propVizType,
|
||||
chartAlert,
|
||||
chartStatus,
|
||||
queriesResponse,
|
||||
chartIsStale,
|
||||
ownState,
|
||||
filterState,
|
||||
postTransformProps,
|
||||
source,
|
||||
emitCrossFilters,
|
||||
onChartStateChange,
|
||||
} = restProps;
|
||||
class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
|
||||
static defaultProps = defaultProps;
|
||||
|
||||
const theme = useTheme();
|
||||
private hasQueryResponseChange: boolean;
|
||||
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
formData.viz_type ?? propVizType,
|
||||
)?.suppressContextMenu;
|
||||
private contextMenuRef: RefObject<ChartContextMenuRef>;
|
||||
|
||||
const [state, setState] = useState<ChartRendererState>({
|
||||
showContextMenu:
|
||||
source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
legendIndex: 0,
|
||||
});
|
||||
private hooks: ChartHooks;
|
||||
|
||||
const hasQueryResponseChangeRef = useRef(false);
|
||||
const renderStartTimeRef = useRef(0);
|
||||
const contextMenuRef = useRef<ChartContextMenuRef>(null);
|
||||
private mutableQueriesResponse: QueryData[] | null | undefined;
|
||||
|
||||
// Results are "ready" when we have a non-error queriesResponse and the
|
||||
// chartStatus reflects it. This mirrors the gating logic from the former
|
||||
// shouldComponentUpdate implementation.
|
||||
const resultsReady =
|
||||
queriesResponse &&
|
||||
['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
|
||||
!queriesResponse?.[0]?.error;
|
||||
private renderStartTime: number;
|
||||
|
||||
// Track whether queriesResponse changed since the previous render so that
|
||||
// handleRenderSuccess / handleRenderFailure know whether to log render time.
|
||||
// Updating a ref during render is safe when the value doesn't affect the
|
||||
// render output (here it's read asynchronously from SuperChart callbacks).
|
||||
const prevQueriesResponseRef = useRef<QueryData[] | null | undefined>(
|
||||
queriesResponse,
|
||||
);
|
||||
if (resultsReady) {
|
||||
hasQueryResponseChangeRef.current =
|
||||
queriesResponse !== prevQueriesResponseRef.current;
|
||||
constructor(props: ChartRendererProps) {
|
||||
super(props);
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
props.formData.viz_type ?? props.vizType,
|
||||
)?.suppressContextMenu;
|
||||
this.state = {
|
||||
showContextMenu:
|
||||
props.source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
legendIndex: 0,
|
||||
};
|
||||
this.hasQueryResponseChange = false;
|
||||
this.renderStartTime = 0;
|
||||
|
||||
this.contextMenuRef = createRef<ChartContextMenuRef>();
|
||||
|
||||
this.handleAddFilter = this.handleAddFilter.bind(this);
|
||||
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
||||
this.handleRenderFailure = this.handleRenderFailure.bind(this);
|
||||
this.handleSetControlValue = this.handleSetControlValue.bind(this);
|
||||
this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
|
||||
this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
|
||||
this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
|
||||
this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
|
||||
this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
|
||||
this.handleLegendScroll = this.handleLegendScroll.bind(this);
|
||||
|
||||
this.hooks = {
|
||||
onAddFilter: this.handleAddFilter,
|
||||
onContextMenu: this.state.showContextMenu
|
||||
? this.handleOnContextMenu
|
||||
: undefined,
|
||||
onError: this.handleRenderFailure,
|
||||
setControlValue: this.handleSetControlValue,
|
||||
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
||||
onFilterMenuClose: this.props.onFilterMenuClose,
|
||||
onLegendStateChanged: this.handleLegendStateChanged,
|
||||
setDataMask: (dataMask: DataMask) => {
|
||||
this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
|
||||
},
|
||||
onLegendScroll: this.handleLegendScroll,
|
||||
onChartStateChange: this.props.onChartStateChange,
|
||||
};
|
||||
|
||||
// TODO: queriesResponse comes from Redux store but it's being edited by
|
||||
// the plugins, hence we need to clone it to avoid state mutation
|
||||
// until we change the reducers to use Redux Toolkit with Immer
|
||||
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
||||
}
|
||||
useEffect(() => {
|
||||
prevQueriesResponseRef.current = queriesResponse;
|
||||
}, [queriesResponse]);
|
||||
|
||||
// Clone queriesResponse to protect against plugin mutation of Redux state.
|
||||
// TODO: remove once reducers use Redux Toolkit with Immer.
|
||||
const mutableQueriesResponse = useMemo(
|
||||
() => cloneDeep(queriesResponse),
|
||||
[queriesResponse],
|
||||
);
|
||||
shouldComponentUpdate(
|
||||
nextProps: ChartRendererProps,
|
||||
nextState: ChartRendererState,
|
||||
): boolean {
|
||||
const resultsReady =
|
||||
nextProps.queriesResponse &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
|
||||
!nextProps.queriesResponse?.[0]?.error;
|
||||
|
||||
// Handler functions
|
||||
const handleAddFilter = useCallback(
|
||||
(col: string, vals: FilterValue[], merge = true, refresh = true): void => {
|
||||
addFilter?.(col, vals, merge, refresh);
|
||||
},
|
||||
[addFilter],
|
||||
);
|
||||
if (resultsReady) {
|
||||
if (!isEqual(this.state, nextState)) {
|
||||
return true;
|
||||
}
|
||||
this.hasQueryResponseChange =
|
||||
nextProps.queriesResponse !== this.props.queriesResponse;
|
||||
|
||||
const handleRenderSuccess = useCallback((): void => {
|
||||
if (this.hasQueryResponseChange) {
|
||||
this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
|
||||
}
|
||||
|
||||
// Check if any matrixify-related properties have changed
|
||||
const hasMatrixifyChanges = (): boolean => {
|
||||
const nextFormData = nextProps.formData as JsonObject;
|
||||
const currentFormData = this.props.formData as JsonObject;
|
||||
const isMatrixifyEnabled =
|
||||
nextFormData.matrixify_enable === true &&
|
||||
((nextFormData.matrixify_mode_rows !== undefined &&
|
||||
nextFormData.matrixify_mode_rows !== 'disabled') ||
|
||||
(nextFormData.matrixify_mode_columns !== undefined &&
|
||||
nextFormData.matrixify_mode_columns !== 'disabled'));
|
||||
if (!isMatrixifyEnabled) return false;
|
||||
|
||||
// Check all matrixify-related properties
|
||||
const matrixifyKeys = Object.keys(nextFormData).filter(key =>
|
||||
key.startsWith('matrixify_'),
|
||||
);
|
||||
|
||||
return matrixifyKeys.some(
|
||||
key => !isEqual(nextFormData[key], currentFormData[key]),
|
||||
);
|
||||
};
|
||||
|
||||
const nextFormData = nextProps.formData as JsonObject;
|
||||
const currentFormData = this.props.formData as JsonObject;
|
||||
|
||||
return (
|
||||
this.hasQueryResponseChange ||
|
||||
!isEqual(nextProps.datasource, this.props.datasource) ||
|
||||
nextProps.annotationData !== this.props.annotationData ||
|
||||
nextProps.ownState !== this.props.ownState ||
|
||||
nextProps.filterState !== this.props.filterState ||
|
||||
nextProps.height !== this.props.height ||
|
||||
nextProps.width !== this.props.width ||
|
||||
nextProps.triggerRender === true ||
|
||||
nextProps.labelsColor !== this.props.labelsColor ||
|
||||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
||||
nextFormData.color_scheme !== currentFormData.color_scheme ||
|
||||
nextFormData.stack !== currentFormData.stack ||
|
||||
nextFormData.subcategories !== currentFormData.subcategories ||
|
||||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
||||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
|
||||
nextProps.postTransformProps !== this.props.postTransformProps ||
|
||||
hasMatrixifyChanges()
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
handleAddFilter(
|
||||
col: string,
|
||||
vals: FilterValue[],
|
||||
merge = true,
|
||||
refresh = true,
|
||||
): void {
|
||||
this.props.addFilter?.(col, vals, merge, refresh);
|
||||
}
|
||||
|
||||
handleRenderSuccess(): void {
|
||||
const { actions, chartStatus, chartId, vizType } = this.props;
|
||||
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
|
||||
actions.chartRenderingSucceeded(chartId);
|
||||
}
|
||||
|
||||
// only log chart render time which is triggered by query results change
|
||||
if (hasQueryResponseChangeRef.current) {
|
||||
// currently we don't log chart re-render time, like window resize etc
|
||||
if (this.hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
viz_type: propVizType,
|
||||
start_offset: renderStartTimeRef.current,
|
||||
viz_type: vizType,
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
}, [actions, chartId, chartStatus, propVizType]);
|
||||
}
|
||||
|
||||
const handleRenderFailure = useCallback(
|
||||
(error: Error, info: { componentStack: string } | null): void => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
handleRenderFailure(
|
||||
error: Error,
|
||||
info: { componentStack: string } | null,
|
||||
): void {
|
||||
const { actions, chartId } = this.props;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
|
||||
// only trigger render log when query is changed
|
||||
if (hasQueryResponseChangeRef.current) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
// only trigger render log when query is changed
|
||||
if (this.hasQueryResponseChange) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: this.renderStartTime,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - this.renderStartTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const handleSetControlValue = useCallback(
|
||||
(name: string, value: unknown): void => {
|
||||
if (setControlValue) {
|
||||
setControlValue(name, value);
|
||||
}
|
||||
},
|
||||
[setControlValue],
|
||||
);
|
||||
handleSetControlValue(name: string, value: unknown): void {
|
||||
const { setControlValue } = this.props;
|
||||
if (setControlValue) {
|
||||
setControlValue(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
const handleOnContextMenu = useCallback(
|
||||
(offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
|
||||
contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
setState(prev => ({ ...prev, inContextMenu: true }));
|
||||
},
|
||||
[contextMenuRef],
|
||||
);
|
||||
handleOnContextMenu(
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
filters?: ContextMenuFilters,
|
||||
): void {
|
||||
this.contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
this.setState({ inContextMenu: true });
|
||||
}
|
||||
|
||||
const handleContextMenuSelected = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
handleContextMenuSelected(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
const handleContextMenuClosed = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
handleContextMenuClosed(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
const handleLegendStateChanged = useCallback(
|
||||
(legendState: LegendState): void => {
|
||||
setState(prev => ({ ...prev, legendState }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleLegendScroll = useCallback((legendIndex: number): void => {
|
||||
setState(prev => ({ ...prev, legendIndex }));
|
||||
}, []);
|
||||
handleLegendStateChanged(legendState: LegendState): void {
|
||||
this.setState({ legendState });
|
||||
}
|
||||
|
||||
// When viz plugins don't handle `contextmenu` event, fallback handler
|
||||
// calls `handleOnContextMenu` with no `filters` param.
|
||||
const onContextMenuFallback = useCallback(
|
||||
(event: MouseEvent<HTMLDivElement>): void => {
|
||||
if (!state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
},
|
||||
[handleOnContextMenu, state.inContextMenu],
|
||||
);
|
||||
|
||||
const setDataMaskCallback = useCallback(
|
||||
(dataMask: DataMask) => {
|
||||
actions?.updateDataMask?.(chartId, dataMask);
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
// Hooks object - memoized
|
||||
const hooks = useMemo<ChartHooks>(
|
||||
() => ({
|
||||
onAddFilter: handleAddFilter,
|
||||
onContextMenu: state.showContextMenu ? handleOnContextMenu : undefined,
|
||||
onError: handleRenderFailure,
|
||||
setControlValue: handleSetControlValue,
|
||||
onFilterMenuOpen,
|
||||
onFilterMenuClose,
|
||||
onLegendStateChanged: handleLegendStateChanged,
|
||||
setDataMask: setDataMaskCallback,
|
||||
onLegendScroll: handleLegendScroll,
|
||||
onChartStateChange,
|
||||
}),
|
||||
[
|
||||
handleAddFilter,
|
||||
handleLegendScroll,
|
||||
handleLegendStateChanged,
|
||||
handleOnContextMenu,
|
||||
handleRenderFailure,
|
||||
handleSetControlValue,
|
||||
onChartStateChange,
|
||||
onFilterMenuClose,
|
||||
onFilterMenuOpen,
|
||||
setDataMaskCallback,
|
||||
state.showContextMenu,
|
||||
],
|
||||
);
|
||||
|
||||
const hasAnyErrors = queriesResponse?.some(item => item?.error);
|
||||
const hasValidPreviousData =
|
||||
(queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
|
||||
|
||||
if (!!chartAlert || chartStatus === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chartStatus === 'loading') {
|
||||
if (!restProps.suppressLoadingSpinner || !hasValidPreviousData) {
|
||||
return null;
|
||||
onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
|
||||
if (!this.state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
this.handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
renderStartTimeRef.current = Logger.getTimestamp();
|
||||
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
const vizType = currentFormData.viz_type || propVizType;
|
||||
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
// container. It may cause css conflicts as in the case of legacy table chart.
|
||||
// When migrating charts, we should gradually add a `superset-chart-` prefix
|
||||
// to each one of them.
|
||||
const snakeCaseVizType = snakeCase(vizType);
|
||||
const chartClassName =
|
||||
vizType === VizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
let noResultsComponent: ReactNode;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
if (
|
||||
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
|
||||
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
|
||||
) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
|
||||
);
|
||||
handleLegendScroll(legendIndex: number): void {
|
||||
this.setState({ legendIndex });
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(vizType)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: state.inContextMenu }
|
||||
: {};
|
||||
// By pass no result component when server pagination is enabled & the table has:
|
||||
// - a backend search query, OR
|
||||
// - non-empty AG Grid filter model
|
||||
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
|
||||
const hasAgGridFilters =
|
||||
ownState?.agGridFilterModel &&
|
||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||
render(): ReactNode {
|
||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||
|
||||
const currentFormDataExtended = currentFormData as JsonObject;
|
||||
const bypassNoResult = !(
|
||||
currentFormDataExtended?.server_pagination &&
|
||||
(hasSearchText || hasAgGridFilters)
|
||||
);
|
||||
const hasAnyErrors = this.props.queriesResponse?.some(item => item?.error);
|
||||
const hasValidPreviousData =
|
||||
(this.props.queriesResponse?.length ?? 0) > 0 && !hasAnyErrors;
|
||||
|
||||
return (
|
||||
<>
|
||||
{state.showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData as QueryFormData}
|
||||
onSelection={handleContextMenuSelected}
|
||||
onClose={handleContextMenuClosed}
|
||||
if (!!chartAlert || chartStatus === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (chartStatus === 'loading') {
|
||||
if (!this.props.suppressLoadingSpinner || !hasValidPreviousData) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
this.renderStartTime = Logger.getTimestamp();
|
||||
|
||||
const {
|
||||
width,
|
||||
height,
|
||||
datasource,
|
||||
annotationData,
|
||||
initialValues,
|
||||
ownState,
|
||||
filterState,
|
||||
chartIsStale,
|
||||
formData,
|
||||
latestQueryFormData,
|
||||
postTransformProps,
|
||||
} = this.props;
|
||||
|
||||
const currentFormData =
|
||||
chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
|
||||
const vizType = currentFormData.viz_type || this.props.vizType;
|
||||
|
||||
// It's bad practice to use unprefixed `vizType` as classnames for chart
|
||||
// container. It may cause css conflicts as in the case of legacy table chart.
|
||||
// When migrating charts, we should gradually add a `superset-chart-` prefix
|
||||
// to each one of them.
|
||||
const snakeCaseVizType = snakeCase(vizType);
|
||||
const chartClassName =
|
||||
vizType === VizType.Table
|
||||
? `superset-chart-${snakeCaseVizType}`
|
||||
: snakeCaseVizType;
|
||||
|
||||
const webpackHash =
|
||||
process.env.WEBPACK_MODE === 'development'
|
||||
? `-${
|
||||
// eslint-disable-next-line camelcase
|
||||
typeof __webpack_require__ !== 'undefined' &&
|
||||
// eslint-disable-next-line camelcase, no-undef
|
||||
typeof __webpack_require__.h === 'function' &&
|
||||
// eslint-disable-next-line no-undef, camelcase
|
||||
__webpack_require__.h()
|
||||
}`
|
||||
: '';
|
||||
|
||||
let noResultsComponent: ReactNode;
|
||||
const noResultTitle = t('No results were returned for this query');
|
||||
const noResultDescription =
|
||||
this.props.source === ChartSource.Explore
|
||||
? t(
|
||||
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
|
||||
)
|
||||
: undefined;
|
||||
const noResultImage = 'chart.svg';
|
||||
if (
|
||||
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
|
||||
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
|
||||
) {
|
||||
noResultsComponent = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
title={noResultTitle}
|
||||
description={noResultDescription}
|
||||
image={noResultImage}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onContextMenu={
|
||||
state.showContextMenu ? onContextMenuFallback : undefined
|
||||
}
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
theme={theme}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
|
||||
behaviors={behaviors}
|
||||
queriesData={mutableQueriesResponse ?? undefined}
|
||||
onRenderSuccess={handleRenderSuccess}
|
||||
onRenderFailure={handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
legendState={state.legendState}
|
||||
enableNoResults={bypassNoResult}
|
||||
legendIndex={state.legendIndex}
|
||||
isRefreshing={
|
||||
Boolean(restProps.suppressLoadingSpinner) &&
|
||||
chartStatus === 'loading'
|
||||
);
|
||||
} else {
|
||||
noResultsComponent = (
|
||||
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
|
||||
);
|
||||
}
|
||||
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(vizType)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: this.state.inContextMenu }
|
||||
: {};
|
||||
// By pass no result component when server pagination is enabled & the table has:
|
||||
// - a backend search query, OR
|
||||
// - non-empty AG Grid filter model
|
||||
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
|
||||
const hasAgGridFilters =
|
||||
ownState?.agGridFilterModel &&
|
||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||
|
||||
const currentFormDataExtended = currentFormData as JsonObject;
|
||||
const bypassNoResult = !(
|
||||
currentFormDataExtended?.server_pagination &&
|
||||
(hasSearchText || hasAgGridFilters)
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this.state.showContextMenu && (
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData as QueryFormData}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
onContextMenu={
|
||||
this.state.showContextMenu ? this.onContextMenuFallback : undefined
|
||||
}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
>
|
||||
<SuperChart
|
||||
disableErrorBoundary
|
||||
key={`${chartId}${webpackHash}`}
|
||||
id={`chart-id-${chartId}`}
|
||||
className={chartClassName}
|
||||
chartType={vizType}
|
||||
width={width}
|
||||
height={height}
|
||||
annotationData={annotationData}
|
||||
datasource={datasource}
|
||||
initialValues={initialValues}
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hooks={this.hooks as any}
|
||||
behaviors={behaviors}
|
||||
queriesData={this.mutableQueriesResponse ?? undefined}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
postTransformProps={postTransformProps}
|
||||
emitCrossFilters={emitCrossFilters}
|
||||
legendState={this.state.legendState}
|
||||
enableNoResults={bypassNoResult}
|
||||
legendIndex={this.state.legendIndex}
|
||||
isRefreshing={
|
||||
Boolean(this.props.suppressLoadingSpinner) &&
|
||||
chartStatus === 'loading'
|
||||
}
|
||||
{...drillToDetailProps}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const ChartRenderer = memo(ChartRendererComponent);
|
||||
|
||||
export default ChartRenderer;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
SuperChart,
|
||||
ContextMenuFilters,
|
||||
} from '@superset-ui/core';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
interface DrillByChartProps {
|
||||
@@ -45,7 +45,6 @@ export default function DrillByChart({
|
||||
onContextMenu,
|
||||
inContextMenu,
|
||||
}: DrillByChartProps) {
|
||||
const theme = useTheme();
|
||||
const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]);
|
||||
|
||||
return (
|
||||
@@ -68,7 +67,6 @@ export default function DrillByChart({
|
||||
inContextMenu={inContextMenu}
|
||||
height="100%"
|
||||
width="100%"
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -191,8 +191,6 @@ export function DatabaseSelector({
|
||||
}: DatabaseSelectorProps) {
|
||||
const showCatalogSelector = !!db?.allow_multi_catalog;
|
||||
const [currentDb, setCurrentDb] = useState<DatabaseValue | undefined>();
|
||||
const showSchemaSelector =
|
||||
(db?.supports_schemas ?? currentDb?.supports_schemas) !== false;
|
||||
const [errorPayload, setErrorPayload] = useState<SupersetError | null>();
|
||||
const [currentCatalog, setCurrentCatalog] = useState<
|
||||
CatalogOption | null | undefined
|
||||
@@ -262,12 +260,6 @@ export function DatabaseSelector({
|
||||
database_name: row.database_name,
|
||||
backend: row.backend,
|
||||
allow_multi_catalog: row.allow_multi_catalog,
|
||||
supports_schemas:
|
||||
(
|
||||
row as DatabaseObject & {
|
||||
engine_information?: { supports_schemas?: boolean };
|
||||
}
|
||||
).engine_information?.supports_schemas !== false,
|
||||
order,
|
||||
}));
|
||||
|
||||
@@ -605,7 +597,7 @@ export function DatabaseSelector({
|
||||
{renderDatabaseSelect()}
|
||||
{renderError()}
|
||||
{showCatalogSelector && renderCatalogSelect()}
|
||||
{showSchemaSelector && renderSchemaSelect()}
|
||||
{renderSchemaSelect()}
|
||||
</DatabaseSelectorWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ export type DatabaseValue = {
|
||||
id: number;
|
||||
database_name: string;
|
||||
backend?: string;
|
||||
supports_schemas?: boolean;
|
||||
};
|
||||
|
||||
export type DatabaseObject = {
|
||||
@@ -32,7 +31,6 @@ export type DatabaseObject = {
|
||||
database_name: string;
|
||||
backend?: string;
|
||||
allow_multi_catalog?: boolean;
|
||||
supports_schemas?: boolean;
|
||||
};
|
||||
|
||||
export interface DatabaseSelectorProps {
|
||||
|
||||
@@ -260,52 +260,6 @@ test('table multi select retain all the values selected', async () => {
|
||||
expect(selections[1]).toHaveTextContent('table_c');
|
||||
});
|
||||
|
||||
test('calls onTableSelectChange for schema-less database without schema', async () => {
|
||||
fetchMock.get(catalogApiRoute, { result: [] });
|
||||
fetchMock.get(schemaApiRoute, { result: [] });
|
||||
fetchMock.get(tablesApiRoute, getTableMockFunction());
|
||||
|
||||
const callback = jest.fn();
|
||||
const props = createProps({
|
||||
database: {
|
||||
id: 1,
|
||||
database_name: 'ydb',
|
||||
backend: 'ydb',
|
||||
supports_schemas: false,
|
||||
},
|
||||
schema: undefined,
|
||||
onTableSelectChange: callback,
|
||||
});
|
||||
|
||||
render(<TableSelector {...props} />, { useRedux: true, store });
|
||||
|
||||
const tableSelect = screen.getByRole('combobox', {
|
||||
name: 'Select table or type to search tables',
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(tableSelect);
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(screen.getByText('table_a')).toBeInTheDocument();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await userEvent.click(screen.getByText('table_a'));
|
||||
});
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(callback).toHaveBeenCalled();
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
}, 15000);
|
||||
|
||||
test('TableOption renders correct icons for different table types', () => {
|
||||
// Test regular table
|
||||
const tableTable = {
|
||||
|
||||
@@ -190,7 +190,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
dbId: database?.id,
|
||||
catalog: currentCatalog,
|
||||
schema: currentSchema,
|
||||
supportsSchemas: database?.supports_schemas,
|
||||
onSuccess: (data, isFetched) => {
|
||||
setErrorPayload(null);
|
||||
if (isFetched) {
|
||||
@@ -248,8 +247,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
const internalTableChange = (
|
||||
selectedOptions: TableOption | TableOption[] | undefined,
|
||||
) => {
|
||||
setTableSelectValue(selectedOptions);
|
||||
if (currentSchema || database?.supports_schemas === false) {
|
||||
if (currentSchema) {
|
||||
onTableSelectChange?.(
|
||||
Array.isArray(selectedOptions)
|
||||
? selectedOptions.map(option => option?.value)
|
||||
@@ -257,6 +255,8 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
currentCatalog,
|
||||
currentSchema,
|
||||
);
|
||||
} else {
|
||||
setTableSelectValue(selectedOptions);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -302,8 +302,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
|
||||
);
|
||||
|
||||
function renderTableSelect() {
|
||||
const disabled =
|
||||
readOnly || (database?.supports_schemas !== false && !currentSchema);
|
||||
const disabled = (currentSchema && !formMode && readOnly) || !currentSchema;
|
||||
|
||||
const label = t('Table');
|
||||
|
||||
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, act } from 'spec/helpers/testing-library';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import { getStatusConfig, StatusIndicatorDot } from './StatusIndicatorDot';
|
||||
import { StatusIndicatorDot } from './StatusIndicatorDot';
|
||||
import { AutoRefreshStatus } from '../../types/autoRefresh';
|
||||
|
||||
afterEach(() => {
|
||||
@@ -63,15 +62,6 @@ test('renders with paused status', () => {
|
||||
expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Paused);
|
||||
});
|
||||
|
||||
test('uses the icon color for the paused status outline', () => {
|
||||
expect(
|
||||
getStatusConfig(supersetTheme, AutoRefreshStatus.Paused),
|
||||
).toMatchObject({
|
||||
needsBorder: true,
|
||||
outlineColor: 'currentColor',
|
||||
});
|
||||
});
|
||||
|
||||
test('has correct accessibility attributes', () => {
|
||||
render(<StatusIndicatorDot status={AutoRefreshStatus.Success} />);
|
||||
const dot = screen.getByTestId('status-indicator-dot');
|
||||
|
||||
@@ -39,10 +39,9 @@ export interface StatusIndicatorDotProps {
|
||||
interface StatusConfig {
|
||||
color: string;
|
||||
needsBorder: boolean;
|
||||
outlineColor?: string;
|
||||
}
|
||||
|
||||
export const getStatusConfig = (
|
||||
const getStatusConfig = (
|
||||
theme: ReturnType<typeof useTheme>,
|
||||
status: AutoRefreshStatus,
|
||||
): StatusConfig => {
|
||||
@@ -76,7 +75,6 @@ export const getStatusConfig = (
|
||||
return {
|
||||
color: theme.colorBgContainer,
|
||||
needsBorder: true,
|
||||
outlineColor: 'currentColor',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
@@ -138,15 +136,13 @@ export const StatusIndicatorDot: FC<StatusIndicatorDotProps> = ({
|
||||
width: ${size}px;
|
||||
height: ${size}px;
|
||||
border-radius: 50%;
|
||||
color: ${theme.colorTextSecondary};
|
||||
background-color: ${statusConfig.color};
|
||||
transition:
|
||||
background-color ${theme.motionDurationMid} ease-in-out,
|
||||
border-color ${theme.motionDurationMid} ease-in-out;
|
||||
border: ${statusConfig.needsBorder ? '1px solid' : 'none'};
|
||||
border-color: ${statusConfig.needsBorder
|
||||
? statusConfig.outlineColor
|
||||
: 'transparent'};
|
||||
border: ${statusConfig.needsBorder
|
||||
? `1px solid ${theme.colorBorder}`
|
||||
: 'none'};
|
||||
box-shadow: ${statusConfig.needsBorder
|
||||
? 'none'
|
||||
: `0 0 0 2px ${theme.colorBgContainer}`};
|
||||
|
||||
@@ -67,7 +67,7 @@ interface DashboardActions {
|
||||
setDatasources: (datasources: unknown) => void;
|
||||
}
|
||||
|
||||
export interface DashboardProps {
|
||||
interface DashboardProps {
|
||||
actions: DashboardActions;
|
||||
dashboardId: number;
|
||||
editMode?: boolean;
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Droppable } from './dnd/DragDroppable';
|
||||
import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
|
||||
import { TAB_TYPE } from '../util/componentTypes';
|
||||
|
||||
export interface DashboardGridProps {
|
||||
interface DashboardGridProps {
|
||||
depth: number;
|
||||
editMode?: boolean;
|
||||
canEdit?: boolean;
|
||||
|
||||
@@ -611,7 +611,7 @@ test('should refresh the charts', async () => {
|
||||
});
|
||||
|
||||
test('auto-refresh uses onRefresh with skipped filters and toggles refresh state', async () => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
onRefresh.mockResolvedValue(undefined);
|
||||
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
|
||||
@@ -763,123 +763,6 @@ test('Should show row count warning for table chart with server pagination when
|
||||
mockUseUiConfig.mockRestore();
|
||||
});
|
||||
|
||||
test('Should show row count warning for non-table chart when row limit is reached', () => {
|
||||
const props = createProps({
|
||||
formData: {
|
||||
...createProps().formData,
|
||||
viz_type: VizType.Bar,
|
||||
row_limit: 10,
|
||||
},
|
||||
slice: {
|
||||
...createProps().slice,
|
||||
form_data: {
|
||||
...createProps().slice.form_data,
|
||||
viz_type: VizType.Bar,
|
||||
row_limit: 10,
|
||||
},
|
||||
viz_type: VizType.Bar,
|
||||
},
|
||||
});
|
||||
const barChartState = {
|
||||
...initialState,
|
||||
charts: {
|
||||
[props.slice.slice_id]: {
|
||||
id: MOCKED_CHART_ID,
|
||||
chartStatus: 'rendered',
|
||||
queriesResponse: [
|
||||
{
|
||||
sql_rowcount: 10,
|
||||
data: Array(10).fill({}),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockUseUiConfig = useUiConfig as jest.MockedFunction<
|
||||
typeof useUiConfig
|
||||
>;
|
||||
mockUseUiConfig.mockReturnValue({
|
||||
hideTitle: false,
|
||||
hideTab: false,
|
||||
hideNav: false,
|
||||
hideChartControls: false,
|
||||
emitDataMasks: false,
|
||||
showRowLimitWarning: true,
|
||||
});
|
||||
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: barChartState,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('warning')).toBeInTheDocument();
|
||||
|
||||
mockUseUiConfig.mockRestore();
|
||||
});
|
||||
|
||||
test('Should show row count warning for ag-grid table chart with server pagination when limit is reached', () => {
|
||||
const props = createProps({
|
||||
formData: {
|
||||
...createProps().formData,
|
||||
viz_type: VizType.TableAgGrid,
|
||||
row_limit: 10,
|
||||
server_pagination: true,
|
||||
},
|
||||
slice: {
|
||||
...createProps().slice,
|
||||
form_data: {
|
||||
...createProps().slice.form_data,
|
||||
viz_type: VizType.TableAgGrid,
|
||||
row_limit: 10,
|
||||
server_pagination: true,
|
||||
},
|
||||
viz_type: VizType.TableAgGrid,
|
||||
},
|
||||
});
|
||||
const agGridWithPaginationState = {
|
||||
...initialState,
|
||||
charts: {
|
||||
[props.slice.slice_id]: {
|
||||
id: MOCKED_CHART_ID,
|
||||
chartStatus: 'rendered',
|
||||
queriesResponse: [
|
||||
{
|
||||
sql_rowcount: 10,
|
||||
data: Array(10).fill({}),
|
||||
},
|
||||
{
|
||||
data: [{ rowcount: 50 }],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockUseUiConfig = useUiConfig as jest.MockedFunction<
|
||||
typeof useUiConfig
|
||||
>;
|
||||
mockUseUiConfig.mockReturnValue({
|
||||
hideTitle: false,
|
||||
hideTab: false,
|
||||
hideNav: false,
|
||||
hideChartControls: false,
|
||||
emitDataMasks: false,
|
||||
showRowLimitWarning: true,
|
||||
});
|
||||
|
||||
render(<SliceHeader {...props} />, {
|
||||
useRedux: true,
|
||||
useRouter: true,
|
||||
initialState: agGridWithPaginationState,
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('warning')).toBeInTheDocument();
|
||||
|
||||
mockUseUiConfig.mockRestore();
|
||||
});
|
||||
|
||||
test('Should NOT show row count warning for table chart with server pagination when limit is NOT reached', () => {
|
||||
const props = createProps({
|
||||
formData: {
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
useState,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { getExtensionsRegistry, QueryData, VizType } from '@superset-ui/core';
|
||||
import { getExtensionsRegistry, QueryData } from '@superset-ui/core';
|
||||
import {
|
||||
css,
|
||||
styled,
|
||||
@@ -206,12 +206,9 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
|
||||
|
||||
const rowLimit = Number(formData.row_limit ?? 0);
|
||||
|
||||
const isTableChart =
|
||||
formData.viz_type === VizType.Table ||
|
||||
formData.viz_type === VizType.TableAgGrid;
|
||||
const countFromSecondQuery = isTableChart
|
||||
? secondQueryResponse?.data?.[0]?.rowcount
|
||||
: undefined;
|
||||
const isTableChart = formData.viz_type === 'table';
|
||||
const countFromSecondQuery =
|
||||
isTableChart && secondQueryResponse?.data?.[0]?.rowcount;
|
||||
|
||||
const sqlRowCount =
|
||||
countFromSecondQuery != null
|
||||
|
||||
@@ -80,7 +80,7 @@ interface FilterScopeMap {
|
||||
[key: string]: FilterScopeMapEntry;
|
||||
}
|
||||
|
||||
export interface FilterScopeSelectorProps {
|
||||
interface FilterScopeSelectorProps {
|
||||
dashboardFilters: Record<number, DashboardFilter>;
|
||||
layout: DashboardLayout;
|
||||
updateDashboardFiltersScope: (
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
RefObject,
|
||||
} from 'react';
|
||||
import type { ChartCustomization, JsonObject } from '@superset-ui/core';
|
||||
import { VizType } from '@superset-ui/core';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { debounce } from 'lodash';
|
||||
@@ -496,9 +495,7 @@ const Chart = (props: ChartProps) => {
|
||||
const resultType = isPivot ? 'post_processed' : 'full';
|
||||
|
||||
let actualRowCount: number | undefined;
|
||||
const vizType = (formData as JsonObject)?.viz_type;
|
||||
const isTableViz =
|
||||
vizType === VizType.Table || vizType === VizType.TableAgGrid;
|
||||
const isTableViz = (formData as JsonObject)?.viz_type === 'table';
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
|
||||
export const CHART_MARGIN = 32;
|
||||
|
||||
export interface ChartHolderProps {
|
||||
interface ChartHolderProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
dashboardId: number;
|
||||
|
||||
@@ -39,7 +39,7 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
||||
|
||||
export interface ColumnProps {
|
||||
interface ColumnProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
component: LayoutItem;
|
||||
|
||||
@@ -43,13 +43,13 @@ import {
|
||||
GRID_BASE_UNIT,
|
||||
} from 'src/dashboard/util/constants';
|
||||
|
||||
export interface EditorInstance {
|
||||
interface EditorInstance {
|
||||
resize?: (force: boolean) => void;
|
||||
getSession?: () => { setUseWrapMode: (wrap: boolean) => void };
|
||||
focus?: () => void;
|
||||
}
|
||||
|
||||
export interface MarkdownOwnProps {
|
||||
interface MarkdownOwnProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
component: LayoutItem;
|
||||
@@ -71,7 +71,7 @@ export interface MarkdownOwnProps {
|
||||
updateComponents: (components: Record<string, LayoutItem>) => void;
|
||||
}
|
||||
|
||||
export interface MarkdownStateProps {
|
||||
interface MarkdownStateProps {
|
||||
logEvent: (eventName: string, eventData: JsonObject) => void;
|
||||
addDangerToast: (msg: string) => void;
|
||||
undoLength: number;
|
||||
@@ -80,9 +80,9 @@ export interface MarkdownStateProps {
|
||||
htmlSchemaOverrides?: JsonObject;
|
||||
}
|
||||
|
||||
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
||||
type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
|
||||
|
||||
export interface MarkdownState {
|
||||
interface MarkdownState {
|
||||
isFocused: boolean;
|
||||
markdownSource: string;
|
||||
editor: EditorInstance | null;
|
||||
|
||||
@@ -57,7 +57,7 @@ export const RENDER_TAB_CONTENT = 'RENDER_TAB_CONTENT';
|
||||
// Delay before refreshing charts to ensure they are fully mounted
|
||||
const CHART_MOUNT_DELAY = 100;
|
||||
|
||||
export interface TabProps {
|
||||
interface TabProps {
|
||||
dashboardId: number;
|
||||
id: string;
|
||||
parentId: string;
|
||||
|
||||
@@ -44,7 +44,7 @@ import TabsRenderer from '../TabsRenderer';
|
||||
import type { LayoutItem, RootState } from 'src/dashboard/types';
|
||||
import type { DropResult } from 'src/dashboard/components/dnd/dragDroppableConfig';
|
||||
|
||||
export interface TabsProps {
|
||||
interface TabsProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
component: LayoutItem;
|
||||
|
||||
@@ -39,7 +39,7 @@ import FilterBar from '.';
|
||||
import { FILTERS_CONFIG_MODAL_TEST_ID } from '../FiltersConfigModal/FiltersConfigModal';
|
||||
import * as dataMaskActions from 'src/dataMask/actions';
|
||||
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
...jest.requireActual('@superset-ui/core'),
|
||||
@@ -486,7 +486,7 @@ test('FilterBar renders correctly when filter has complete extraFormData', async
|
||||
expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('Clear All stages filter_select clear without dispatching until Apply', async () => {
|
||||
test('handleClearAll dispatches updateDataMask with value undefined for filter_select', async () => {
|
||||
const filterId = 'NATIVE_FILTER-clear-select';
|
||||
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
|
||||
const selectFilter = createFilter({
|
||||
@@ -513,9 +513,7 @@ test('Clear All stages filter_select clear without dispatching until Apply', asy
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[filterId]: createDataMask(filterId, ['East'], {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['East'] }],
|
||||
}),
|
||||
[filterId]: createDataMask(filterId, ['East']),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: selectFilter },
|
||||
@@ -535,24 +533,14 @@ test('Clear All stages filter_select clear without dispatching until Apply', asy
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
|
||||
// Clear All must not dispatch — staging only
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
|
||||
// Apply commits the staged clear
|
||||
const applyBtn = screen.getByTestId(getTestId('apply-button'));
|
||||
expect(applyBtn).not.toBeDisabled();
|
||||
await act(async () => {
|
||||
userEvent.click(applyBtn);
|
||||
});
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
|
||||
id: filterId,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Clear All stages filter_range clear with [null, null], dispatched on Apply', async () => {
|
||||
test('handleClearAll dispatches updateDataMask with [null, null] for filter_range', async () => {
|
||||
fetchMock.post('glob:*/api/v1/chart/data', {
|
||||
result: [{ data: [{ min: 0, max: 100 }] }],
|
||||
});
|
||||
@@ -582,9 +570,7 @@ test('Clear All stages filter_range clear with [null, null], dispatched on Apply
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[filterId]: createDataMask(filterId, [10, 50], {
|
||||
filters: [{ col: 'age', op: '>=', val: 10 }],
|
||||
}),
|
||||
[filterId]: createDataMask(filterId, [10, 50]),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: rangeFilter },
|
||||
@@ -604,21 +590,14 @@ test('Clear All stages filter_range clear with [null, null], dispatched on Apply
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
|
||||
const applyBtn = screen.getByTestId(getTestId('apply-button'));
|
||||
await act(async () => {
|
||||
userEvent.click(applyBtn);
|
||||
});
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
|
||||
id: filterId,
|
||||
filterState: { value: [null, null], validateStatus: undefined },
|
||||
filterState: { value: [null, null] },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Clear All + Apply only dispatches for filters present in dataMask', async () => {
|
||||
test('handleClearAll only dispatches for filters present in dataMask', async () => {
|
||||
const idInMask = 'NATIVE_FILTER-has-value';
|
||||
const idNotInMask = 'NATIVE_FILTER-no-value';
|
||||
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
|
||||
@@ -652,9 +631,7 @@ test('Clear All + Apply only dispatches for filters present in dataMask', async
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[idInMask]: createDataMask(idInMask, ['v'], {
|
||||
filters: [{ col: 'x', op: 'IN', val: ['v'] }],
|
||||
}),
|
||||
[idInMask]: createDataMask(idInMask, ['v']),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: {
|
||||
@@ -675,16 +652,10 @@ test('Clear All + Apply only dispatches for filters present in dataMask', async
|
||||
await act(async () => {
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
|
||||
const applyBtn = screen.getByTestId(getTestId('apply-button'));
|
||||
await act(async () => {
|
||||
userEvent.click(applyBtn);
|
||||
});
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledTimes(1);
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, {
|
||||
id: idInMask,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
updateDataMaskSpy.mockRestore();
|
||||
@@ -819,86 +790,18 @@ test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones',
|
||||
await act(async () => {
|
||||
userEvent.click(clearButton);
|
||||
});
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
|
||||
// After Apply: only the in-scope filter was cleared. Out-of-scope filters
|
||||
// retain their original values (Apply re-dispatches them unchanged).
|
||||
const applyButton = screen.getByTestId(getTestId('apply-button'));
|
||||
await act(async () => {
|
||||
userEvent.click(applyButton);
|
||||
});
|
||||
// Verify only the in-scope filter was cleared, not the out-of-scope ones
|
||||
const clearedFilterIds = updateDataMaskSpy.mock.calls.map(call => call[0]);
|
||||
expect(clearedFilterIds).toContain(inScopeFilterId);
|
||||
expect(clearedFilterIds).not.toContain(outOfScopeRequiredFilterId);
|
||||
expect(clearedFilterIds).not.toContain(outOfScopeNonRequiredFilterId);
|
||||
|
||||
// Verify the in-scope filter was cleared with the correct value
|
||||
expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, {
|
||||
id: inScopeFilterId,
|
||||
filterState: { value: undefined, validateStatus: undefined },
|
||||
filterState: { value: undefined },
|
||||
extraFormData: {},
|
||||
});
|
||||
|
||||
// Out-of-scope filters keep their existing values; not cleared
|
||||
const outOfScopeRequiredCall = updateDataMaskSpy.mock.calls.find(
|
||||
call => call[0] === outOfScopeRequiredFilterId,
|
||||
);
|
||||
expect(outOfScopeRequiredCall?.[1]?.filterState?.value).toEqual(['value2']);
|
||||
const outOfScopeNonRequiredCall = updateDataMaskSpy.mock.calls.find(
|
||||
call => call[0] === outOfScopeNonRequiredFilterId,
|
||||
);
|
||||
expect(outOfScopeNonRequiredCall?.[1]?.filterState?.value).toEqual([
|
||||
'value3',
|
||||
]);
|
||||
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('Clear All on a required filter disables Apply via validateStatus', async () => {
|
||||
const filterId = 'NATIVE_FILTER-required-clear';
|
||||
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
|
||||
const requiredFilter = createFilter({
|
||||
id: filterId,
|
||||
name: 'Required Region',
|
||||
filterType: 'filter_select',
|
||||
targets: [{ datasetId: 7, column: { name: 'region' } }],
|
||||
controlValues: { enableEmptyFilter: true },
|
||||
chartsInScope: [18],
|
||||
});
|
||||
const state = {
|
||||
...stateWithoutNativeFilters,
|
||||
dashboardInfo: {
|
||||
id: 1,
|
||||
dash_edit_perm: true,
|
||||
filterBarOrientation: FilterBarOrientation.Vertical,
|
||||
metadata: {
|
||||
native_filter_configuration: [requiredFilter],
|
||||
chart_configuration: {},
|
||||
},
|
||||
},
|
||||
dashboardState: {
|
||||
...stateWithoutNativeFilters.dashboardState,
|
||||
activeTabs: ['ROOT_ID'],
|
||||
},
|
||||
dataMask: {
|
||||
[filterId]: createDataMask(filterId, ['East'], {
|
||||
filters: [{ col: 'region', op: 'IN', val: ['East'] }],
|
||||
}),
|
||||
},
|
||||
nativeFilters: {
|
||||
filters: { [filterId]: requiredFilter },
|
||||
filtersState: {},
|
||||
},
|
||||
};
|
||||
|
||||
const props = createOpenedBarProps();
|
||||
renderFilterBar(props, state);
|
||||
await act(async () => {
|
||||
jest.advanceTimersByTime(300);
|
||||
});
|
||||
|
||||
const clearBtn = screen.getByTestId(getTestId('clear-button'));
|
||||
await act(async () => {
|
||||
userEvent.click(clearBtn);
|
||||
});
|
||||
|
||||
// No dispatch yet; Apply should be disabled because the required filter is empty
|
||||
expect(updateDataMaskSpy).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
|
||||
updateDataMaskSpy.mockRestore();
|
||||
});
|
||||
|
||||
@@ -498,20 +498,17 @@ const FilterBar: FC<FiltersBarProps> = ({
|
||||
// Range filters use [null, null] as the cleared value; others use undefined
|
||||
const clearedValue =
|
||||
filterType === 'filter_range' ? [null, null] : undefined;
|
||||
const isRequired = !!filter.controlValues?.enableEmptyFilter;
|
||||
const clearedDataMask = {
|
||||
filterState: { value: clearedValue },
|
||||
extraFormData: {},
|
||||
};
|
||||
if (dataMaskSelected[id]) {
|
||||
// Stage the cleared value locally; do NOT dispatch to Redux here.
|
||||
// Persistence happens when the user clicks Apply.
|
||||
dispatch(updateDataMask(id, clearedDataMask));
|
||||
setDataMaskSelected(draft => {
|
||||
if (draft[id].filterState?.value !== undefined) {
|
||||
draft[id].filterState!.value = clearedValue;
|
||||
}
|
||||
draft[id].extraFormData = {};
|
||||
if (draft[id].filterState) {
|
||||
draft[id].filterState!.validateStatus = isRequired
|
||||
? 'error'
|
||||
: undefined;
|
||||
}
|
||||
});
|
||||
newClearAllTriggers[id] = true;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('FilterScope TreeInitialization', () => {
|
||||
let formRef: { current: FormInstance | null };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
formRef = { current: null };
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('FilterScope TreeSelection', () => {
|
||||
let formRef: { current: FormInstance | null };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
formRef = { current: null };
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const TIME_GRAIN_TUPLES: [string, string][] = [
|
||||
// "state update on unmounted component" warnings. Scoped fake timers let us
|
||||
// clear pending work deterministically during teardown for this test only.
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import { buildActiveFilters } from '../util/activeDashboardFilters';
|
||||
import { getChartIdAndColumnFromFilterKey } from '../util/getDashboardFilterKey';
|
||||
import { LayoutItem } from '../types';
|
||||
|
||||
export interface FilterScope {
|
||||
interface FilterScope {
|
||||
scope: string[];
|
||||
immune: number[];
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ interface DashboardInfoAction {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface HydrateDashboardInfoAction {
|
||||
interface HydrateDashboardAction {
|
||||
type: typeof HYDRATE_DASHBOARD;
|
||||
data: {
|
||||
dashboardInfo: DashboardInfo;
|
||||
@@ -65,9 +65,7 @@ export interface HydrateDashboardInfoAction {
|
||||
};
|
||||
}
|
||||
|
||||
type DashboardInfoReducerAction =
|
||||
| DashboardInfoAction
|
||||
| HydrateDashboardInfoAction;
|
||||
type DashboardInfoReducerAction = DashboardInfoAction | HydrateDashboardAction;
|
||||
|
||||
type DashboardInfoState = Partial<DashboardInfo> & {
|
||||
last_modified_time?: number;
|
||||
@@ -76,7 +74,7 @@ type DashboardInfoState = Partial<DashboardInfo> & {
|
||||
|
||||
function isHydrateAction(
|
||||
action: DashboardInfoReducerAction,
|
||||
): action is HydrateDashboardInfoAction {
|
||||
): action is HydrateDashboardAction {
|
||||
return action.type === HYDRATE_DASHBOARD;
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ interface DashboardMetadata {
|
||||
chart_customization_config?: ChartCustomization[];
|
||||
}
|
||||
|
||||
export interface HydrateDataMaskAction {
|
||||
interface HydrateDashboardAction {
|
||||
type: typeof HYDRATE_DASHBOARD;
|
||||
data: {
|
||||
dashboardInfo: {
|
||||
@@ -199,7 +199,7 @@ function updateDataMaskForFilterChanges(
|
||||
const dataMaskReducer = produce(
|
||||
(
|
||||
draft: DataMaskStateWithId,
|
||||
action: AnyDataMaskAction | HydrateDataMaskAction | HydrateExplore,
|
||||
action: AnyDataMaskAction | HydrateDashboardAction | HydrateExplore,
|
||||
) => {
|
||||
const cleanState: DataMaskStateWithId = {};
|
||||
switch (action.type) {
|
||||
@@ -213,7 +213,7 @@ const dataMaskReducer = produce(
|
||||
};
|
||||
return draft;
|
||||
case HYDRATE_DASHBOARD: {
|
||||
const hydrateDashboardAction = action as HydrateDataMaskAction;
|
||||
const hydrateDashboardAction = action as HydrateDashboardAction;
|
||||
const metadata = hydrateDashboardAction.data.dashboardInfo?.metadata;
|
||||
const loadedDataMask = hydrateDashboardAction.data.dataMask;
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import 'src/public-path';
|
||||
|
||||
import { lazy, StrictMode, Suspense, useEffect } from 'react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -66,21 +66,20 @@ const LazyDashboardPage = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const EmbeddedLazyDashboardPage = () => {
|
||||
const EmbededLazyDashboardPage = () => {
|
||||
const uiConfig = useUiConfig();
|
||||
const emitDataMasks = uiConfig?.emitDataMasks;
|
||||
|
||||
// Emit data mask changes to the parent window. Subscribing inside an effect
|
||||
// (rather than during render) ensures the unsubscribe runs on unmount,
|
||||
// including StrictMode's dev-mode double-mount cycle.
|
||||
useEffect(() => {
|
||||
if (!emitDataMasks) return undefined;
|
||||
// Emit data mask changes to the parent window
|
||||
if (uiConfig?.emitDataMasks) {
|
||||
log('setting up Switchboard event emitter');
|
||||
|
||||
let previousDataMask = store.getState().dataMask;
|
||||
|
||||
return store.subscribe(() => {
|
||||
const currentDataMask = store.getState().dataMask;
|
||||
store.subscribe(() => {
|
||||
const currentState = store.getState();
|
||||
const currentDataMask = currentState.dataMask;
|
||||
|
||||
// Only emit if the dataMask has changed
|
||||
if (previousDataMask !== currentDataMask) {
|
||||
Switchboard.emit('observeDataMask', {
|
||||
...currentDataMask,
|
||||
@@ -89,7 +88,7 @@ const EmbeddedLazyDashboardPage = () => {
|
||||
previousDataMask = currentDataMask;
|
||||
}
|
||||
});
|
||||
}, [emitDataMasks]);
|
||||
}
|
||||
|
||||
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
|
||||
};
|
||||
@@ -108,7 +107,7 @@ const EmbeddedRoute = () => (
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<EmbeddedLazyDashboardPage />
|
||||
<EmbededLazyDashboardPage />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</Suspense>
|
||||
@@ -197,11 +196,7 @@ function start() {
|
||||
if (!root) {
|
||||
root = createRoot(appMountPoint);
|
||||
}
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<EmbeddedApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
root.render(<EmbeddedApp />);
|
||||
},
|
||||
err => {
|
||||
// something is most likely wrong with the guest token; reset the guard
|
||||
|
||||
@@ -21,10 +21,6 @@ import { VizType } from '@superset-ui/core';
|
||||
import { hydrateExplore, HYDRATE_EXPLORE } from './hydrateExplore';
|
||||
import { exploreInitialData } from '../fixtures';
|
||||
|
||||
afterEach(() => {
|
||||
window.history.pushState({}, '', '/');
|
||||
});
|
||||
|
||||
test('creates hydrate action from initial data', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
@@ -172,84 +168,6 @@ test('creates hydrate action with existing state', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates sliceName from preview form data before saved slice name', () => {
|
||||
window.history.pushState({}, '', '/explore/?form_data_key=preview-key');
|
||||
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
const previewSliceName = 'RENAMED - Bug Evidence';
|
||||
const savedSliceName = 'Most Populated Countries';
|
||||
const previewInitialData = {
|
||||
...exploreInitialData,
|
||||
form_data: {
|
||||
...exploreInitialData.form_data,
|
||||
slice_name: previewSliceName,
|
||||
},
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error we only need the fields consumed by hydrateExplore
|
||||
hydrateExplore(previewInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: expect.objectContaining({
|
||||
explore: expect.objectContaining({
|
||||
sliceName: previewSliceName,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('hydrates sliceName from saved slice when regular form data has stale name', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
user: {},
|
||||
charts: {},
|
||||
datasources: {},
|
||||
common: {},
|
||||
explore: {},
|
||||
}));
|
||||
const staleFormDataSliceName = 'Stale Params Name';
|
||||
const savedSliceName = 'Current Saved Name';
|
||||
const savedChartInitialData = {
|
||||
...exploreInitialData,
|
||||
form_data: {
|
||||
...exploreInitialData.form_data,
|
||||
slice_name: staleFormDataSliceName,
|
||||
},
|
||||
slice: {
|
||||
...exploreInitialData.slice!,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
};
|
||||
|
||||
// @ts-expect-error we only need the fields consumed by hydrateExplore
|
||||
hydrateExplore(savedChartInitialData)(dispatch, getState);
|
||||
|
||||
expect(dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
type: HYDRATE_EXPLORE,
|
||||
data: expect.objectContaining({
|
||||
explore: expect.objectContaining({
|
||||
sliceName: savedSliceName,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('uses configured default time range if not set', () => {
|
||||
const dispatch = jest.fn();
|
||||
const getState = jest.fn(() => ({
|
||||
|
||||
@@ -77,12 +77,6 @@ export const hydrateExplore =
|
||||
const fallbackSlice = sliceId ? sliceEntities?.slices?.[sliceId] : null;
|
||||
const initialSlice = slice ?? fallbackSlice;
|
||||
const initialFormData = form_data ?? initialSlice?.form_data;
|
||||
const isCachedFormData = getUrlParam(URL_PARAMS.formDataKey) !== null;
|
||||
const [primarySliceNameSource, fallbackSliceNameSource] = isCachedFormData
|
||||
? [initialFormData, initialSlice]
|
||||
: [initialSlice, initialFormData];
|
||||
const initialSliceName =
|
||||
primarySliceNameSource?.slice_name ?? fallbackSliceNameSource?.slice_name;
|
||||
if (!initialFormData.viz_type) {
|
||||
const defaultVizType = common?.conf.DEFAULT_VIZ_TYPE || VizType.Table;
|
||||
initialFormData.viz_type =
|
||||
@@ -189,7 +183,6 @@ export const hydrateExplore =
|
||||
// because `bootstrapData.controls` is undefined.
|
||||
controls: initialControls,
|
||||
form_data: initialFormData,
|
||||
sliceName: initialSliceName,
|
||||
slice: initialSlice,
|
||||
controlsTransferred: explore.controlsTransferred,
|
||||
standalone: getUrlParam(URL_PARAMS.standalone),
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { forwardRef, RefObject } from 'react';
|
||||
import { QueryData, VizType } from '@superset-ui/core';
|
||||
import { QueryData } from '@superset-ui/core';
|
||||
import { css, SupersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
CachedLabel,
|
||||
@@ -68,9 +68,7 @@ export const ChartPills = forwardRef(
|
||||
const firstQueryResponse = queriesResponse?.[0];
|
||||
|
||||
// For table charts with server pagination, check second query for total count
|
||||
const isTableChart =
|
||||
formData?.viz_type === VizType.Table ||
|
||||
formData?.viz_type === VizType.TableAgGrid;
|
||||
const isTableChart = formData?.viz_type === 'table';
|
||||
const hasCountQuery = queriesResponse && queriesResponse.length > 1;
|
||||
const countFromSecondQuery = hasCountQuery
|
||||
? queriesResponse[1]?.data?.[0]?.rowcount
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useContext, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
@@ -26,11 +26,12 @@ import { ControlConfig } from '@superset-ui/chart-controls';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import { Input } from '@superset-ui/core/components';
|
||||
import { Constants, Input } from '@superset-ui/core/components';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import { ExploreActions } from 'src/explore/actions/exploreActions';
|
||||
import Control from 'src/explore/components/Control';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { DndItemType } from '../DndItemType';
|
||||
import { DatasourceFolder, DatasourcePanelColumn, DndItemValue } from './types';
|
||||
import { DropzoneContext } from '../ExploreContainer';
|
||||
@@ -159,7 +160,7 @@ export default function DataSourcePanel({
|
||||
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const searchKeyword = useDeferredValue(inputValue);
|
||||
const searchKeyword = useDebounceValue(inputValue, Constants.FAST_DEBOUNCE);
|
||||
|
||||
const filteredColumns = useMemo(() => {
|
||||
if (!searchKeyword) {
|
||||
|
||||
@@ -179,33 +179,6 @@ test('renders the right footer buttons', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('initializes chart name from current Explore slice name', () => {
|
||||
const previewSliceName = 'RENAMED - Bug Evidence';
|
||||
const savedSliceName = 'Most Populated Countries';
|
||||
const { getByTestId } = setup(
|
||||
{
|
||||
...defaultProps,
|
||||
form_data: {
|
||||
...defaultProps.form_data,
|
||||
slice_name: previewSliceName,
|
||||
},
|
||||
sliceName: previewSliceName,
|
||||
},
|
||||
mockStore({
|
||||
...initialState,
|
||||
explore: {
|
||||
...initialState.explore,
|
||||
slice: {
|
||||
...initialState.explore.slice,
|
||||
slice_name: savedSliceName,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getByTestId('new-chart-name')).toHaveValue(previewSliceName);
|
||||
});
|
||||
|
||||
test('does not render a message when overriding', () => {
|
||||
const { getByRole, queryByRole } = setup();
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ interface CollectionItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface CollectionControlProps {
|
||||
interface CollectionControlProps {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
@@ -94,7 +94,7 @@ interface FormData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface DatasourceControlProps {
|
||||
interface DatasourceControlProps {
|
||||
actions: DatasourceControlActions;
|
||||
onChange?: () => void;
|
||||
value?: string | null;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { CustomFrame } from '../components';
|
||||
|
||||
const TODAY = '2024-06-03';
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.useFakeTimers();
|
||||
jest.setSystemTime(new Date(TODAY).getTime());
|
||||
|
||||
const emptyValue = '';
|
||||
|
||||
@@ -69,7 +69,7 @@ interface Datasource {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface AdhocFilterControlProps {
|
||||
interface AdhocFilterControlProps {
|
||||
label?: ReactNode;
|
||||
name?: string;
|
||||
sections?: string[];
|
||||
|
||||
@@ -45,6 +45,10 @@ jest.mock('src/core/editors', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('src/hooks/useDebounceValue', () => ({
|
||||
useDebounceValue: (value: string) => value,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
name: 'echartOptions',
|
||||
label: 'EChart Options',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useDeferredValue, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import ControlHeader, {
|
||||
ControlHeaderProps,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EChartOptionsParseError,
|
||||
} from '@superset-ui/plugin-chart-echarts';
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
|
||||
const Container = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
@@ -49,10 +50,10 @@ export default function JSEditorControl({
|
||||
onChange,
|
||||
value,
|
||||
}: ControlHeaderProps & ControlComponentProps<string>) {
|
||||
const deferredValue = useDeferredValue(value);
|
||||
const debouncedValue = useDebounceValue(value);
|
||||
const error = useMemo(() => {
|
||||
try {
|
||||
safeParseEChartOptions(deferredValue ?? '');
|
||||
safeParseEChartOptions(debouncedValue ?? '');
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof EChartOptionsParseError) {
|
||||
@@ -60,7 +61,7 @@ export default function JSEditorControl({
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, [deferredValue]);
|
||||
}, [debouncedValue]);
|
||||
const headerProps = {
|
||||
name,
|
||||
label: label ?? name,
|
||||
|
||||
@@ -108,7 +108,7 @@ const getMetricsMatchingCurrentDataset = (
|
||||
);
|
||||
});
|
||||
|
||||
export interface MetricsControlProps {
|
||||
interface MetricsControlProps {
|
||||
name: string;
|
||||
onChange: (value: unknown) => void;
|
||||
multi?: boolean;
|
||||
|
||||