Compare commits

..

2 Commits

Author SHA1 Message Date
Amin Ghadersohi
16d136ef8d fix(mcp): context-aware recovery hints and sanitize identifier in not-found errors
- In get_chart_preview, when identifier looks like a form_data_key (long
  non-numeric string), suggest regenerating the explore link rather than
  always pointing to list_charts, which is only relevant for chart IDs.
- Truncate request.identifier to 200 chars before embedding in error
  messages across get_chart_preview, get_chart_data, and update_chart
  to prevent injection via oversized attacker-controlled identifiers.
2026-05-09 00:26:11 +00:00
Amin Ghadersohi
c78658d852 fix(mcp): improve "not found" errors to suggest corresponding list_* tools
When MCP tools return "not found" errors for database, chart, dataset, or
dashboard IDs, include recovery guidance pointing to the appropriate list
tool (list_databases, list_charts, list_datasets, list_dashboards).

Affected tools: execute_sql, open_sql_lab_with_context, query_dataset,
get_chart_data, get_chart_preview, update_chart,
add_chart_to_existing_dashboard, generate_dashboard
2026-05-06 22:55:46 +00:00
177 changed files with 4403 additions and 12622 deletions

View File

@@ -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'

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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/)
:::

View File

@@ -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

View File

@@ -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

View File

@@ -70,7 +70,7 @@
"@swc/core": "^1.15.33",
"antd": "^6.3.7",
"baseline-browser-mapping": "^2.10.27",
"caniuse-lite": "^1.0.30001792",
"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": {

View File

@@ -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);

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 132 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 118 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 240 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 141 KiB

View File

@@ -5040,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":
@@ -6035,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"
@@ -14715,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"

View File

@@ -142,16 +142,10 @@ 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",
]
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"]
@@ -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"]

View File

@@ -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

View File

@@ -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

View File

@@ -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');

View File

@@ -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",
@@ -120,13 +120,13 @@
"pretty-ms": "^9.3.0",
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react": "^18.2.0",
"react-arborist": "^3.5.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
@@ -209,8 +209,8 @@
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
@@ -274,7 +274,6 @@
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^11.1.3",
"react-refresh": "^0.18.0",
"react-resizable": "^3.1.3",
"redux-mock-store": "^1.5.4",
@@ -24761,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",
@@ -41225,16 +41224,6 @@
"dnd-core": "^11.1.3"
}
},
"node_modules/react-dnd-test-backend": {
"version": "11.1.3",
"resolved": "https://registry.npmjs.org/react-dnd-test-backend/-/react-dnd-test-backend-11.1.3.tgz",
"integrity": "sha512-5qFm+NI2GdWIUfiYun0A8Gv0xjbq0NGOPS+f6z3x/3nTuliApjmqcM1lfTgePoz1FDG47ZofF58N8y96If62+Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"dnd-core": "^11.1.3"
}
},
"node_modules/react-docgen": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/react-docgen/-/react-docgen-7.1.1.tgz",
@@ -50014,8 +50003,8 @@
"jed": "^1.1.1",
"lodash": "^4.18.1",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
}
@@ -50041,9 +50030,9 @@
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
"memoize-one": "^5.1.1",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
}
},
"packages/superset-ui-core": {
@@ -50128,8 +50117,8 @@
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
}
@@ -50294,7 +50283,7 @@
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-calendar/node_modules/d3-array": {
@@ -50355,7 +50344,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-country-map/node_modules/d3-array": {
@@ -50383,7 +50372,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-horizon/node_modules/d3-array": {
@@ -50417,7 +50406,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-parallel-coordinates": {
@@ -50432,7 +50421,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-partition": {
@@ -50450,8 +50439,8 @@
"@superset-ui/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-rose": {
@@ -50468,7 +50457,7 @@
"@emotion/react": "^11.4.1",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-world-map": {
@@ -50486,7 +50475,7 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/legacy-plugin-chart-world-map/node_modules/d3-array": {
@@ -50573,7 +50562,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/plugin-chart-ag-grid-table": {
@@ -50601,8 +50590,8 @@
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "*",
"@types/react": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-ag-grid-table/node_modules/d3-array": {
@@ -50646,8 +50635,8 @@
"geostyler-wfs-parser": "^3.0.1",
"ol": "^10.8.0",
"polished": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-echarts": {
@@ -50669,7 +50658,7 @@
"dayjs": "^1.11.19",
"echarts": "*",
"memoize-one": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/plugin-chart-echarts/node_modules/acorn": {
@@ -50727,9 +50716,9 @@
"dayjs": "^1.11.19",
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-handlebars/node_modules/just-handlebars-helpers": {
@@ -50760,8 +50749,8 @@
"@superset-ui/core": "*",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-point-cluster-map": {
@@ -50779,8 +50768,8 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-point-cluster-map/node_modules/react-map-gl": {
@@ -50833,8 +50822,8 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"match-sorter": "^8.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
},
"plugins/plugin-chart-table/node_modules/d3-array": {
@@ -50873,7 +50862,7 @@
"@superset-ui/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
},
"plugins/plugin-chart-word-cloud/node_modules/@types/d3-scale": {
@@ -50904,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",
@@ -50942,8 +50931,8 @@
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"mapbox-gl": ">=1.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {

View File

@@ -109,20 +109,20 @@
"@emotion/cache": "^11.4.0",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@googleapis/sheets": "^13.0.1",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.7.0",
"@luma.gl/constants": "~9.2.5",
"@luma.gl/core": "~9.2.5",
"@luma.gl/engine": "~9.2.5",
"@luma.gl/gltf": "~9.2.5",
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@fontsource/fira-code": "^5.2.7",
"@fontsource/inter": "^5.2.8",
"@great-expectations/jsonforms-antd-renderers": "^2.2.10",
"@jsonforms/core": "^3.7.0",
"@jsonforms/react": "^3.7.0",
"@jsonforms/vanilla-renderers": "^3.7.0",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/antd": "^5.24.13",
"@rjsf/core": "^5.24.13",
@@ -140,16 +140,16 @@
"@superset-ui/legacy-plugin-chart-partition": "file:./plugins/legacy-plugin-chart-partition",
"@superset-ui/legacy-plugin-chart-rose": "file:./plugins/legacy-plugin-chart-rose",
"@superset-ui/legacy-plugin-chart-world-map": "file:./plugins/legacy-plugin-chart-world-map",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/legacy-preset-chart-nvd3": "file:./plugins/legacy-preset-chart-nvd3",
"@superset-ui/plugin-chart-ag-grid-table": "file:./plugins/plugin-chart-ag-grid-table",
"@superset-ui/plugin-chart-cartodiagram": "file:./plugins/plugin-chart-cartodiagram",
"@superset-ui/plugin-chart-echarts": "file:./plugins/plugin-chart-echarts",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-handlebars": "file:./plugins/plugin-chart-handlebars",
"@superset-ui/plugin-chart-pivot-table": "file:./plugins/plugin-chart-pivot-table",
"@superset-ui/plugin-chart-point-cluster-map": "file:./plugins/plugin-chart-point-cluster-map",
"@superset-ui/plugin-chart-table": "file:./plugins/plugin-chart-table",
"@superset-ui/plugin-chart-word-cloud": "file:./plugins/plugin-chart-word-cloud",
"@superset-ui/preset-chart-deckgl": "file:./plugins/preset-chart-deckgl",
"@superset-ui/switchboard": "file:./packages/superset-ui-switchboard",
"@types/d3-format": "^3.0.1",
"@types/d3-selection": "^3.0.11",
@@ -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",
@@ -201,13 +201,13 @@
"pretty-ms": "^9.3.0",
"query-string": "9.3.1",
"re-resizable": "^6.11.2",
"react": "^18.3.0",
"react": "^18.2.0",
"react-arborist": "^3.5.0",
"react-checkbox-tree": "^1.8.0",
"react-diff-viewer-continued": "^4.2.2",
"react-dnd": "^11.1.3",
"react-dnd-html5-backend": "^11.1.3",
"react-dom": "^18.3.0",
"react-dom": "^18.2.0",
"react-google-recaptcha": "^3.1.0",
"react-intersection-observer": "^10.0.3",
"react-json-tree": "^0.20.0",
@@ -290,8 +290,8 @@
"@types/json-bigint": "^1.0.4",
"@types/mousetrap": "^1.6.15",
"@types/node": "^25.6.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"@types/react-loadable": "^5.5.11",
"@types/react-redux": "^7.1.10",
"@types/react-resizable": "^3.0.8",
@@ -355,7 +355,6 @@
"prettier": "3.8.3",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^11.1.3",
"react-refresh": "^0.18.0",
"react-resizable": "^3.1.3",
"redux-mock-store": "^1.5.4",

View File

@@ -97,8 +97,8 @@
"@fontsource/ibm-plex-mono": "^5.2.7",
"@fontsource/inter": "^5.2.6",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*",
"lodash": "^4.18.1",

View File

@@ -38,12 +38,9 @@ import {
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
export class Theme {
// 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);

View File

@@ -50,24 +50,20 @@ test('should pipe to `console` methods', () => {
});
test('should use noop functions when console unavailable', () => {
const originalConsole = window.console;
Object.assign(window, { console: undefined });
try {
const { logging } = require('@apache-superset/core/utils');
const { logging } = require('@apache-superset/core/utils');
expect(() => {
logging.debug();
logging.log();
logging.info();
logging.warn('warn');
logging.error('error');
logging.trace();
logging.table([
[1, 2],
[3, 4],
]);
}).not.toThrow();
} finally {
Object.assign(window, { console: originalConsole });
}
expect(() => {
logging.debug();
logging.log();
logging.info();
logging.warn('warn');
logging.error('error');
logging.trace();
logging.table([
[1, 2],
[3, 4],
]);
}).not.toThrow();
Object.assign(window, { console });
});

View File

@@ -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/*';

View File

@@ -40,9 +40,9 @@
"ace-builds": "^1.4.14",
"brace": "^0.11.1",
"memoize-one": "^5.1.1",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -101,8 +101,8 @@
"@types/tinycolor2": "*",
"antd": "^5.26.0",
"nanoid": "^5.0.9",
"react": "^18.3.0",
"react-dom": "^18.3.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-loadable": "^5.5.0",
"tinycolor2": "*"
},

View File

@@ -17,16 +17,15 @@
* under the License.
*/
import { forwardRef } from 'react';
import { Avatar as AntdAvatar } from 'antd';
import type { AvatarProps, GroupProps as AvatarGroupProps } from './types';
export const Avatar = forwardRef<HTMLSpanElement, AvatarProps>((props, ref) => (
<AntdAvatar ref={ref} {...props} />
));
export function Avatar(props: AvatarProps) {
return <AntdAvatar {...props} />;
}
export const AvatarGroup = (props: AvatarGroupProps) => (
<AntdAvatar.Group {...props} />
);
export function AvatarGroup(props: AvatarGroupProps) {
return <AntdAvatar.Group {...props} />;
}
export type { AvatarProps, AvatarGroupProps };

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Children, ReactElement, Fragment, forwardRef, Ref } from 'react';
import { Children, ReactElement, Fragment } from 'react';
import cx from 'classnames';
import { Button as AntdButton } from 'antd';
import { useTheme } from '@apache-superset/core/theme';
@@ -100,7 +100,7 @@ const BUTTON_STYLE_MAP: Record<
link: { type: 'link' },
};
function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
export function Button(props: ButtonProps) {
const {
tooltip,
placement,
@@ -160,7 +160,6 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
const button = (
<AntdButton
ref={ref as Ref<HTMLButtonElement & HTMLAnchorElement>}
href={disabled ? undefined : href}
disabled={disabled}
type={antdType}
@@ -236,6 +235,4 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
return button;
}
export const Button = forwardRef<HTMLElement, ButtonProps>(ButtonInner);
export type { ButtonProps, OnClickHandler };

View File

@@ -75,10 +75,7 @@ export const DropdownButton = ({
id={`${kebabCase(tooltip)}-tooltip`}
title={tooltip}
>
{/* antd Dropdown.Button is a plain function component without
forwardRef; wrap in a span so the Tooltip can attach a ref to a
real DOM node and skip the findDOMNode fallback. */}
<span>{button}</span>
{button}
</Tooltip>
);
}

View File

@@ -240,10 +240,7 @@ export function EditableTitle({
t("You don't have the rights to alter this title.")
}
>
{/* Wrap in span so the Tooltip can attach a ref to a DOM element.
antd's Input.TextArea forwards a non-DOM imperative handle, which
triggers a React 18 findDOMNode deprecation warning. */}
<span>{titleComponent}</span>
{titleComponent}
</Tooltip>
);
}

View File

@@ -16,54 +16,47 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tooltip } from '../Tooltip';
import { Button } from '../Button';
import type { IconTooltipProps } from './types';
export const IconTooltip = forwardRef<HTMLElement, IconTooltipProps>(
(
{
children = null,
className = '',
onClick = () => undefined,
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
},
ref,
) => {
const iconTooltip = (
<Button
ref={ref}
onClick={onClick}
style={{
padding: 0,
...style,
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
export const IconTooltip = ({
children = null,
className = '',
onClick = () => undefined,
placement = 'top',
style = {},
tooltip = null,
mouseEnterDelay = 0.3,
mouseLeaveDelay = 0.15,
}: IconTooltipProps) => {
const iconTooltip = (
<Button
onClick={onClick}
style={{
padding: 0,
...style,
}}
buttonStyle="link"
className={`IconTooltip ${className}`}
>
{children}
</Button>
);
if (tooltip) {
return (
<Tooltip
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
>
{children}
</Button>
{iconTooltip}
</Tooltip>
);
if (tooltip) {
return (
<Tooltip
id="tooltip"
title={tooltip}
placement={placement}
mouseEnterDelay={mouseEnterDelay}
mouseLeaveDelay={mouseLeaveDelay}
>
{iconTooltip}
</Tooltip>
);
}
return iconTooltip;
},
);
}
return iconTooltip;
};
export type { IconTooltipProps };

View File

@@ -165,7 +165,7 @@ import {
SlackOutlined,
ApiOutlined,
} from '@ant-design/icons';
import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react';
import { FC } from 'react';
import { IconType } from './types';
import { BaseIconComponent } from './BaseIcon';
@@ -323,25 +323,19 @@ type AntdIconNames = keyof typeof AntdIcons;
export const antdEnhancedIcons: Record<
AntdIconNames,
ForwardRefExoticComponent<IconType & RefAttributes<HTMLSpanElement>>
FC<IconType>
> = Object.keys(AntdIcons)
.filter(key => !EXCLUDED_ICONS.some(excluded => key.includes(excluded)))
.reduce(
(acc, key) => {
acc[key as AntdIconNames] = forwardRef<HTMLSpanElement, IconType>(
(props, ref) => (
<BaseIconComponent
ref={ref}
component={AntdIcons[key as AntdIconNames]}
fileName={key}
{...props}
/>
),
acc[key as AntdIconNames] = (props: IconType) => (
<BaseIconComponent
component={AntdIcons[key as AntdIconNames]}
fileName={key}
{...props}
/>
);
return acc;
},
{} as Record<
AntdIconNames,
ForwardRefExoticComponent<IconType & RefAttributes<HTMLSpanElement>>
>,
{} as Record<AntdIconNames, FC<IconType>>,
);

View File

@@ -17,12 +17,12 @@
* under the License.
*/
import { FC, SVGProps, forwardRef, useEffect, useRef, useState } from 'react';
import { FC, SVGProps, useEffect, useRef, useState } from 'react';
import TransparentIcon from './svgs/transparent.svg';
import { IconType } from './types';
import { BaseIconComponent } from './BaseIcon';
const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
const AsyncIcon = (props: IconType) => {
const [, setLoaded] = useState(false);
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
@@ -46,7 +46,6 @@ const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
return (
<BaseIconComponent
ref={ref}
component={ImportedSVG.current || TransparentIcon}
fileName={fileName}
customIcons={customIcons}
@@ -56,6 +55,6 @@ const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
{...restProps}
/>
);
});
};
export default AsyncIcon;

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { forwardRef, type ComponentType } from 'react';
import { css, useTheme, getFontSize } from '@apache-superset/core/theme';
import { AntdIconType, BaseIconProps, CustomIconType, IconType } from './types';
@@ -36,78 +35,65 @@ const genAriaLabel = (fileName: string) => {
return name.toLowerCase();
};
export const BaseIconComponent = forwardRef<
HTMLSpanElement | SVGSVGElement,
export const BaseIconComponent: React.FC<
BaseIconProps & Omit<IconType, 'component'>
>(
(
{
component: Component,
iconColor,
iconSize,
viewBox,
customIcons,
fileName,
...rest
},
ref,
) => {
const theme = useTheme();
const whatRole = rest?.onClick ? 'button' : 'img';
const ariaLabel = genAriaLabel(fileName || '');
const style = {
color: iconColor,
fontSize: iconSize
? `${getFontSize(theme, iconSize)}px`
: `${theme.fontSize}px`,
cursor: rest?.onClick ? 'pointer' : undefined,
};
> = ({
component: Component,
iconColor,
iconSize,
viewBox,
customIcons,
fileName,
...rest
}) => {
const theme = useTheme();
const whatRole = rest?.onClick ? 'button' : 'img';
const ariaLabel = genAriaLabel(fileName || '');
const style = {
color: iconColor,
fontSize: iconSize
? `${getFontSize(theme, iconSize)}px`
: `${theme.fontSize}px`,
cursor: rest?.onClick ? 'pointer' : undefined,
};
const AntdComponent = Component as ComponentType<
Record<string, unknown> & {
ref?: React.Ref<HTMLSpanElement | SVGSVGElement>;
}
>;
return customIcons ? (
<span
ref={ref as React.Ref<HTMLSpanElement>}
role={whatRole}
aria-label={ariaLabel}
data-test={ariaLabel}
css={[
css`
display: inline-flex;
align-items: center;
line-height: 0;
vertical-align: middle;
`,
]}
>
<Component
viewBox={viewBox || '0 0 24 24'}
style={style}
width={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
height={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
{...(rest as CustomIconType)}
/>
</span>
) : (
<AntdComponent
ref={ref}
role={whatRole}
return customIcons ? (
<span
role={whatRole}
aria-label={ariaLabel}
data-test={ariaLabel}
css={[
css`
display: inline-flex;
align-items: center;
line-height: 0;
vertical-align: middle;
`,
]}
>
<Component
viewBox={viewBox || '0 0 24 24'}
style={style}
aria-label={ariaLabel}
data-test={ariaLabel}
{...(rest as AntdIconType)}
width={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
height={
iconSize
? `${getFontSize(theme, iconSize) || theme.fontSize}px`
: `${theme.fontSize}px`
}
{...(rest as CustomIconType)}
/>
);
},
);
</span>
) : (
<Component
role={whatRole}
style={style}
aria-label={ariaLabel}
data-test={ariaLabel}
{...(rest as AntdIconType)}
/>
);
};

View File

@@ -17,16 +17,12 @@
* under the License.
*/
import { ForwardRefExoticComponent, RefAttributes, forwardRef } from 'react';
import { FC } from 'react';
import { antdEnhancedIcons } from './AntdEnhanced';
import AsyncIcon from './AsyncIcon';
import type { IconType } from './types';
type IconComponent = ForwardRefExoticComponent<
IconType & RefAttributes<HTMLSpanElement>
>;
/**
* Filename is going to be inferred from the icon name.
* i.e. BigNumberChartTile => assets/images/icons/big_number_chart_tile
@@ -62,17 +58,15 @@ const customIcons = [
'Undo',
] as const;
type CustomIconType = Record<(typeof customIcons)[number], IconComponent>;
type CustomIconType = Record<(typeof customIcons)[number], FC<IconType>>;
const iconOverrides: CustomIconType = {} as CustomIconType;
customIcons.forEach(customIcon => {
const fileName = customIcon
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
.toLowerCase();
iconOverrides[customIcon] = forwardRef<HTMLSpanElement, IconType>(
(props, ref) => (
<AsyncIcon ref={ref} customIcons fileName={fileName} {...props} />
),
iconOverrides[customIcon] = (props: IconType) => (
<AsyncIcon customIcons fileName={fileName} {...props} />
);
});
@@ -80,7 +74,7 @@ export type IconNameType =
| keyof typeof antdEnhancedIcons
| keyof typeof iconOverrides;
type IconComponentType = Record<IconNameType, IconComponent>;
type IconComponentType = Record<IconNameType, FC<IconType>>;
export const Icons: IconComponentType = {
...antdEnhancedIcons,

View File

@@ -16,7 +16,6 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tag } from '@superset-ui/core/components/Tag';
import { css } from '@emotion/react';
import { useTheme, getColorVariants } from '@apache-superset/core/theme';
@@ -24,7 +23,7 @@ import { DatasetTypeLabel } from './reusable/DatasetTypeLabel';
import { PublishedLabel } from './reusable/PublishedLabel';
import type { LabelProps } from './types';
export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
export function Label(props: LabelProps) {
const theme = useTheme();
// Use Ant Design's motion duration instead of deprecated transitionTiming
const {
@@ -72,7 +71,6 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
return (
<Tag
ref={ref}
onClick={onClick}
role={onClick ? 'button' : undefined}
style={style}
@@ -83,6 +81,6 @@ export const Label = forwardRef<HTMLSpanElement, LabelProps>((props, ref) => {
{children}
</Tag>
);
});
}
export { DatasetTypeLabel, PublishedLabel };
export type { LabelType } from './types';

View File

@@ -357,9 +357,6 @@ const CustomModal = ({
disabled={!draggable || dragDisabled}
bounds={bounds}
onStart={(event, uiData) => onDragStart(event, uiData)}
// Pass nodeRef so react-draggable does not fall back to
// ReactDOM.findDOMNode (deprecated in React 18+ Strict Mode).
nodeRef={draggableRef}
{...draggableConfig}
>
{resizable ? (

View File

@@ -16,15 +16,11 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Popover as AntdPopover } from 'antd';
import { PopoverProps as AntdPopoverProps } from 'antd/es/popover';
import type { TooltipRef } from 'antd/es/tooltip';
export interface PopoverProps extends AntdPopoverProps {
forceRender?: boolean;
}
export const Popover = forwardRef<TooltipRef, PopoverProps>((props, ref) => (
<AntdPopover ref={ref} {...props} />
));
export const Popover = (props: PopoverProps) => <AntdPopover {...props} />;

View File

@@ -16,9 +16,10 @@
* specific language governing permissions and limitations
* under the License.
*/
import { MouseEventHandler } from 'react';
import { MouseEventHandler, forwardRef } from 'react';
import { SupersetTheme } from '@apache-superset/core/theme';
import { Icons } from '@superset-ui/core/components/Icons';
import type { IconType } from '@superset-ui/core/components/Icons/types';
import { Tooltip } from '../Tooltip';
export interface RefreshLabelProps {
@@ -31,19 +32,25 @@ const RefreshLabel = ({
onClick,
tooltipContent,
disabled,
}: RefreshLabelProps) => (
<Tooltip title={tooltipContent}>
<Icons.SyncOutlined
iconSize="l"
role="button"
onClick={disabled ? undefined : onClick}
css={(theme: SupersetTheme) => ({
cursor: 'pointer',
color: theme.colorIcon,
'&:hover': { color: theme.colorPrimary },
})}
/>
</Tooltip>
);
}: RefreshLabelProps) => {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const IconWithoutRef = forwardRef((props: IconType, ref: any) => (
<Icons.SyncOutlined iconSize="l" {...props} />
));
return (
<Tooltip title={tooltipContent}>
<IconWithoutRef
role="button"
onClick={disabled ? undefined : onClick}
css={(theme: SupersetTheme) => ({
cursor: 'pointer',
color: theme.colorIcon,
'&:hover': { color: theme.colorPrimary },
})}
/>
</Tooltip>
);
};
export default RefreshLabel;

View File

@@ -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();

View File

@@ -16,22 +16,17 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef } from 'react';
import { Tooltip as AntdTooltip } from 'antd';
import type { TooltipRef } from 'antd/es/tooltip';
import type { TooltipProps, TooltipPlacement } from './types';
export const Tooltip = forwardRef<TooltipRef, TooltipProps>(
({ overlayStyle, ...props }, ref) => (
<AntdTooltip
ref={ref}
styles={{
body: { overflow: 'hidden', textOverflow: 'ellipsis' },
root: overlayStyle ?? {},
}}
{...props}
/>
),
export const Tooltip = ({ overlayStyle, ...props }: TooltipProps) => (
<AntdTooltip
styles={{
body: { overflow: 'hidden', textOverflow: 'ellipsis' },
root: overlayStyle ?? {},
}}
{...props}
/>
);
export type { TooltipProps, TooltipPlacement };

View File

@@ -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);
}
});

View File

@@ -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'),
},

View File

@@ -33,7 +33,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -34,6 +34,6 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -31,7 +31,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -31,7 +31,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -36,6 +36,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -33,8 +33,8 @@
"@apache-superset/core": "*",
"@testing-library/jest-dom": "*",
"@testing-library/react": "^14.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -32,7 +32,7 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -39,6 +39,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"@apache-superset/core": "*",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -43,6 +43,6 @@
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"react": "^18.3.0"
"react": "^18.2.0"
}
}

View File

@@ -44,8 +44,8 @@
"@testing-library/react": "^14.0.0",
"@testing-library/user-event": "*",
"@types/react": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -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(

View File

@@ -47,7 +47,7 @@
"geostyler-wfs-parser": "^3.0.1",
"ol": "^10.8.0",
"polished": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
}
}

View File

@@ -38,7 +38,7 @@
"dayjs": "^1.11.19",
"echarts": "*",
"memoize-one": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -39,9 +39,9 @@
"handlebars": "^4.7.8",
"lodash": "^4.18.1",
"dayjs": "^1.11.19",
"react": "^18.3.0",
"react": "^18.2.0",
"react-ace": "^10.1.0",
"react-dom": "^18.3.0"
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/jest": "^30.0.0",

View File

@@ -33,8 +33,8 @@
"@superset-ui/core": "*",
"lodash": "^4.18.1",
"prop-types": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@babel/types": "^7.29.0",

View File

@@ -36,8 +36,8 @@
"@apache-superset/core": "*",
"@superset-ui/chart-controls": "*",
"@superset-ui/core": "*",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -45,8 +45,8 @@
"@testing-library/user-event": "*",
"@types/react": "*",
"match-sorter": "^8.2.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"publishConfig": {
"access": "public"

View File

@@ -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 || [],

View File

@@ -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,
}),
]),
);
});

View File

@@ -39,7 +39,7 @@
"@superset-ui/core": "*",
"@types/lodash": "*",
"@types/react": "*",
"react": "^18.3.0"
"react": "^18.2.0"
},
"devDependencies": {
"@types/d3-cloud": "^1.2.9"

View File

@@ -67,8 +67,8 @@
"@superset-ui/core": "*",
"dayjs": "^1.11.19",
"mapbox-gl": ">=1.0.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"peerDependenciesMeta": {
"mapbox-gl": {

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { AriaAttributes, Ref } from 'react';
import { AriaAttributes } from 'react';
import 'core-js/stable';
import 'regenerator-runtime/runtime';
import jQuery from 'jquery';
@@ -98,39 +98,31 @@ jest.mock('rehype-raw', () => () => jest.fn());
// Tests should override this when needed
jest.mock('@superset-ui/core/components/Icons/AsyncIcon', () => ({
__esModule: true,
// eslint-disable-next-line global-require
default: require('react').forwardRef(
(
{
fileName,
role,
'aria-label': ariaLabel,
onClick,
...rest
}: {
fileName: string;
role?: string;
'aria-label'?: AriaAttributes['aria-label'];
onClick?: () => void;
},
ref: Ref<HTMLSpanElement>,
) => {
// Simple mock that provides the essential attributes for testing
const label =
ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
ref={ref}
role={role || (onClick ? 'button' : 'img')}
aria-label={label}
data-test={label}
onClick={onClick}
{...rest}
/>
);
},
),
default: ({
fileName,
role,
'aria-label': ariaLabel,
onClick,
...rest
}: {
fileName: string;
role?: string;
'aria-label'?: AriaAttributes['aria-label'];
onClick?: () => void;
}) => {
// Simple mock that provides the essential attributes for testing
const label = ariaLabel || fileName?.replace(/_/g, '-').toLowerCase() || '';
return (
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
<span
role={role || (onClick ? 'button' : 'img')}
aria-label={label}
data-test={label}
onClick={onClick}
{...rest}
/>
);
},
StyledIcon: ({
component: Component,
role,

View File

@@ -37,7 +37,6 @@ import { BrowserRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import { DndProvider } from 'react-dnd';
import { HTML5Backend } from 'react-dnd-html5-backend';
import { TestBackend } from 'react-dnd-test-backend';
import reducerIndex from 'spec/helpers/reducerIndex';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -47,11 +46,7 @@ import userEvent from '@testing-library/user-event';
type Options = Omit<RenderOptions, 'queries'> & {
useRedux?: boolean;
// `true` -> HTML5Backend (default; matches browser).
// `'test'` -> TestBackend, drive drags programmatically via
// `react-dnd-test-backend` `getInstance()` — avoids the jsdom
// HTML5-drag-event / preventDefault / zero-rect gaps.
useDnd?: boolean | 'test';
useDnd?: boolean;
useQueryParams?: boolean;
useRouter?: boolean;
useTheme?: boolean;
@@ -102,9 +97,8 @@ export function createWrapper(options?: Options) {
}
if (useDnd) {
const backend = useDnd === 'test' ? TestBackend : HTML5Backend;
// @ts-expect-error react-dnd types not updated for React 18
result = <DndProvider backend={backend}>{result}</DndProvider>;
result = <DndProvider backend={HTML5Backend}>{result}</DndProvider>;
}
if (useRedux || store) {

View File

@@ -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;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { act } from 'react';
import { act } from 'react-dom/test-utils';
import { QueryState } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
@@ -53,7 +53,7 @@ describe('QueryAutoRefresh', () => {
const refreshApi = 'glob:*/api/v1/query/updated_since?*';
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
jest.useFakeTimers();
});
afterEach(() => {

View File

@@ -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');

View File

@@ -189,19 +189,15 @@ const SqlEditorLeftBar = ({ queryEditorId }: SqlEditorLeftBarProps) => {
placement="bottomLeft"
trigger="click"
>
{/* Wrap in a span so the Popover can attach a ref without relying
on findDOMNode (deprecated in React 18+). */}
<span>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
onOpenModal={openSelectorModal}
/>
</span>
<DatabaseSelector
key={`db-selector-${db ? db.id : 'no-db'}:${catalog ?? 'no-catalog'}:${
schema ?? 'no-schema'
}`}
{...dbSelectorProps}
emptyState={<EmptyState />}
sqlLabMode
onOpenModal={openSelectorModal}
/>
</Popover>
<StyledDivider />
<TableExploreTree queryEditorId={activeQEId} />

View File

@@ -98,10 +98,7 @@ class CopyToClip extends Component<CopyToClipboardProps> {
trigger={['hover']}
arrow={{ pointAtCenter: true }}
>
{/* Wrap in a span so antd Tooltip has a real DOM ref target;
avoids findDOMNode fallback when copyNode is a function
component without forwardRef. */}
<span>{this.getDecoratedCopyNode()}</span>
{this.getDecoratedCopyNode()}
</Tooltip>
) : (
this.getDecoratedCopyNode()

View File

@@ -17,20 +17,21 @@
* under the License.
*/
import { forwardRef, PropsWithoutRef, Ref, RefAttributes } from 'react';
import { PropsWithoutRef, RefAttributes } from 'react';
import { Link, LinkProps } from 'react-router-dom';
import { isUrlExternal, parseUrl } from 'src/utils/urlUtils';
type GenericLinkProps<S> = PropsWithoutRef<LinkProps<S>> &
RefAttributes<HTMLAnchorElement>;
const GenericLinkInner = <S,>(
{ to, component, replace, innerRef, children, ...rest }: GenericLinkProps<S>,
ref: Ref<HTMLAnchorElement>,
) => {
export const GenericLink = <S,>({
to,
component,
replace,
innerRef,
children,
...rest
}: PropsWithoutRef<LinkProps<S>> & RefAttributes<HTMLAnchorElement>) => {
if (typeof to === 'string' && isUrlExternal(to)) {
return (
<a ref={ref} data-test="external-link" href={parseUrl(to)} {...rest}>
<a data-test="external-link" href={parseUrl(to)} {...rest}>
{children}
</a>
);
@@ -41,14 +42,10 @@ const GenericLinkInner = <S,>(
to={to}
component={component}
replace={replace}
innerRef={innerRef ?? ref}
innerRef={innerRef}
{...rest}
>
{children}
</Link>
);
};
export const GenericLink = forwardRef(GenericLinkInner) as <S>(
props: GenericLinkProps<S> & { ref?: Ref<HTMLAnchorElement> },
) => ReturnType<typeof GenericLinkInner>;

View File

@@ -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');

View File

@@ -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}`};

View File

@@ -67,7 +67,7 @@ interface DashboardActions {
setDatasources: (datasources: unknown) => void;
}
export interface DashboardProps {
interface DashboardProps {
actions: DashboardActions;
dashboardId: number;
editMode?: boolean;

View File

@@ -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;

View File

@@ -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;

View File

@@ -80,7 +80,7 @@ interface FilterScopeMap {
[key: string]: FilterScopeMapEntry;
}
export interface FilterScopeSelectorProps {
interface FilterScopeSelectorProps {
dashboardFilters: Record<number, DashboardFilter>;
layout: DashboardLayout;
updateDashboardFiltersScope: (

View File

@@ -43,7 +43,7 @@ import {
export const CHART_MARGIN = 32;
export interface ChartHolderProps {
interface ChartHolderProps {
id: string;
parentId: string;
dashboardId: number;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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'),

View File

@@ -31,7 +31,7 @@ describe('FilterScope TreeInitialization', () => {
let formRef: { current: FormInstance | null };
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
jest.useFakeTimers();
formRef = { current: null };
});

View File

@@ -31,7 +31,7 @@ describe('FilterScope TreeSelection', () => {
let formRef: { current: FormInstance | null };
beforeEach(() => {
jest.useFakeTimers({ advanceTimers: true });
jest.useFakeTimers();
formRef = { current: null };
});

View File

@@ -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(() => {

View File

@@ -193,57 +193,52 @@ export default function getControlItemsMap({
t('Populate "Default value" to enable this control')
}
>
{/* Wrap in span so antd Tooltip can attach a ref without
relying on findDOMNode (deprecated in React 18+). */}
<span>
<StyledRowFormItem
expanded={expanded}
key={controlItem.name}
name={['filters', filterId, 'controlValues', controlItem.name]}
initialValue={initialValue}
valuePropName="checked"
colon={false}
<StyledRowFormItem
expanded={expanded}
key={controlItem.name}
name={['filters', filterId, 'controlValues', controlItem.name]}
initialValue={initialValue}
valuePropName="checked"
colon={false}
>
<Checkbox
disabled={controlItem.config.affectsDataMask && disabled}
onChange={checked => {
if (controlItem.config.requiredFirst) {
setNativeFilterFieldValues(form, filterId, {
requiredFirst: {
...formFilter?.requiredFirst,
[controlItem.name]: checked,
},
});
}
if (controlItem.config.resetConfig) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
}
formChanged();
forceUpdate();
}}
>
<Checkbox
disabled={controlItem.config.affectsDataMask && disabled}
onChange={checked => {
if (controlItem.config.requiredFirst) {
setNativeFilterFieldValues(form, filterId, {
requiredFirst: {
...formFilter?.requiredFirst,
[controlItem.name]: checked,
},
});
}
if (controlItem.config.resetConfig) {
setNativeFilterFieldValues(form, filterId, {
defaultDataMask: null,
});
}
formChanged();
forceUpdate();
}}
>
<>
{typeof controlItem.config.label === 'function'
? (controlItem.config.label as Function)()
: controlItem.config.label}
&nbsp;
{controlItem.config.description && (
<InfoTooltip
placement="top"
tooltip={
typeof controlItem.config.description === 'function'
? (controlItem.config.description as Function)()
: (controlItem.config
.description as React.ReactNode)
}
/>
)}
</>
</Checkbox>
</StyledRowFormItem>
</span>
<>
{typeof controlItem.config.label === 'function'
? (controlItem.config.label as Function)()
: controlItem.config.label}
&nbsp;
{controlItem.config.description && (
<InfoTooltip
placement="top"
tooltip={
typeof controlItem.config.description === 'function'
? (controlItem.config.description as Function)()
: (controlItem.config.description as React.ReactNode)
}
/>
)}
</>
</Checkbox>
</StyledRowFormItem>
</Tooltip>
</>
);

View File

@@ -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[];
}

View File

@@ -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;
}

View File

@@ -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;

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