Compare commits

...

47 Commits

Author SHA1 Message Date
Joe Li
354a143171 fix(playwright): address review feedback on de-flake change
Three follow-ups from /review-code on the list-spec de-flake work:

- Revert toast assertions to expect(toast.getSuccess()).toBeVisible().
  The previous switch to toast.getSuccess().waitFor({ state: 'visible' })
  was a no-op: both APIs poll for visibility, so neither catches a fast
  auto-dismiss between polls. The expect() form fails as an assertion
  (clearer diff, counted in reports). Misleading "use waitFor so we
  detect auto-dismiss" comments are gone too.

- Drop redundant waitFor({ state: 'visible' }) calls in BulkSelect's
  selectRow / deselectRow / clickAction. Locator.check()/click() already
  auto-wait for visibility, stability, and enabled state. The
  expect(checkbox).toBeChecked() assertion -- which is the actual race
  guard against React state propagation -- stays.

- Expand the DeleteConfirmationModal.clickDelete docstring to explain
  why it bypasses Modal.clickFooterButton: the footer button label is
  i18n'd, so name-based lookups break in non-English locales.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:10:20 -07:00
Joe Li
556a93e8f3 fix(playwright): extend de-flake hardening across list specs
Apply the same flake-pattern fixes from 4c4f56cd8a to the rest of the
list-spec test surface so the hardening is consistent end-to-end.

Spec-level (dashboard/chart/dataset):
- Row-visibility waits on freshly-created rows now use TIMEOUT.API_RESPONSE
  (15s) so the asynchronous list query has time on slow CI.
- Toast assertions switched from `expect(toast).toBeVisible()` to
  `toast.waitFor({ state: 'visible' })` so a fast auto-dismiss is detected.
- Post-delete row assertions switched from `not.toBeVisible()` to
  `toHaveCount(0)` since deleted rows are removed from the DOM.
- Bulk export specs (chart, dataset) now set `test.setTimeout(SLOW_TEST)`
  and `waitForGet({ timeout: SLOW_TEST })` to match the bulk-delete budget;
  the prior implicit budget was capped by Playwright's 30s test timeout.

Page object:
- BulkSelect.deselectRow mirrors selectRow's visibility wait + state
  assertion so any lingering selection surfaces at the call site.
2026-05-11 16:10:20 -07:00
Joe Li
839b5a8d0b fix(playwright): de-flake dashboard-list delete and bulk-export tests
Harden the delete-confirmation modal and bulk-select page objects against
known Playwright race patterns: explicit data-test selectors, an
enabled-state wait before clicking the disabled-by-default Delete button,
a header-toggle wait after enabling bulk select, and visibility gates on
checkboxes and action buttons that mount only after a row is selected.
Also extend list-row visibility timeouts for slow CI and switch the
post-delete assertion to toHaveCount(0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:10:20 -07:00
Evan Rusackas
cfb704dbeb test(sqllab): stabilize SaveDatasetModal overwrite-flow test helper (#40036)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 15:48:10 -07:00
Evan Rusackas
e77f6ece92 fix(ci): serialize Docs Deployment runs to avoid push races (#40030)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 11:25:31 -07:00
Evan Rusackas
785a08c7d5 chore(frontend): export typed useAppDispatch / useAppSelector hooks (#40027)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 11:01:57 -07:00
Maxime Beauchemin
d90d3a2dea fix(importexport): honor overwrite flag on /api/v1/assets/import (#39502)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:24:42 -07:00
Maxime Beauchemin
6ee4d694bc fix(sqllab): include template_params when overwriting a dataset (#39501)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-11 10:24:15 -07:00
Evan Rusackas
006a1800be chore(lint): convert react-pivottable components to function components (#39453)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 10:19:05 -07:00
Evan Rusackas
2fe6269c22 chore(lint): convert ChartDataProvider and StatefulChart to function components (#39456)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:55:27 -07:00
Evan Rusackas
26ef4b7ed3 fix(sqla): pass catalog and schema to get_sqla_engine in values_for_column (#38681)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Claude <claude@anthropic.com>
2026-05-11 09:54:48 -07:00
Evan Rusackas
a7aa854968 fix(big-number): guard against null colorPicker in transformProps (#39110)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:54:08 -07:00
Evan Rusackas
db0c5b32da chore(lint): convert SuperChart and SuperChartCore to function components (#39457)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:51:52 -07:00
Evan Rusackas
96ad20318d chore(superset-core): forward-compat fixes for TypeScript 6.0 - Phase C (#39537)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 09:50:07 -07:00
Evan Rusackas
516bb19e10 feat(frontend): enable React StrictMode at root (#39893)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 09:48:54 -07:00
Evan Rusackas
2cc20d3284 perf(explore): use useDeferredValue for explore menu search and JS editor parse (#39975)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 09:48:07 -07:00
Evan Rusackas
3e3c5c36c3 perf(explore): use useDeferredValue for datasource panel search (#39970)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 09:47:50 -07:00
Evan Rusackas
eed7098093 perf(sql-lab): use useDeferredValue for schema browser search (#39928)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-05-11 09:47:24 -07:00
dependabot[bot]
1d1a0e6fec chore(deps-dev): update sqlalchemy-firebird requirement from <0.8,>=0.7.0 to >=0.7.0,<2.2 (#39755)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 09:00:12 -07:00
dependabot[bot]
494c29f5bf chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.59.1 to 8.59.2 in /superset-frontend (#39878)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:59:49 -07:00
dependabot[bot]
ad7075d2aa chore(deps): bump fast-uri from 3.0.6 to 3.1.2 in /docs (#39979)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:59:26 -07:00
dependabot[bot]
3e1cfc6d69 chore(deps): bump @babel/plugin-transform-modules-systemjs from 7.27.1 to 7.29.4 in /docs (#39981)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:59:04 -07:00
dependabot[bot]
fcf3f6c0d5 chore(deps-dev): update pinotdb requirement from <6.0.0,>=5.0.0 to >=5.0.0,<10.0.0 (#39985)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:58:44 -07:00
dependabot[bot]
14ba666594 chore(deps-dev): update ibm-db-sa requirement from <=0.4.0,>0.3.8 to >0.3.8,<=0.4.4 (#39986)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:58:14 -07:00
dependabot[bot]
1c795418d2 chore(deps-dev): bump pyinstrument from 4.4.0 to 5.1.2 (#39987)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:57:47 -07:00
dependabot[bot]
6271272e60 chore(deps): bump nh3 from 0.2.21 to 0.3.5 (#39988)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:57:05 -07:00
dependabot[bot]
2cf4a2c31f chore(deps-dev): bump databricks-sql-connector from 4.1.2 to 4.2.6 (#39989)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:54:57 -07:00
dependabot[bot]
2adb6f64eb chore(deps): bump baseline-browser-mapping from 2.10.27 to 2.10.29 in /docs (#40013)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:54:43 -07:00
dependabot[bot]
5a453fe95d chore(deps-dev): bump wait-on from 9.0.5 to 9.0.6 in /superset-frontend (#40014)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-11 08:54:26 -07:00
Mehmet Salih Yavuz
245fffca79 fix(dashboard): Clear All filters now stages changes until Apply (#39778)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
2026-05-11 17:15:35 +03:00
Mehmet Salih Yavuz
372b50e19d fix(dashboard): row limit warning missing for non-table charts (#39911) 2026-05-11 17:14:55 +03:00
Oleg Ovcharuk
d83b0c5ce3 feat: support creating datasets for schema-less databases (#39433)
Co-authored-by: codeant-ai-for-open-source[bot] <244253245+codeant-ai-for-open-source[bot]@users.noreply.github.com>
2026-05-11 08:30:13 -04:00
Evan Rusackas
f81821086a chore(releasing): fix email parsing in verify_release.py (#39602)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 08:57:33 -07:00
dependabot[bot]
f67dd4a8f3 chore(deps): bump geostyler from 18.5.0 to 18.5.1 in /superset-frontend (#39702)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 16:17:31 -07:00
Maxime Beauchemin
68fa8e2733 fix(viz): flatten MultiIndex columns in Time-Series Table for multiple Group By (#37869)
Co-authored-by: Claude Opus 4 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-05-08 16:11:13 -07:00
Maxime Beauchemin
a60860c969 fix(table): fall back to datasource columns for conditional formatting when query results are empty (#39345)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joe Li <joe@preset.io>
2026-05-08 16:10:41 -07:00
Maxime Beauchemin
d023fe1703 fix(trino/presto): use equality for boolean filters to support computed columns (#39500) 2026-05-08 16:10:27 -07:00
Amin Ghadersohi
547660dcc4 fix(mcp): ASCII chart crashes with NaN when dataset contains null values (#39916) 2026-05-08 17:35:15 -04:00
Joe Li
e934f2af92 fix(tests): prevent jest hangs caused by MessageChannel-mocked React scheduler (#39957)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-05-08 14:27:03 -07:00
Amin Ghadersohi
cfb0b6e811 fix(mcp): clarify request wrapper in list_datasets, list_charts, list_dashboards (#39920) 2026-05-08 16:01:07 -04:00
Amin Ghadersohi
ff7dc53853 fix(mcp): get_chart_sql drops x_axis on echarts_timeseries_* and only renders one query for mixed_timeseries (#39865) 2026-05-08 15:29:28 -04:00
dependabot[bot]
dce3317bc9 chore(deps-dev): bump typescript-eslint from 8.59.1 to 8.59.2 in /docs (#39876)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:24:38 -07:00
dependabot[bot]
dc22b82d88 chore(deps-dev): bump @typescript-eslint/parser from 8.59.1 to 8.59.2 in /superset-websocket (#39874)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-08 11:24:22 -07:00
Evan Rusackas
0250092378 chore(frontend): TypeScript 6.0 readiness — declaration emit fixes (Phase A) (#39530)
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 10:42:07 -07:00
Beto Dealmeida
4311a15eb2 feat(sqlglot): Vertica dialect (#39969) 2026-05-08 14:34:34 -03:00
Evan Rusackas
b899556130 docs: Superset 6.1 documentation catch-up (security, alerts/reports, theming, config) (#39440)
Co-authored-by: Superset Dev <dev@superset.apache.org>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 10:11:09 -07:00
Evan Rusackas
2f82236b29 feat(docs): expand docs:screenshots generator with manifest and tutorial captures (#39444)
Co-authored-by: Claude <claude@anthropic.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: Superset Dev <dev@superset.apache.org>
2026-05-08 10:02:19 -07:00
139 changed files with 8451 additions and 3323 deletions

View File

@@ -17,6 +17,16 @@ on:
workflow_dispatch: {}
# Serialize deploys: the action pushes to apache/superset-site without
# rebasing, so concurrent runs race on the final push and the loser fails
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
# in-progress run as soon as a newer one starts — the destination repo
# isn't touched until the final push step, so canceling mid-build is safe,
# and the freshest content always wins.
concurrency:
group: docs-deploy-asf-site
cancel-in-progress: true
jobs:
config:
runs-on: ubuntu-24.04

View File

@@ -56,8 +56,33 @@ 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 key and email address."""
"""Run the GPG verify command and extract RSA/EDDSA key and email address."""
asc_filename = filename + ".asc"
result = subprocess.run( # noqa: S603
["gpg", "--verify", asc_filename, filename], # noqa: S607
@@ -65,25 +90,50 @@ 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)
email = re.search(r'issuer "([^"]+)"', 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
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("email found")
print(f"Email found: {email_result}")
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,6 +81,87 @@ 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,6 +472,38 @@ 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,6 +122,17 @@ 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:
@@ -143,7 +154,11 @@ Superset supports custom fonts through the theme configuration, allowing you to
### Default Fonts
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.
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.
:::
### Configuring Custom Fonts

View File

@@ -205,6 +205,57 @@ 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

@@ -69,7 +69,7 @@
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.15.33",
"antd": "^6.3.7",
"baseline-browser-mapping": "^2.10.27",
"baseline-browser-mapping": "^2.10.29",
"caniuse-lite": "^1.0.30001792",
"docusaurus-plugin-openapi-docs": "^5.0.2",
"docusaurus-theme-openapi-docs": "^5.0.2",
@@ -106,7 +106,7 @@
"globals": "^17.6.0",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"typescript-eslint": "^8.59.1",
"typescript-eslint": "^8.59.2",
"webpack": "^5.106.2"
},
"browserslist": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 116 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: 99 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 240 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 141 KiB

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -261,6 +261,15 @@
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/code-frame@^7.29.0":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c"
integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==
dependencies:
"@babel/helper-validator-identifier" "^7.28.5"
js-tokens "^4.0.0"
picocolors "^1.1.1"
"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0":
version "7.28.0"
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.0.tgz"
@@ -303,6 +312,17 @@
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/generator@^7.29.0":
version "7.29.1"
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50"
integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==
dependencies:
"@babel/parser" "^7.29.0"
"@babel/types" "^7.29.0"
"@jridgewell/gen-mapping" "^0.3.12"
"@jridgewell/trace-mapping" "^0.3.28"
jsesc "^3.0.2"
"@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3":
version "7.27.3"
resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz"
@@ -404,6 +424,11 @@
resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz"
integrity sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==
"@babel/helper-plugin-utils@^7.28.6":
version "7.28.6"
resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8"
integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==
"@babel/helper-remap-async-to-generator@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz"
@@ -435,11 +460,6 @@
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
"@babel/helper-validator-identifier@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz"
integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==
"@babel/helper-validator-identifier@^7.28.5":
version "7.28.5"
resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz"
@@ -474,6 +494,13 @@
dependencies:
"@babel/types" "^7.28.6"
"@babel/parser@^7.29.0":
version "7.29.3"
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.3.tgz#116f70a77958307fceac27747573032f8a62f88e"
integrity sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==
dependencies:
"@babel/types" "^7.29.0"
"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.27.1.tgz"
@@ -758,14 +785,14 @@
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/plugin-transform-modules-systemjs@^7.27.1":
version "7.27.1"
resolved "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.27.1.tgz"
integrity sha512-w5N1XzsRbc0PQStASMksmUeqECuzKuTJer7kFagK8AXgpCMkeDMO5S+aaFb7A51ZYDF7XI34qsTX+fkHiIm5yA==
version "7.29.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.29.4.tgz#f621105da99919c15cf4bde6fcc7346ef95e7b20"
integrity sha512-N7QmZ0xRZfjHOfZeQLJjwgX2zS9pdGHSVl/cjSGlo4dXMqvurfxXDMKY4RqEKzPozV78VMcd0lxyG13mlbKc4w==
dependencies:
"@babel/helper-module-transforms" "^7.27.1"
"@babel/helper-plugin-utils" "^7.27.1"
"@babel/helper-validator-identifier" "^7.27.1"
"@babel/traverse" "^7.27.1"
"@babel/helper-module-transforms" "^7.28.6"
"@babel/helper-plugin-utils" "^7.28.6"
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/traverse" "^7.29.0"
"@babel/plugin-transform-modules-umd@^7.27.1":
version "7.27.1"
@@ -1163,6 +1190,19 @@
"@babel/types" "^7.28.6"
debug "^4.3.1"
"@babel/traverse@^7.29.0":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a"
integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==
dependencies:
"@babel/code-frame" "^7.29.0"
"@babel/generator" "^7.29.0"
"@babel/helper-globals" "^7.28.0"
"@babel/parser" "^7.29.0"
"@babel/template" "^7.28.6"
"@babel/types" "^7.29.0"
debug "^4.3.1"
"@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.6", "@babel/types@^7.4.4":
version "7.28.6"
resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.6.tgz"
@@ -1171,6 +1211,14 @@
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@babel/types@^7.29.0":
version "7.29.0"
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7"
integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==
dependencies:
"@babel/helper-string-parser" "^7.27.1"
"@babel/helper-validator-identifier" "^7.28.5"
"@braintree/sanitize-url@^7.0.4":
version "7.1.1"
resolved "https://registry.npmjs.org/@braintree/sanitize-url/-/sanitize-url-7.1.1.tgz"
@@ -5040,100 +5088,100 @@
dependencies:
"@types/yargs-parser" "*"
"@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==
"@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==
dependencies:
"@eslint-community/regexpp" "^4.12.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"
"@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"
ignore "^7.0.5"
natural-compare "^1.4.0"
ts-api-utils "^2.5.0"
"@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==
"@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==
dependencies:
"@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"
"@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"
debug "^4.4.3"
"@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==
"@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==
dependencies:
"@typescript-eslint/tsconfig-utils" "^8.59.1"
"@typescript-eslint/types" "^8.59.1"
"@typescript-eslint/tsconfig-utils" "^8.59.2"
"@typescript-eslint/types" "^8.59.2"
debug "^4.4.3"
"@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==
"@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==
dependencies:
"@typescript-eslint/types" "8.59.1"
"@typescript-eslint/visitor-keys" "8.59.1"
"@typescript-eslint/types" "8.59.2"
"@typescript-eslint/visitor-keys" "8.59.2"
"@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/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/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==
"@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==
dependencies:
"@typescript-eslint/types" "8.59.1"
"@typescript-eslint/typescript-estree" "8.59.1"
"@typescript-eslint/utils" "8.59.1"
"@typescript-eslint/types" "8.59.2"
"@typescript-eslint/typescript-estree" "8.59.2"
"@typescript-eslint/utils" "8.59.2"
debug "^4.4.3"
ts-api-utils "^2.5.0"
"@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/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/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==
"@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==
dependencies:
"@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"
"@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"
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.1":
version "8.59.1"
resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.59.1.tgz#cf6204d69701bbbc5b150f98c18aeef0a42c10bd"
integrity sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==
"@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==
dependencies:
"@eslint-community/eslint-utils" "^4.9.1"
"@typescript-eslint/scope-manager" "8.59.1"
"@typescript-eslint/types" "8.59.1"
"@typescript-eslint/typescript-estree" "8.59.1"
"@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.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==
"@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==
dependencies:
"@typescript-eslint/types" "8.59.1"
"@typescript-eslint/types" "8.59.2"
eslint-visitor-keys "^5.0.0"
"@ungap/structured-clone@^1.0.0":
@@ -5794,10 +5842,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.27, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.27"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.27.tgz#fee941c2a0b42cdf83c6427e4c830b1d0bdab2c3"
integrity sha512-zEs/ufmZoUd7WftKpKyXaT6RFxpQ5Qm9xytKRHvJfxFV9DFJkZph9RvJ1LcOUi0Z1ZVijMte65JbILeV+8QQEA==
baseline-browser-mapping@^2.10.29, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
version "2.10.29"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz#47bdc13027af28d341f367a4f35a07ce872e27b4"
integrity sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==
batch@0.6.1:
version "0.6.1"
@@ -8118,9 +8166,9 @@ fast-safe-stringify@^2.0.7:
integrity sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==
fast-uri@^3.0.1:
version "3.0.6"
resolved "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.6.tgz"
integrity sha512-Atfo14OibSv5wAp4VWNsFYE1AchQRTv9cBGWET4pZWHzYshFSS9NQI6I57rdKn9croWVMbYFbLhJ+yJvmZIIHw==
version "3.1.2"
resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.2.tgz#8af3d4fc9d3e71b11572cc2673b514a7d1a8c8ec"
integrity sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==
fastq@^1.6.0:
version "1.19.1"
@@ -14715,15 +14763,15 @@ types-ramda@^0.30.1:
dependencies:
ts-toolbelt "^9.6.0"
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==
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==
dependencies:
"@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-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@~6.0.3:
version "6.0.3"

View File

@@ -71,7 +71,7 @@ dependencies = [
"marshmallow>=3.0, <4",
"marshmallow-union>=0.1",
"msgpack>=1.0.0, <1.2",
"nh3>=0.2.11, <0.3",
"nh3>=0.2.11, <0.4",
"numpy>1.23.5, <2.3",
"packaging",
# --------------------------
@@ -131,10 +131,10 @@ d1 = [
]
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
databricks = [
"databricks-sql-connector==4.1.2",
"databricks-sql-connector==4.2.6",
"databricks-sqlalchemy==1.0.5",
]
db2 = ["ibm-db-sa>0.3.8, <=0.4.0"]
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=1.0.6,<2.1.0"]
dremio = ["sqlalchemy-dremio>=1.2.1, <4"]
drill = ["sqlalchemy-drill>=1.1.4, <2"]
@@ -152,7 +152,7 @@ fastmcp = [
# heuristic that under-counts JSON-heavy MCP responses.
"tiktoken>=0.7.0,<1.0",
]
firebird = ["sqlalchemy-firebird>=0.7.0, <0.8"]
firebird = ["sqlalchemy-firebird>=0.7.0, <2.2"]
firebolt = ["firebolt-sqlalchemy>=1.0.0, <2"]
gevent = ["gevent>=23.9.1"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
@@ -179,7 +179,7 @@ ocient = [
]
oracle = ["cx-Oracle>8.0.0, <8.4"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <6.0.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.37.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
@@ -224,7 +224,7 @@ development = [
"progress>=1.5,<2",
"psutil",
"pyfakefs",
"pyinstrument>=4.0.2,<5",
"pyinstrument>=4.0.2,<6",
"pylint",
"pytest<8.0.0", # hairy issue with pytest >=8 where current_app proxies are not set in time
"pytest-asyncio",
@@ -383,6 +383,7 @@ 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

@@ -96,7 +96,7 @@
"fs-extra": "^11.3.4",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.5.0",
"geostyler": "^18.5.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
@@ -222,7 +222,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.58.2",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.1",
@@ -290,7 +290,7 @@
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.5",
"wait-on": "^9.0.6",
"webpack": "^5.106.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",
@@ -14358,17 +14358,17 @@
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.1.tgz",
"integrity": "sha512-BOziFIfE+6osHO9FoJG4zjoHUcvI7fTNBSpdAwrNH0/TLvzjsk2oo8XSSOT2HhqUyhZPfHv4UOffoJ9oEEQ7Ag==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
"integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/regexpp": "^4.12.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",
"@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",
"ignore": "^7.0.5",
"natural-compare": "^1.4.0",
"ts-api-utils": "^2.5.0"
@@ -14381,20 +14381,42 @@
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"@typescript-eslint/parser": "^8.59.1",
"@typescript-eslint/parser": "^8.59.2",
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/project-service": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
"@typescript-eslint/tsconfig-utils": "^8.59.2",
"@typescript-eslint/types": "^8.59.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14404,10 +14426,27 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/types": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14419,16 +14458,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14447,16 +14486,16 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14471,13 +14510,13 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -14499,9 +14538,9 @@
}
},
"node_modules/@typescript-eslint/eslint-plugin/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14551,16 +14590,16 @@
}
},
"node_modules/@typescript-eslint/parser": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.1.tgz",
"integrity": "sha512-HDQH9O/47Dxi1ceDhBXdaldtf/WV9yRYMjbjCuNk3qnaTD564qwv61Y7+gTxwxRKzSrgO5uhtw584igXVuuZkA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz",
"integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3"
},
"engines": {
@@ -14575,15 +14614,37 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/project-service": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
"@typescript-eslint/tsconfig-utils": "^8.59.2",
"@typescript-eslint/types": "^8.59.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14593,10 +14654,27 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/types": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14608,16 +14686,16 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14636,13 +14714,13 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -14664,9 +14742,9 @@
}
},
"node_modules/@typescript-eslint/parser/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14777,15 +14855,15 @@
}
},
"node_modules/@typescript-eslint/type-utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.1.tgz",
"integrity": "sha512-klWPBR2ciQHS3f++ug/mVnWKPjBUo7icEL3FAO1lhAR1Z1i5NQYZ1EannMSRYcq5qCv5wNALlXr6fksRHyYl7w==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz",
"integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1",
"@typescript-eslint/utils": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2",
"@typescript-eslint/utils": "8.59.2",
"debug": "^4.4.3",
"ts-api-utils": "^2.5.0"
},
@@ -14801,15 +14879,37 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.1.tgz",
"integrity": "sha512-LwuHQI4pDOYVKvmH2dkaJo6YZCSgouVgnS/z7yBPKBMvgtBvyLqiLy9Z6b7+m/TRcX1NFYUqZetI5Y+aT4GEfg==",
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/project-service": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz",
"integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/visitor-keys": "8.59.1"
"@typescript-eslint/tsconfig-utils": "^8.59.2",
"@typescript-eslint/types": "^8.59.2",
"debug": "^4.4.3"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/scope-manager": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz",
"integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/visitor-keys": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14819,10 +14919,27 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/tsconfig-utils": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz",
"integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/typescript-eslint"
},
"peerDependencies": {
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/types": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.1.tgz",
"integrity": "sha512-ZDCjgccSdYPw5Bxh+my4Z0lJU96ZDN7jbBzvmEn0FZx3RtU1C7VWl6NbDx94bwY3V5YsgwRzJPOgeY2Q/nLG8A==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz",
"integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==",
"dev": true,
"license": "MIT",
"engines": {
@@ -14834,16 +14951,16 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/typescript-estree": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.1.tgz",
"integrity": "sha512-OUd+vJS05sSkOip+BkZ/2NS8RMxrAAJemsC6vU3kmfLyeaJT0TftHkV9mcx2107MmsBVXXexhVu4F0TZXyMl4g==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz",
"integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@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",
"@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",
"debug": "^4.4.3",
"minimatch": "^10.2.2",
"semver": "^7.7.3",
@@ -14862,16 +14979,16 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.1.tgz",
"integrity": "sha512-3pIeoXhCeYH9FSCBI8P3iNwJlGuzPlYKkTlen2O9T1DSeeg8UG8jstq6BLk+Mda0qup7mgk4z4XL4OzRaxZ8LA==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz",
"integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.9.1",
"@typescript-eslint/scope-manager": "8.59.1",
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/typescript-estree": "8.59.1"
"@typescript-eslint/scope-manager": "8.59.2",
"@typescript-eslint/types": "8.59.2",
"@typescript-eslint/typescript-estree": "8.59.2"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
@@ -14886,13 +15003,13 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/visitor-keys": {
"version": "8.59.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.1.tgz",
"integrity": "sha512-LdDNl6C5iJExcM0Yh0PwAIBb9PrSiCsWamF/JyEZawm3kFDnRoaq3LGE4bpyRao/fWeGKKyw7icx0YxrLFC5Cg==",
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz",
"integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@typescript-eslint/types": "8.59.1",
"@typescript-eslint/types": "8.59.2",
"eslint-visitor-keys": "^5.0.0"
},
"engines": {
@@ -14914,9 +15031,9 @@
}
},
"node_modules/@typescript-eslint/type-utils/node_modules/brace-expansion": {
"version": "5.0.5",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz",
"integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==",
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -16882,13 +16999,13 @@
}
},
"node_modules/axios": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.15.0.tgz",
"integrity": "sha512-wWyJDlAatxk30ZJer+GeCWS209sA42X+N5jU2jy6oHTp7ufw8uzUTVFBX9+wTfAlhiJXGS0Bq7X6efruWjuK9Q==",
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz",
"integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==",
"dev": true,
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.11",
"follow-redirects": "^1.16.0",
"form-data": "^4.0.5",
"proxy-from-env": "^2.1.0"
}
@@ -24760,9 +24877,9 @@
"license": "MIT"
},
"node_modules/geostyler": {
"version": "18.5.0",
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.0.tgz",
"integrity": "sha512-azjLMEhrTQot+pU3phfSrUZI7CdetyAl7JNAnxrGaPA/E/5mmyoPQugZso3CfIuIBwOtFLmfB36SLE/FeGFakA==",
"version": "18.5.1",
"resolved": "https://registry.npmjs.org/geostyler/-/geostyler-18.5.1.tgz",
"integrity": "sha512-5+vLuDo1oR4QQTnrfkccIQSe3qEn0ytV9dLiFFhnxhPdziv/Wp3vKNhJZ37MUF5yIj2ISWZ+q/VmSNH6ifvWpg==",
"license": "BSD-2-Clause",
"dependencies": {
"@ant-design/icons": "^5.5.1",
@@ -31577,9 +31694,9 @@
}
},
"node_modules/joi": {
"version": "18.1.2",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.1.2.tgz",
"integrity": "sha512-rF5MAmps5esSlhCA+N1b6IYHDw9j/btzGaqfgie522jS02Ju/HXBxamlXVlKEHAxoMKQL77HWI8jlqWsFuekZA==",
"version": "18.2.1",
"resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz",
"integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
@@ -47858,14 +47975,14 @@
}
},
"node_modules/wait-on": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.5.tgz",
"integrity": "sha512-qgnbHDfDTRIp73ANEJNRW/7kn8CrDUcvZz18xotJQku/P4saTGkbIzvnMZebPmVvVNUiRq1qWAPyqCH+W4H8KA==",
"version": "9.0.6",
"resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.6.tgz",
"integrity": "sha512-KR+Te+NBg6DmPVil4anyIO72mpt/QDHjRo3nVFkwRgb26oweUp3DDW2szO3EeUY4cqafWy4rQuOOeEk4n+7Oeg==",
"dev": true,
"license": "MIT",
"dependencies": {
"axios": "^1.15.0",
"joi": "^18.1.2",
"axios": "^1.16.0",
"joi": "^18.2.1",
"lodash": "^4.18.1",
"minimist": "^1.2.8",
"rxjs": "^7.8.2"
@@ -50893,7 +51010,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",

View File

@@ -177,7 +177,7 @@
"fs-extra": "^11.3.4",
"fuse.js": "^7.3.0",
"geolib": "^3.3.14",
"geostyler": "^18.5.0",
"geostyler": "^18.5.1",
"geostyler-data": "^1.1.0",
"geostyler-openlayers-parser": "^5.7.0",
"geostyler-style": "11.0.2",
@@ -303,7 +303,7 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.59.1",
"@typescript-eslint/eslint-plugin": "^8.59.2",
"@typescript-eslint/parser": "^8.58.2",
"babel-jest": "^30.0.2",
"babel-loader": "^10.1.1",
@@ -371,7 +371,7 @@
"typescript": "5.4.5",
"unzipper": "^0.12.3",
"vm-browserify": "^1.1.2",
"wait-on": "^9.0.5",
"wait-on": "^9.0.6",
"webpack": "^5.106.2",
"webpack-bundle-analyzer": "^5.3.0",
"webpack-cli": "^6.0.1",

View File

@@ -38,9 +38,17 @@ import {
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
export class Theme {
theme: SupersetTheme;
// Forward-compat: TS 6.0 enforces strictPropertyInitialization here;
// both fields are assigned via setConfig() during construction, so we
// use a definite-assignment assertion rather than hoisting the logic
// out of setConfig().
//
// Assigned via setConfig() in the constructor; TypeScript 6.0's
// strictPropertyInitialization can't trace that call chain, so we use
// a definite-assignment assertion.
theme!: SupersetTheme;
private antdConfig: AntdThemeConfig;
private antdConfig!: AntdThemeConfig;
private constructor({ config }: { config?: AnyThemeConfig }) {
this.SupersetThemeProvider = this.SupersetThemeProvider.bind(this);

View File

@@ -20,3 +20,10 @@
* 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

@@ -17,8 +17,7 @@
* under the License.
*/
/* eslint react/sort-comp: 'off' */
import { PureComponent, ReactNode } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
SupersetClientInterface,
RequestConfig,
@@ -67,103 +66,112 @@ export type ChartDataProviderState = {
error?: ProvidedProps['error'];
};
class ChartDataProvider extends PureComponent<
ChartDataProviderProps,
ChartDataProviderState
> {
readonly chartClient: ChartClient;
function ChartDataProvider({
children,
client,
formData,
sliceId,
loadDatasource,
onError,
onLoaded,
formDataRequestOptions,
datasourceRequestOptions,
queryRequestOptions,
}: ChartDataProviderProps): JSX.Element | null {
const [state, setState] = useState<ChartDataProviderState>({
status: 'uninitialized',
});
constructor(props: ChartDataProviderProps) {
super(props);
this.state = { status: 'uninitialized' };
this.chartClient = new ChartClient({ client: props.client });
}
const chartClient = useMemo(() => new ChartClient({ client }), [client]);
componentDidMount() {
this.handleFetchData();
}
const extractSliceIdAndFormData = useCallback(
(): SliceIdAndOrFormData =>
formData ? { formData } : { sliceId: sliceId as number },
[formData, sliceId],
);
componentDidUpdate(prevProps: ChartDataProviderProps) {
const { formData, sliceId } = this.props;
if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
this.handleFetchData();
const handleReceiveData = useCallback(
(payload?: Payload) => {
if (onLoaded) onLoaded(payload);
setState({ payload, status: 'loaded' });
},
[onLoaded],
);
const handleError = useCallback(
(error: ProvidedProps['error']) => {
if (onError) onError(error);
setState({ error, status: 'error' });
},
[onError],
);
const handleFetchData = useCallback(() => {
setState({ status: 'loading' });
try {
chartClient
.loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
.then(loadedFormData =>
Promise.all([
loadDatasource
? chartClient.loadDatasource(
loadedFormData.datasource,
datasourceRequestOptions,
)
: Promise.resolve(undefined),
chartClient.loadQueryData(loadedFormData, queryRequestOptions),
]).then(
([datasource, queriesData]) =>
({
datasource,
formData: loadedFormData,
queriesData,
}) as Payload,
),
)
.then(handleReceiveData)
.catch(handleError);
} catch (error) {
handleError(error as Error);
}
}
}, [
chartClient,
extractSliceIdAndFormData,
formDataRequestOptions,
loadDatasource,
datasourceRequestOptions,
queryRequestOptions,
handleReceiveData,
handleError,
]);
private extractSliceIdAndFormData() {
const { formData, sliceId } = this.props;
return formData ? { formData } : { sliceId: sliceId as number };
}
// Fetch on mount and only refetch when formData or sliceId changes.
// This preserves the original class component's componentDidUpdate
// semantics (which compared only formData and sliceId). Other
// fetch-related inputs referenced by handleFetchData (callbacks and
// request option props) are intentionally excluded from the dependency
// array, so the exhaustive-deps rule is suppressed here.
useEffect(() => {
handleFetchData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [formData, sliceId]);
private handleFetchData = () => {
const {
loadDatasource,
formDataRequestOptions,
datasourceRequestOptions,
queryRequestOptions,
} = this.props;
const { status, payload, error } = state;
this.setState({ status: 'loading' }, () => {
try {
this.chartClient
.loadFormData(
this.extractSliceIdAndFormData(),
formDataRequestOptions,
)
.then(formData =>
Promise.all([
loadDatasource
? this.chartClient.loadDatasource(
formData.datasource,
datasourceRequestOptions,
)
: Promise.resolve(undefined),
this.chartClient.loadQueryData(formData, queryRequestOptions),
]).then(
([datasource, queriesData]) =>
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
({
datasource,
formData,
queriesData,
}) as Payload,
),
)
.then(this.handleReceiveData)
.catch(this.handleError);
} catch (error) {
this.handleError(error as Error);
}
});
};
private handleReceiveData = (payload?: Payload) => {
const { onLoaded } = this.props;
if (onLoaded) onLoaded(payload);
this.setState({ payload, status: 'loaded' });
};
private handleError = (error: ProvidedProps['error']) => {
const { onError } = this.props;
if (onError) onError(error);
this.setState({ error, status: 'error' });
};
render() {
const { children } = this.props;
const { status, payload, error } = this.state;
switch (status) {
case 'loading':
return children({ loading: true });
case 'loaded':
return children({ payload });
case 'error':
return children({ error });
case 'uninitialized':
default:
return null;
}
// Wrap the children result in a Fragment so the component's return type
// stays `JSX.Element | null` (which TypeScript requires for JSX components)
// while still letting consumers return any ReactNode (strings, fragments,
// arrays, null, etc.) from the render prop.
switch (status) {
case 'loading':
return <>{children({ loading: true })}</>;
case 'loaded':
return <>{children({ payload })}</>;
case 'error':
return <>{children({ error })}</>;
case 'uninitialized':
default:
return null;
}
}

View File

@@ -21,8 +21,11 @@ import {
ReactNode,
RefObject,
ComponentType,
PureComponent,
Fragment,
memo,
useCallback,
useMemo,
useRef,
} from 'react';
import {
@@ -32,23 +35,19 @@ import {
} from 'react-error-boundary';
import { ParentSize } from '@visx/responsive';
import { createSelector } from 'reselect';
import { withTheme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import { parseLength, Dimension } from '../../dimension';
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
import SuperChartCore, {
Props as SuperChartCoreProps,
SuperChartCoreRef,
} from './SuperChartCore';
import DefaultFallbackComponent from './FallbackComponent';
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
import NoResultsComponent from './NoResultsComponent';
import { isMatrixifyEnabled } from '../types/matrixify';
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
const defaultProps = {
FallbackComponent: DefaultFallbackComponent,
height: 400 as string | number,
width: '100%' as string | number,
enableNoResults: true,
};
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
export type WrapperProps = Dimension & {
@@ -56,7 +55,9 @@ export type WrapperProps = Dimension & {
};
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
Omit<ChartPropsConfig, 'width' | 'height'> & {
Omit<ChartPropsConfig, 'width' | 'height' | 'theme'> & {
/** Theme object (optional, falls back to ThemeProvider context) */
theme?: SupersetTheme;
/**
* Set this to true to disable error boundary built-in in SuperChart
* and let the error propagate to upper level
@@ -102,215 +103,269 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
inContextMenu?: boolean;
};
type PropsWithDefault = Props & Readonly<typeof defaultProps>;
class SuperChart extends PureComponent<Props, {}> {
function SuperChart({
id,
className,
chartType,
preTransformProps,
overrideTransformProps,
postTransformProps,
onRenderSuccess,
onRenderFailure,
disableErrorBoundary,
FallbackComponent = DefaultFallbackComponent,
onErrorBoundary,
Wrapper,
queriesData,
enableNoResults = true,
noResults,
theme: themeProp,
debounceTime,
height = 400,
width = '100%',
...rest
}: Props): JSX.Element {
/**
* SuperChart's core
* SuperChart's core ref
*/
core?: SuperChartCore | null;
const coreRef = useRef<SuperChartCoreRef | null>(null);
private createChartProps = ChartProps.createSelector();
// Use theme from prop if provided, otherwise from context.
// When no ThemeProvider is present, useTheme() returns an empty object,
// so we fall back to the default supersetTheme to avoid passing an invalid theme downstream.
const themeFromContext = useTheme() as Partial<SupersetTheme>;
const theme =
themeProp ??
(Object.keys(themeFromContext).length > 0
? (themeFromContext as SupersetTheme)
: supersetTheme);
private parseDimension = createSelector(
[
({ width }: { width: string | number; height: string | number }) => width,
({ height }) => height,
],
(width, height) => {
// Parse them in case they are % or 'auto'
const widthInfo = parseLength(width);
const heightInfo = parseLength(height);
const boxHeight = heightInfo.isDynamic
? `${heightInfo.multiplier * 100}%`
: heightInfo.value;
const boxWidth = widthInfo.isDynamic
? `${widthInfo.multiplier * 100}%`
: widthInfo.value;
const style = {
height: boxHeight,
width: boxWidth,
};
const createChartProps = useMemo(() => ChartProps.createSelector(), []);
// bounding box will ensure that when one dimension is not dynamic
// e.g. height = 300
// the auto size will be bound to that value instead of being 100% by default
// e.g. height: 300 instead of height: '100%'
const BoundingBox =
widthInfo.isDynamic &&
heightInfo.isDynamic &&
widthInfo.multiplier === 1 &&
heightInfo.multiplier === 1
? Fragment
: ({ children }: { children: ReactNode }) => (
<div style={style}>{children}</div>
);
const parseDimension = useMemo(
() =>
createSelector(
[
({ width: w }: { width: string | number; height: string | number }) =>
w,
({
height: h,
}: {
width: string | number;
height: string | number;
}) => h,
],
(w, h) => {
// Parse them in case they are % or 'auto'
const widthInfo = parseLength(w);
const heightInfo = parseLength(h);
const boxHeight = heightInfo.isDynamic
? `${heightInfo.multiplier * 100}%`
: heightInfo.value;
const boxWidth = widthInfo.isDynamic
? `${widthInfo.multiplier * 100}%`
: widthInfo.value;
const style = {
height: boxHeight,
width: boxWidth,
};
return { BoundingBox, heightInfo, widthInfo };
},
// bounding box will ensure that when one dimension is not dynamic
// e.g. height = 300
// the auto size will be bound to that value instead of being 100% by default
// e.g. height: 300 instead of height: '100%'
const BoundingBox =
widthInfo.isDynamic &&
heightInfo.isDynamic &&
widthInfo.multiplier === 1 &&
heightInfo.multiplier === 1
? Fragment
: ({ children }: { children: ReactNode }) => (
<div style={style}>{children}</div>
);
return { BoundingBox, heightInfo, widthInfo };
},
),
[],
);
static defaultProps = defaultProps;
const setRef = useCallback((core: SuperChartCoreRef | null) => {
coreRef.current = core;
}, []);
private setRef = (core: SuperChartCore | null) => {
this.core = core;
};
const getQueryCount = useCallback(
() => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
[chartType],
);
private getQueryCount = () =>
getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
const renderChart = useCallback(
(chartWidth: number, chartHeight: number) => {
const chartProps = createChartProps({
...rest,
queriesData,
height: chartHeight,
width: chartWidth,
theme,
});
renderChart(width: number, height: number) {
const {
// Check if Matrixify is enabled - use rawFormData (snake_case)
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
if (matrixifyEnabled) {
// When matrixify is enabled, queriesData is expected to be empty
// since each cell fetches its own data via StatefulChart
const matrixifyChart = (
<MatrixifyGridRenderer
formData={chartProps.rawFormData}
datasource={chartProps.datasource}
width={chartWidth}
height={chartHeight}
hooks={chartProps.hooks}
/>
);
// Apply wrapper if provided
const wrappedChart = Wrapper ? (
<Wrapper width={chartWidth} height={chartHeight}>
{matrixifyChart}
</Wrapper>
) : (
matrixifyChart
);
// Include error boundary unless disabled
return disableErrorBoundary === true ? (
wrappedChart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent
width={chartWidth}
height={chartHeight}
{...props}
/>
)}
onError={onErrorBoundary}
>
{wrappedChart}
</ErrorBoundary>
);
}
// Check for no results only for non-matrixified charts
const noResultQueries =
enableNoResults &&
(!queriesData ||
queriesData
.slice(0, getQueryCount())
.every(
({ data }) => !data || (Array.isArray(data) && data.length === 0),
));
let chart: JSX.Element;
if (noResultQueries) {
chart = noResults ? (
<>{noResults}</>
) : (
<NoResultsComponent
id={id}
className={className}
height={chartHeight}
width={chartWidth}
/>
);
} else {
const chartWithoutWrapper = (
<SuperChartCore
ref={setRef}
id={id}
className={className}
chartType={chartType}
chartProps={chartProps}
preTransformProps={preTransformProps}
overrideTransformProps={overrideTransformProps}
postTransformProps={postTransformProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
);
chart = Wrapper ? (
<Wrapper width={chartWidth} height={chartHeight}>
{chartWithoutWrapper}
</Wrapper>
) : (
chartWithoutWrapper
);
}
// Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
chart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent
width={chartWidth}
height={chartHeight}
{...props}
/>
)}
onError={onErrorBoundary}
>
{chart}
</ErrorBoundary>
);
},
[
createChartProps,
rest,
queriesData,
theme,
Wrapper,
disableErrorBoundary,
FallbackComponent,
onErrorBoundary,
enableNoResults,
getQueryCount,
noResults,
id,
className,
setRef,
chartType,
preTransformProps,
overrideTransformProps,
postTransformProps,
onRenderSuccess,
onRenderFailure,
disableErrorBoundary,
FallbackComponent,
onErrorBoundary,
Wrapper,
queriesData,
enableNoResults,
noResults,
theme,
...rest
} = this.props as PropsWithDefault;
],
);
const chartProps = this.createChartProps({
...rest,
queriesData,
height,
width,
theme,
});
const { heightInfo, widthInfo, BoundingBox } = parseDimension({
width,
height,
});
// Check if Matrixify is enabled - use rawFormData (snake_case)
const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
if (matrixifyEnabled) {
// When matrixify is enabled, queriesData is expected to be empty
// since each cell fetches its own data via StatefulChart
const matrixifyChart = (
<MatrixifyGridRenderer
formData={chartProps.rawFormData}
datasource={chartProps.datasource}
width={width}
height={height}
hooks={chartProps.hooks}
/>
);
// Apply wrapper if provided
const wrappedChart = Wrapper ? (
<Wrapper width={width} height={height}>
{matrixifyChart}
</Wrapper>
) : (
matrixifyChart
);
// Include error boundary unless disabled
return disableErrorBoundary === true ? (
wrappedChart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent width={width} height={height} {...props} />
)}
onError={onErrorBoundary}
>
{wrappedChart}
</ErrorBoundary>
);
}
// Check for no results only for non-matrixified charts
const noResultQueries =
enableNoResults &&
(!queriesData ||
queriesData
.slice(0, this.getQueryCount())
.every(
({ data }) => !data || (Array.isArray(data) && data.length === 0),
));
let chart;
if (noResultQueries) {
chart = noResults || (
<NoResultsComponent
id={id}
className={className}
height={height}
width={width}
/>
);
} else {
const chartWithoutWrapper = (
<SuperChartCore
ref={this.setRef}
id={id}
className={className}
chartType={chartType}
chartProps={chartProps}
preTransformProps={preTransformProps}
overrideTransformProps={overrideTransformProps}
postTransformProps={postTransformProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
);
chart = Wrapper ? (
<Wrapper width={width} height={height}>
{chartWithoutWrapper}
</Wrapper>
) : (
chartWithoutWrapper
);
}
// Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
chart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent width={width} height={height} {...props} />
)}
onError={onErrorBoundary}
>
{chart}
</ErrorBoundary>
// If any of the dimension is dynamic, get parent's dimension
if (widthInfo.isDynamic || heightInfo.isDynamic) {
return (
<BoundingBox>
<ParentSize debounceTime={debounceTime}>
{({ width: parentWidth, height: parentHeight }) =>
renderChart(
widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
heightInfo.isDynamic
? Math.floor(parentHeight)
: heightInfo.value,
)
}
</ParentSize>
</BoundingBox>
);
}
render() {
const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
this.props as PropsWithDefault,
);
// If any of the dimension is dynamic, get parent's dimension
if (widthInfo.isDynamic || heightInfo.isDynamic) {
const { debounceTime } = this.props;
return (
<BoundingBox>
<ParentSize debounceTime={debounceTime}>
{({ width, height }) =>
this.renderChart(
widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
)
}
</ParentSize>
</BoundingBox>
);
}
return this.renderChart(widthInfo.value, heightInfo.value);
}
return renderChart(widthInfo.value, heightInfo.value);
}
export default withTheme(SuperChart);
// Wrap in memo to preserve the shallow-prop-comparison behavior
// of the original PureComponent implementation.
export default memo(SuperChart);

View File

@@ -17,8 +17,13 @@
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props */
import { PureComponent } from 'react';
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { createSelector } from 'reselect';
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
@@ -39,16 +44,6 @@ function IDENTITY<T>(x: T) {
const EMPTY = () => null;
const defaultProps = {
id: '',
className: '',
preTransformProps: IDENTITY,
overrideTransformProps: undefined,
postTransformProps: IDENTITY,
onRenderSuccess() {},
onRenderFailure() {},
};
interface LoadingProps {
error: { toString(): string };
}
@@ -78,174 +73,231 @@ export type Props = {
onRenderFailure?: HandlerFunction;
};
export default class SuperChartCore extends PureComponent<Props, {}> {
/**
* The HTML element that wraps all chart content
*/
container?: HTMLElement | null;
export interface SuperChartCoreRef {
container: HTMLElement | null;
}
/**
* memoized function so it will not recompute and return previous value
* unless one of
* - preTransformProps
* - chartProps
* is changed.
*/
preSelector = createSelector(
[
(input: {
const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
function SuperChartCore(
{
id = '',
className = '',
chartProps = BLANK_CHART_PROPS,
chartType,
preTransformProps = IDENTITY,
overrideTransformProps,
postTransformProps = IDENTITY,
onRenderSuccess = () => {},
onRenderFailure = () => {},
},
ref,
) {
const containerRef = useRef<HTMLElement | null>(null);
// Expose container via ref
useImperativeHandle(
ref,
() => ({
get container() {
return containerRef.current;
},
}),
[],
);
/**
* memoized function so it will not recompute and return previous value
* unless one of
* - preTransformProps
* - chartProps
* is changed.
*/
const preSelector = useMemo(
() =>
createSelector(
[
(input: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
],
(inputChartProps, pre = IDENTITY) => pre(inputChartProps),
),
[],
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
const transformSelector = useMemo(
() =>
createSelector(
[
(input: {
chartProps: ChartProps;
transformProps?: TransformProps;
}) => input.chartProps,
input => input.transformProps,
],
(preprocessedChartProps, transform = IDENTITY) =>
transform(preprocessedChartProps),
),
[],
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
const postSelector = useMemo(
() =>
createSelector(
[
(input: {
chartProps: ChartProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.postTransformProps,
],
(transformedChartProps, post = IDENTITY) =>
post(transformedChartProps),
),
[],
);
/**
* Using each memoized function to retrieve the computed chartProps
*/
const processChartProps = useCallback(
({
chartProps: inputChartProps,
preTransformProps: pre,
transformProps,
postTransformProps: post,
}: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
}) => input.chartProps,
input => input.preTransformProps,
],
(chartProps, pre = IDENTITY) => pre(chartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
transformSelector = createSelector(
[
(input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
input.chartProps,
input => input.transformProps,
],
(preprocessedChartProps, transform = IDENTITY) =>
transform(preprocessedChartProps),
);
/**
* memoized function so it will not recompute and return previous value
* unless one of the input arguments have changed.
*/
postSelector = createSelector(
[
(input: {
chartProps: ChartProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) => input.chartProps,
input => input.postTransformProps,
],
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
);
/**
* Using each memoized function to retrieve the computed chartProps
*/
processChartProps = ({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
}: {
chartProps: ChartProps;
preTransformProps?: PreTransformProps;
transformProps?: TransformProps;
postTransformProps?: PostTransformProps;
}) =>
this.postSelector({
chartProps: this.transformSelector({
chartProps: this.preSelector({ chartProps, preTransformProps }),
transformProps,
}),
postTransformProps,
});
/**
* memoized function so it will not recompute
* and return previous value
* unless one of
* - chartType
* - overrideTransformProps
* is changed.
*/
private createLoadableRenderer = createSelector(
[
(input: { chartType: string; overrideTransformProps?: TransformProps }) =>
input.chartType,
input => input.overrideTransformProps,
],
(chartType, overrideTransformProps) => {
if (chartType) {
const Renderer = createLoadableRenderer({
loader: {
Chart: () => getChartComponentRegistry().getAsPromise(chartType),
transformProps: overrideTransformProps
? () => Promise.resolve(overrideTransformProps)
: () => getChartTransformPropsRegistry().getAsPromise(chartType),
},
loading: (loadingProps: LoadingProps) =>
this.renderLoading(loadingProps, chartType),
render: this.renderChart,
});
// Trigger preloading.
Renderer.preload();
return Renderer;
}
return EMPTY;
},
);
static defaultProps = defaultProps;
private renderChart = (loaded: LoadedModules, props: RenderProps) => {
const { Chart, transformProps } = loaded;
const { chartProps, preTransformProps, postTransformProps } = props;
return (
<Chart
{...this.processChartProps({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
})}
/>
}) =>
postSelector({
chartProps: transformSelector({
chartProps: preSelector({
chartProps: inputChartProps,
preTransformProps: pre,
}),
transformProps,
}),
postTransformProps: post,
}),
[preSelector, transformSelector, postSelector],
);
};
private renderLoading = (loadingProps: LoadingProps, chartType: string) => {
const { error } = loadingProps;
const renderLoading = useCallback(
(loadingProps: LoadingProps, loadingChartType: string) => {
const { error } = loadingProps;
if (error) {
return (
<div className="alert alert-warning" role="alert">
<strong>{t('ERROR')}</strong>&nbsp;
<code>chartType=&quot;{chartType}&quot;</code> &mdash;
{error.toString()}
</div>
);
}
if (error) {
return (
<div className="alert alert-warning" role="alert">
<strong>{t('ERROR')}</strong>&nbsp;
<code>chartType=&quot;{loadingChartType}&quot;</code> &mdash;
{error.toString()}
</div>
);
}
return null;
};
return null;
},
[],
);
private setRef = (container: HTMLElement | null) => {
this.container = container;
};
const renderChart = useCallback(
(loaded: LoadedModules, props: RenderProps) => {
const { Chart, transformProps } = loaded;
const {
chartProps: renderChartProps,
preTransformProps: pre,
postTransformProps: post,
} = props;
render() {
const {
id,
className,
preTransformProps,
postTransformProps,
chartProps = BLANK_CHART_PROPS,
onRenderSuccess,
onRenderFailure,
} = this.props;
return (
<Chart
{...processChartProps({
chartProps: renderChartProps,
preTransformProps: pre,
transformProps,
postTransformProps: post,
})}
/>
);
},
[processChartProps],
);
/**
* memoized function so it will not recompute
* and return previous value
* unless one of
* - chartType
* - overrideTransformProps
* is changed.
*/
const createLoadableRendererSelector = useMemo(
() =>
createSelector(
[
(input: {
chartType: string;
overrideTransformProps?: TransformProps;
}) => input.chartType,
input => input.overrideTransformProps,
],
(selectorChartType, selectorOverrideTransformProps) => {
if (selectorChartType) {
const Renderer = createLoadableRenderer({
loader: {
Chart: () =>
getChartComponentRegistry().getAsPromise(selectorChartType),
transformProps: selectorOverrideTransformProps
? () => Promise.resolve(selectorOverrideTransformProps)
: () =>
getChartTransformPropsRegistry().getAsPromise(
selectorChartType,
),
},
loading: (loadingProps: LoadingProps) =>
renderLoading(loadingProps, selectorChartType),
render: renderChart,
});
// Trigger preloading.
Renderer.preload();
return Renderer;
}
return EMPTY;
},
),
[renderLoading, renderChart],
);
const setRef = useCallback((container: HTMLElement | null) => {
containerRef.current = container;
}, []);
// Create LoadableRenderer and start preloading
// the lazy-loaded Chart components
const Renderer = this.createLoadableRenderer(this.props);
const Renderer = createLoadableRendererSelector({
chartType,
overrideTransformProps,
});
// Do not render if chartProps is set to null.
// but the pre-loading has been started in this.createLoadableRenderer
// but the pre-loading has been started in createLoadableRendererSelector
// to prepare for rendering once chartProps becomes available.
if (chartProps === null) {
return null;
@@ -263,7 +315,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
}
return (
<div {...containerProps} ref={this.setRef}>
<div {...containerProps} ref={setRef}>
<Renderer
preTransformProps={preTransformProps}
postTransformProps={postTransformProps}
@@ -273,5 +325,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
/>
</div>
);
}
}
},
);
export default SuperChartCore;

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();
jest.useFakeTimers({ advanceTimers: true });
render(<Select {...defaultProps} allowNewOptions />);
await open();

View File

@@ -24,6 +24,7 @@ import { triggerResizeObserver } from 'resize-observer-polyfill';
import { ErrorBoundary } from 'react-error-boundary';
import { promiseTimeout, SuperChart } from '@superset-ui/core';
import { supersetTheme } from '@apache-superset/core/theme';
import { WrapperProps } from '../../../src/chart/components/SuperChart';
import {
@@ -118,6 +119,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width="200"
height="200"
theme={supersetTheme}
/>,
);
@@ -138,6 +140,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width="200"
height="200"
theme={supersetTheme}
FallbackComponent={CustomFallbackComponent}
/>,
);
@@ -154,6 +157,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width="200"
height="200"
theme={supersetTheme}
onErrorBoundary={handleError}
/>,
);
@@ -178,6 +182,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width="200"
height="200"
theme={supersetTheme}
onErrorBoundary={inactiveErrorHandler}
/>
</ErrorBoundary>,
@@ -205,6 +210,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width={101}
height={118}
theme={supersetTheme}
formData={{ abc: 1 }}
/>,
);
@@ -285,6 +291,7 @@ describe('SuperChart', () => {
debounceTime={1}
width="100%"
height="100%"
theme={supersetTheme}
/>,
);
@@ -332,6 +339,7 @@ describe('SuperChart', () => {
queriesData={DEFAULT_QUERIES_DATA}
width={101}
height={118}
theme={supersetTheme}
formData={{ abc: 1 }}
/>,
);
@@ -347,7 +355,12 @@ describe('SuperChart', () => {
describe('supports NoResultsComponent', () => {
test('renders NoResultsComponent when queriesData is missing', () => {
render(
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
<SuperChart
chartType={ChartKeys.DILIGENT}
width="200"
height="200"
theme={supersetTheme}
/>,
);
expect(screen.getByText('No Results')).toBeInTheDocument();
@@ -360,6 +373,7 @@ describe('SuperChart', () => {
queriesData={[{ data: null }]}
width="200"
height="200"
theme={supersetTheme}
/>,
);
@@ -387,6 +401,7 @@ describe('SuperChart', () => {
queriesData={[DEFAULT_QUERY_DATA]}
width={100}
height={100}
theme={supersetTheme}
/>,
);
@@ -411,6 +426,7 @@ describe('SuperChart', () => {
debounceTime={1}
width="100%"
height="100%"
theme={supersetTheme}
Wrapper={MyWrapper}
/>
</div>,
@@ -475,6 +491,7 @@ describe('SuperChart', () => {
chartType={ChartKeys.DILIGENT}
width="200"
height="200"
theme={supersetTheme}
queriesData={[{ data: [] }]}
enableNoResults
/>,
@@ -500,6 +517,7 @@ describe('SuperChart', () => {
chartType={ChartKeys.DILIGENT}
width="200"
height="200"
theme={supersetTheme}
queriesData={[{ data: null }]}
enableNoResults
/>,
@@ -527,6 +545,7 @@ describe('SuperChart', () => {
chartType={ChartKeys.DILIGENT}
width="200"
height="200"
theme={supersetTheme}
queriesData={[{ data: [] }]}
enableNoResults
noResults={<CustomNoResults />}
@@ -556,6 +575,7 @@ describe('SuperChart', () => {
chartType={ChartKeys.DILIGENT}
width="200"
height="200"
theme={supersetTheme}
queriesData={[{ data: [] }]}
enableNoResults
onErrorBoundary={onErrorBoundary}

View File

@@ -227,15 +227,28 @@ describe('SuperChartCore', () => {
});
});
describe('.processChartProps()', () => {
test('use identity functions for unspecified transforms', () => {
const chart = new SuperChartCore({
chartType: ChartKeys.DILIGENT,
describe('processChartProps behavior', () => {
test('applies identity pre/post transforms so chartProps reach overrideTransformProps unchanged', async () => {
// When pre/post transform props are not specified, identity functions are used,
// so the original chartProps should reach overrideTransformProps unchanged.
// overrideTransformProps is used here as a probe to read the final chartProps;
// it's not part of what's being tested for identity behavior.
const chartProps2 = new ChartProps({
queriesData: [{ message: 'identity-test' }],
theme: supersetTheme,
});
const chartProps2 = new ChartProps();
expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
chartProps2,
render(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
chartProps={chartProps2}
overrideTransformProps={props => props.queriesData[0]}
/>,
);
await waitFor(() => {
expect(screen.getByText('identity-test')).toBeInTheDocument();
});
});
});
});

View File

@@ -17,12 +17,13 @@
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Locator, Page, expect } from '@playwright/test';
import { Button, Checkbox, Table } from '../core';
const BULK_SELECT_SELECTORS = {
CONTROLS: '[data-test="bulk-select-controls"]',
ACTION: '[data-test="bulk-select-action"]',
HEADER_TOGGLE: '[data-test="header-toggle-all"]',
} as const;
/**
@@ -56,10 +57,17 @@ export class BulkSelect {
}
/**
* Enables bulk selection mode by clicking the toggle button
* Enables bulk selection mode by clicking the toggle button.
*
* Waits for the bulk-select column header checkbox to render so the next
* row interaction does not race the table re-render that adds the
* checkbox column.
*/
async enable(): Promise<void> {
await this.getToggleButton().click();
await this.page
.locator(BULK_SELECT_SELECTORS.HEADER_TOGGLE)
.waitFor({ state: 'visible' });
}
/**
@@ -72,19 +80,27 @@ export class BulkSelect {
}
/**
* Selects a row's checkbox in bulk select mode
* Selects a row's checkbox in bulk select mode.
* Asserts the checkbox is checked afterwards so any state-update race
* surfaces here rather than as a missing bulk-action button later.
* @param rowName - The name/text identifying the row to select
*/
async selectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).check();
const checkbox = this.getRowCheckbox(rowName);
await checkbox.check();
await expect(checkbox.element).toBeChecked();
}
/**
* Deselects a row's checkbox in bulk select mode
* Deselects a row's checkbox in bulk select mode.
* Mirrors selectRow: asserts the unchecked state so any lingering selection
* surfaces here rather than as a stale bulk-action count later.
* @param rowName - The name/text identifying the row to deselect
*/
async deselectRow(rowName: string): Promise<void> {
await this.getRowCheckbox(rowName).uncheck();
const checkbox = this.getRowCheckbox(rowName);
await checkbox.uncheck();
await expect(checkbox.element).not.toBeChecked();
}
/**
@@ -107,10 +123,11 @@ export class BulkSelect {
}
/**
* Clicks a bulk action button by name (e.g., "Export", "Delete")
* Clicks a bulk action button by name (e.g., "Export", "Delete").
* @param actionName - The name of the bulk action to click
*/
async clickAction(actionName: string): Promise<void> {
await this.getActionButton(actionName).click();
const button = this.getActionButton(actionName);
await button.click();
}
}

View File

@@ -17,6 +17,7 @@
* under the License.
*/
import { expect } from '@playwright/test';
import { Modal, Input } from '../core';
/**
@@ -27,7 +28,8 @@ import { Modal, Input } from '../core';
*/
export class DeleteConfirmationModal extends Modal {
private static readonly SELECTORS = {
CONFIRMATION_INPUT: 'input[type="text"]',
CONFIRMATION_INPUT: '[data-test="delete-modal-input"]',
CONFIRM_BUTTON: '[data-test="modal-confirm-button"]',
};
/**
@@ -36,12 +38,16 @@ export class DeleteConfirmationModal extends Modal {
private get confirmationInput(): Input {
return new Input(
this.page,
this.body.locator(DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT),
this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRMATION_INPUT,
),
);
}
/**
* Fills the confirmation input with the specified text.
* Waits for the input to be visible before filling so callers don't race
* with the modal's open animation / focus effect.
*
* @param confirmationText - The text to type
* @param options - Optional fill options (timeout, force)
@@ -57,11 +63,25 @@ export class DeleteConfirmationModal extends Modal {
confirmationText: string,
options?: { timeout?: number; force?: boolean },
): Promise<void> {
await this.confirmationInput.element.waitFor({
state: 'visible',
timeout: options?.timeout,
});
await this.confirmationInput.fill(confirmationText, options);
}
/**
* Clicks the Delete button in the footer
* Clicks the Delete button in the footer.
*
* Targets the confirm button by data-test rather than going through
* Modal.clickFooterButton, which finds buttons by their visible text. The
* button label is i18n'd ("Delete" / "Supprimer" / …) so name-based lookups
* break in non-English locales.
*
* Also waits for the button to become enabled before clicking: it is
* disabled until the confirmation text matches "DELETE", and React's state
* update from fillConfirmationInput is asynchronous, so an immediate click
* can race the disabled→enabled transition.
*
* @param options - Optional click options (timeout, force, delay)
*/
@@ -70,6 +90,10 @@ export class DeleteConfirmationModal extends Modal {
force?: boolean;
delay?: number;
}): Promise<void> {
await this.clickFooterButton('Delete', options);
const confirmButton = this.element.locator(
DeleteConfirmationModal.SELECTORS.CONFIRM_BUTTON,
);
await expect(confirmButton).toBeEnabled({ timeout: options?.timeout });
await confirmButton.click(options);
}
}

View File

@@ -25,22 +25,108 @@
*
* Run locally:
* cd superset-frontend
* npm run docs:screenshots
* PLAYWRIGHT_BASE_URL=http://localhost:8088 PLAYWRIGHT_ADMIN_PASSWORD=admin npm run docs:screenshots
*
* Or directly:
* npx playwright test --config=playwright/generators/playwright.config.ts docs/
*
* Screenshots are saved to docs/static/img/screenshots/.
* 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.
*/
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 SCREENSHOTS_DIR = path.resolve(
__dirname,
'../../../../docs/static/img/screenshots',
);
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 });
}
}
}
test('chart gallery screenshot', async ({ page }) => {
await page.goto(URL.CHART_ADD);
@@ -58,6 +144,7 @@ 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',
@@ -65,36 +152,7 @@ test('chart gallery screenshot', async ({ page }) => {
});
test('dashboard screenshot', async ({ 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 });
await openSalesDashboard(page);
// Open the filter bar (collapsed by default)
const expandButton = page.locator('[data-test="filter-bar__expand-button"]');
@@ -109,6 +167,8 @@ 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',
@@ -143,6 +203,7 @@ test('chart editor screenshot', async ({ page }) => {
timeout: 15000,
});
await settle(page);
await page.screenshot({
path: path.join(SCREENSHOTS_DIR, 'explore.jpg'),
type: 'jpeg',
@@ -151,7 +212,7 @@ test('chart editor screenshot', async ({ page }) => {
});
test('SQL Lab screenshot', async ({ page }) => {
// SQL Lab has many interactive steps (schema, table, query, results) — allow extra time
// SQL Lab has many interactive steps — allow extra time
test.setTimeout(90000);
await page.goto(URL.SQLLAB);
@@ -168,34 +229,7 @@ test('SQL Lab screenshot', async ({ page }) => {
}
await expect(aceEditor).toBeVisible({ timeout: 15000 });
// 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();
// Click the active query tab to ensure focus is on the editor pane
await page.getByText('Untitled Query').first().click();
// Write a multi-line SELECT with explicit columns to fill the editor
@@ -205,8 +239,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
const runButton = page.getByText('Run', { exact: true });
// Run the query — use the stable data-test attribute on the action button
const runButton = page.locator('[data-test="run-query-action"]');
await expect(runButton).toBeVisible();
await runButton.click();
@@ -222,9 +256,352 @@ 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);
}
});

File diff suppressed because it is too large Load Diff

View File

@@ -64,6 +64,7 @@ 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

@@ -32,6 +32,7 @@ import {
expectStatusOneOf,
expectValidExportZip,
} from '../../helpers/api/assertions';
import { TIMEOUT } from '../../utils/constants';
/**
* Extend testWithAssets with chartListPage navigation (beforeEach equivalent).
@@ -62,8 +63,11 @@ test('should delete a chart with confirmation', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await chartListPage.clickDeleteAction(chartName);
@@ -81,12 +85,12 @@ test('should delete a chart with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify chart is removed from list
await expect(chartListPage.getChartRow(chartName)).not.toBeVisible();
// Verify chart is removed from list (deleted rows leave the DOM)
await expect(chartListPage.getChartRow(chartName)).toHaveCount(0);
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.CHART, chartId, {
@@ -111,8 +115,11 @@ test('should edit chart name via properties modal', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click edit action to open properties modal
await chartListPage.clickEditAction(chartName);
@@ -137,7 +144,7 @@ test('should edit chart name via properties modal', async ({
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
@@ -164,8 +171,11 @@ test('should export a chart as a zip file', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify chart is visible in list
await expect(chartListPage.getChartRow(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart appears.
await expect(chartListPage.getChartRow(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
@@ -202,9 +212,14 @@ test('should bulk delete multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -229,13 +244,13 @@ test('should bulk delete multiple charts', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both charts are removed from list
await expect(chartListPage.getChartRow(chart1.name)).not.toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).not.toBeVisible();
// Verify both charts are removed from list (deleted rows leave the DOM)
await expect(chartListPage.getChartRow(chart1.name)).toHaveCount(0);
await expect(chartListPage.getChartRow(chart2.name)).toHaveCount(0);
// Backend verification: Both return 404
for (const chart of [chart1, chart2]) {
@@ -259,8 +274,11 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
await cardListPage.gotoCardView();
await cardListPage.waitForCardLoad();
// Verify chart card is visible
await expect(cardListPage.getChartCard(chartName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created chart card appears.
await expect(cardListPage.getChartCard(chartName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Open card dropdown and click edit
await cardListPage.clickCardEditAction(chartName);
@@ -285,13 +303,16 @@ test('should edit chart name from card view', async ({ page, testAssets }) => {
// Modal should close
await propertiesModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify the renamed card appears in card view and old name is gone
await expect(cardListPage.getChartCard(newName)).toBeVisible();
await expect(cardListPage.getChartCard(chartName)).not.toBeVisible();
// (the old card name is removed from the DOM after the rename re-render).
await expect(cardListPage.getChartCard(newName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(cardListPage.getChartCard(chartName)).toHaveCount(0);
// Backend verification: API returns updated name
const response = await apiGetChart(page, chartId);
@@ -304,6 +325,11 @@ test('should bulk export multiple charts', async ({
chartListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway charts for bulk export
const [chart1, chart2] = await Promise.all([
createTestChart(page, testAssets, test.info(), {
@@ -318,9 +344,14 @@ test('should bulk export multiple charts', async ({
await chartListPage.goto();
await chartListPage.waitForTableLoad();
// Verify both charts are visible in list
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible();
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created charts appear.
await expect(chartListPage.getChartRow(chart1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(chartListPage.getChartRow(chart2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await chartListPage.clickBulkSelectButton();
@@ -329,8 +360,12 @@ test('should bulk export multiple charts', async ({
await chartListPage.selectChartCheckbox(chart1.name);
await chartListPage.selectChartCheckbox(chart2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple charts can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.CHART_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
await chartListPage.clickBulkAction('Export');

View File

@@ -0,0 +1,220 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { testWithAssets, expect } from '../../helpers/fixtures';
import { apiPost, apiPut } from '../../helpers/api/requests';
import { apiPostDashboard } from '../../helpers/api/dashboard';
import { DashboardPage } from '../../pages/DashboardPage';
const DATASET_NAME = 'birth_names';
const FILTER_COLUMN = 'gender';
async function findDatasetIdByName(page: any, name: string): Promise<number> {
const rison = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
const resp = await page.request.get(`api/v1/dataset/?q=${rison}`);
const body = await resp.json();
if (!body.result?.length) {
throw new Error(`Dataset ${name} not found`);
}
return body.result[0].id;
}
testWithAssets(
'Clear all filters waits for Apply (sc-105059)',
async ({ page, testAssets }) => {
const datasetId = await findDatasetIdByName(page, DATASET_NAME);
// Create a chart that the dashboard filter will target
const chartParams = {
datasource: `${datasetId}__table`,
viz_type: 'big_number_total',
metric: 'count',
adhoc_filters: [],
header_font_size: 0.4,
subheader_font_size: 0.15,
};
const chartResp = await apiPost(page, 'api/v1/chart/', {
slice_name: `clear_all_repro_${Date.now()}`,
viz_type: 'big_number_total',
datasource_id: datasetId,
datasource_type: 'table',
params: JSON.stringify(chartParams),
});
expect(chartResp.ok()).toBe(true);
const chart = await chartResp.json();
const chartId: number = chart.id ?? chart.result?.id;
testAssets.trackChart(chartId);
// Create dashboard with chart in position_json and a native filter in json_metadata
const filterId = `NATIVE_FILTER-${Math.random().toString(36).slice(2, 10)}`;
const chartLayoutKey = `CHART-${chartId}`;
const positionJson = {
DASHBOARD_VERSION_KEY: 'v2',
ROOT_ID: { type: 'ROOT', id: 'ROOT_ID', children: ['GRID_ID'] },
GRID_ID: {
type: 'GRID',
id: 'GRID_ID',
children: ['ROW-1'],
parents: ['ROOT_ID'],
},
'ROW-1': {
type: 'ROW',
id: 'ROW-1',
children: [chartLayoutKey],
parents: ['ROOT_ID', 'GRID_ID'],
meta: { background: 'BACKGROUND_TRANSPARENT' },
},
[chartLayoutKey]: {
type: 'CHART',
id: chartLayoutKey,
children: [],
parents: ['ROOT_ID', 'GRID_ID', 'ROW-1'],
meta: {
chartId,
width: 6,
height: 50,
sliceName: 'clear_all_repro',
},
},
};
const jsonMetadata = {
native_filter_configuration: [
{
id: filterId,
name: 'Gender',
filterType: 'filter_select',
type: 'NATIVE_FILTER',
targets: [
{
datasetId,
column: { name: FILTER_COLUMN },
},
],
controlValues: {
multiSelect: false,
enableEmptyFilter: false,
defaultToFirstItem: false,
inverseSelection: false,
searchAllOptions: false,
},
defaultDataMask: { filterState: {}, extraFormData: {} },
cascadeParentIds: [],
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
],
chart_configuration: {},
cross_filters_enabled: false,
global_chart_configuration: {
scope: { rootPath: ['ROOT_ID'], excluded: [] },
chartsInScope: [chartId],
},
};
const dashResp = await apiPostDashboard(page, {
dashboard_title: `clear_all_repro_${Date.now()}`,
published: true,
position_json: JSON.stringify(positionJson),
json_metadata: JSON.stringify(jsonMetadata),
});
expect(dashResp.ok()).toBe(true);
const dashBody = await dashResp.json();
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
testAssets.trackDashboard(dashboardId);
// Associate chart with the dashboard so it actually renders
const linkResp = await apiPut(page, `api/v1/chart/${chartId}`, {
dashboards: [dashboardId],
});
expect(linkResp.ok()).toBe(true);
// Visit dashboard
const dashboardPage = new DashboardPage(page);
await dashboardPage.gotoById(dashboardId);
await dashboardPage.waitForLoad();
await dashboardPage.waitForChartsToLoad();
// The Gender select should be visible in the filter bar
const filterCombobox = page
.locator('[data-test="form-item-value"]')
.first()
.locator('[role="combobox"]');
await filterCombobox.click();
await page
.locator('.ant-select-item-option', { hasText: /^boy$/ })
.first()
.click();
// Close the dropdown
await page.keyboard.press('Escape');
const applyBtn = page.locator(
'[data-test="filter-bar__apply-button"], [data-test="filterbar-action-buttons"] button[type="submit"]',
);
// Wait for chart data to come back after Apply
const firstApplyResponse = page.waitForResponse(
r =>
r.url().includes('/api/v1/chart/data') &&
r.request().method() === 'POST',
{ timeout: 10_000 },
);
await applyBtn.first().click();
await firstApplyResponse;
await dashboardPage.waitForChartsToLoad();
// Now track POST /api/v1/chart/data requests around Clear All
const postsAfterClearAll: string[] = [];
const handler = (req: any) => {
if (
req.url().includes('/api/v1/chart/data') &&
req.method() === 'POST'
) {
postsAfterClearAll.push(req.url());
}
};
page.on('request', handler);
const clearBtn = page.locator('[data-test="filter-bar__clear-button"]');
await clearBtn.click();
// Allow time for any debounced reload to fire if the bug is present
await page.waitForTimeout(2000);
page.off('request', handler);
// BUG: on master, the Clear All triggers an immediate dispatch which
// re-runs the chart query before the user clicks Apply. After the fix,
// no chart/data request should fire until Apply is clicked.
expect(
postsAfterClearAll,
'Clear All must not reload charts until Apply is clicked',
).toEqual([]);
// After Apply, the chart should reload
const applyAfterClearPromise = page.waitForResponse(
r =>
r.url().includes('/api/v1/chart/data') &&
r.request().method() === 'POST',
{ timeout: 10_000 },
);
await applyBtn.first().click();
await applyAfterClearPromise;
},
);

View File

@@ -68,33 +68,34 @@ test('should delete a dashboard with confirmation', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await dashboardListPage.clickDeleteAction(dashboardName);
// Delete confirmation modal should appear
const deleteModal = new DeleteConfirmationModal(page);
await deleteModal.waitForVisible();
await deleteModal.waitForReady();
// Type "DELETE" to confirm
await deleteModal.fillConfirmationInput('DELETE');
// Click the Delete button
// Click the Delete button (waits for it to become enabled)
await deleteModal.clickDelete();
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify dashboard is removed from list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(0);
// Backend verification: API returns 404
await expectDeleted(page, ENDPOINTS.DASHBOARD, dashboardId, {
@@ -119,8 +120,11 @@ test('should export a dashboard as a zip file', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard is visible in list
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
@@ -157,13 +161,14 @@ test('should bulk delete multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await dashboardListPage.clickBulkSelectButton();
@@ -188,17 +193,17 @@ test('should bulk delete multiple dashboards', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both dashboards are removed from list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).not.toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).not.toBeVisible();
// Verify both dashboards are removed from list (deleted rows leave the DOM)
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toHaveCount(
0,
);
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toHaveCount(
0,
);
// Backend verification: Both return 404
for (const dashboard of [dashboard1, dashboard2]) {
@@ -213,6 +218,11 @@ test('should bulk export multiple dashboards', async ({
dashboardListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway dashboards for bulk export
const [dashboard1, dashboard2] = await Promise.all([
createTestDashboard(page, testAssets, test.info(), {
@@ -227,25 +237,30 @@ test('should bulk export multiple dashboards', async ({
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify both dashboards are visible in list
await expect(
dashboardListPage.getDashboardRow(dashboard1.name),
).toBeVisible();
await expect(
dashboardListPage.getDashboardRow(dashboard2.name),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dashboards appear.
await expect(dashboardListPage.getDashboardRow(dashboard1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(dashboardListPage.getDashboardRow(dashboard2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
// Enable bulk select mode (waits for the checkbox column to render)
await dashboardListPage.clickBulkSelectButton();
// Select both dashboards
// Select both dashboards (each call asserts the checkbox is checked)
await dashboardListPage.selectDashboardCheckbox(dashboard1.name);
await dashboardListPage.selectDashboardCheckbox(dashboard2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple dashboards can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DASHBOARD_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
// Click bulk export action (waits for the action button to render)
await dashboardListPage.clickBulkAction('Export');
// Wait for export API response and validate zip contains both dashboards
@@ -293,12 +308,12 @@ test.describe('import dashboard', () => {
label: `Dashboard ${dashboardId}`,
});
// Refresh to confirm dashboard is no longer in the list
// Refresh to confirm dashboard is no longer in the list (deleted rows leave the DOM)
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).not.toBeVisible();
await expect(dashboardListPage.getDashboardRow(dashboardName)).toHaveCount(
0,
);
// Click the import button
await dashboardListPage.clickImportButton();
@@ -350,7 +365,7 @@ test.describe('import dashboard', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -358,10 +373,11 @@ test.describe('import dashboard', () => {
await dashboardListPage.goto();
await dashboardListPage.waitForTableLoad();
// Verify dashboard appears in list
await expect(
dashboardListPage.getDashboardRow(dashboardName),
).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dashboard appears.
await expect(dashboardListPage.getDashboardRow(dashboardName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Track for cleanup: look up the reimported dashboard by title
const reimported = await getDashboardByName(page, dashboardName);

View File

@@ -107,8 +107,11 @@ test('should delete a dataset with confirmation', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click delete action button
await datasetListPage.clickDeleteAction(datasetName);
@@ -126,14 +129,13 @@ test('should delete a dataset with confirmation', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears with correct message
// Verify success toast appears with correct message.
const toast = new Toast(page);
const successToast = toast.getSuccess();
await expect(successToast).toBeVisible();
await expect(toast.getSuccess()).toBeVisible();
await expect(toast.getMessage()).toContainText('Deleted');
// Verify dataset is removed from list
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
// Verify dataset is removed from list (deleted rows leave the DOM)
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0);
// Verify via API that dataset no longer exists (404)
await expectDeleted(page, ENDPOINTS.DATASET, datasetId, {
@@ -155,10 +157,13 @@ test('should duplicate a dataset with new name', async ({
);
const duplicateName = `duplicate_${Date.now()}_${test.info().parallelIndex}`;
// Navigate to list and verify original dataset is visible
// Navigate to list and verify original dataset is visible.
// The list query is asynchronous; allow extra time on slow CI.
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Set up response intercept to capture duplicate dataset ID
const duplicateResponsePromise = waitForPost(
@@ -201,9 +206,14 @@ test('should duplicate a dataset with new name', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets exist in list
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible();
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// duplicate appears alongside the original.
await expect(datasetListPage.getDatasetRow(originalName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(duplicateName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// API Verification: Fetch both datasets via detail API for consistent comparison
// (list API may return undefined for fields that detail API returns as null)
@@ -256,6 +266,11 @@ test('should export multiple datasets via bulk select action', async ({
datasetListPage,
testAssets,
}) => {
// Chains create×2 → refresh → bulk select → export. Matches the
// sibling bulk-delete test's budget so the export response wait below
// can exceed the 30s default without hitting the test timeout.
test.setTimeout(TIMEOUT.SLOW_TEST);
// Create 2 throwaway datasets for bulk export
const [dataset1, dataset2] = await Promise.all([
createTestDataset(page, testAssets, test.info(), {
@@ -270,9 +285,14 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -281,8 +301,12 @@ test('should export multiple datasets via bulk select action', async ({
await datasetListPage.selectDatasetCheckbox(dataset1.name);
await datasetListPage.selectDatasetCheckbox(dataset2.name);
// Set up API response intercept for export endpoint
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT);
// Set up API response intercept BEFORE the click that triggers it.
// Exports of multiple datasets can take longer than 30s under load,
// so use SLOW_TEST instead of the default test-timeout-bound budget.
const exportResponsePromise = waitForGet(page, ENDPOINTS.DATASET_EXPORT, {
timeout: TIMEOUT.SLOW_TEST,
});
// Click bulk export action
await datasetListPage.clickBulkAction('Export');
@@ -312,8 +336,11 @@ test('should edit dataset name via modal', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset is visible in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Click edit action to open modal
await datasetListPage.clickEditAction(datasetName);
@@ -348,7 +375,7 @@ test('should edit dataset name via modal', async ({
// Modal should close
await editModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -377,9 +404,14 @@ test('should bulk delete multiple datasets', async ({
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify both datasets are visible in list
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-created datasets appear.
await expect(datasetListPage.getDatasetRow(dataset1.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
await expect(datasetListPage.getDatasetRow(dataset2.name)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Enable bulk select mode
await datasetListPage.clickBulkSelectButton();
@@ -404,13 +436,13 @@ test('should bulk delete multiple datasets', async ({
// Modal should close
await deleteModal.waitForHidden();
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible();
// Verify both datasets are removed from list
await expect(datasetListPage.getDatasetRow(dataset1.name)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(dataset2.name)).not.toBeVisible();
// Verify both datasets are removed from list (deleted rows leave the DOM)
await expect(datasetListPage.getDatasetRow(dataset1.name)).toHaveCount(0);
await expect(datasetListPage.getDatasetRow(dataset2.name)).toHaveCount(0);
// Verify via API that datasets no longer exist (404)
await expectDeleted(page, ENDPOINTS.DATASET, dataset1.id, {
@@ -455,10 +487,10 @@ test.describe('import dataset', () => {
label: `Dataset ${datasetId}`,
});
// Refresh to confirm dataset is no longer in the list
// Refresh to confirm dataset is no longer in the list (deleted rows leave the DOM)
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
await expect(datasetListPage.getDatasetRow(datasetName)).not.toBeVisible();
await expect(datasetListPage.getDatasetRow(datasetName)).toHaveCount(0);
// Click the import button
await datasetListPage.clickImportButton();
@@ -507,7 +539,7 @@ test.describe('import dataset', () => {
// Modal should close on success
await importModal.waitForHidden({ timeout: TIMEOUT.FILE_IMPORT });
// Verify success toast appears
// Verify success toast appears.
const toast = new Toast(page);
await expect(toast.getSuccess()).toBeVisible({ timeout: 10000 });
@@ -515,8 +547,11 @@ test.describe('import dataset', () => {
await datasetListPage.goto();
await datasetListPage.waitForTableLoad();
// Verify dataset appears in list
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible();
// The list query is asynchronous; allow extra time on slow CI before the
// freshly-imported dataset appears.
await expect(datasetListPage.getDatasetRow(datasetName)).toBeVisible({
timeout: TIMEOUT.API_RESPONSE,
});
// Track for cleanup: the dataset import API returns {"message": "OK"}
// with no ID, so look up the reimported dataset by name.

View File

@@ -56,6 +56,7 @@ jest.mock('@superset-ui/chart-controls', () => ({
}));
jest.mock('@superset-ui/core', () => ({
BRAND_COLOR: '#00A699',
GenericDataType: { Temporal: 2, String: 1 },
extractTimegrain: jest.fn(() => 'P1D'),
getMetricLabel: jest.fn(metric => metric),
@@ -280,4 +281,30 @@ describe('BigNumberWithTrendline transformProps', () => {
expect(result.bigNumber).toBe(360);
expect(result.subheader).toBe('50.0% WoW');
});
test('should not crash and should return undefined mainColor when colorPicker is null', () => {
const chartProps = {
width: 400,
height: 300,
queriesData: [
{
data: [
{ __timestamp: 1, value: 100 },
] as unknown as BigNumberDatum[],
colnames: ['__timestamp', 'value'],
coltypes: ['TEMPORAL', 'NUMERIC'],
},
],
formData: { ...baseFormData, colorPicker: null },
rawFormData: baseRawFormData,
hooks: baseHooks,
datasource: baseDatasource,
theme: { colors: { grayscale: { light5: '#eee' } } },
};
const result = transformProps(
chartProps as unknown as BigNumberWithTrendlineChartProps,
);
expect(result.mainColor).toBeUndefined();
});
});

View File

@@ -18,6 +18,7 @@
*/
import { t } from '@apache-superset/core/translation';
import {
BRAND_COLOR,
extractTimegrain,
getNumberFormatter,
NumberFormats,
@@ -140,8 +141,9 @@ export default function transformProps(
const compareLag = Number(compareLag_) || 0;
let formattedSubheader = subheader;
const { r, g, b } = colorPicker;
const mainColor = `rgb(${r}, ${g}, ${b})`;
const mainColor = colorPicker
? `rgb(${colorPicker.r}, ${colorPicker.g}, ${colorPicker.b})`
: undefined;
const xAxisLabel = getXAxisLabel(rawFormData) as string;
let trendLineData: TimeSeriesDatum[] | undefined;
@@ -290,12 +292,12 @@ export default function transformProps(
symbol: 'circle',
symbolSize: 10,
showSymbol: false,
color: mainColor,
color: mainColor ?? BRAND_COLOR,
areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
offset: 0,
color: mainColor,
color: mainColor ?? BRAND_COLOR,
},
{
offset: 1,

View File

@@ -17,16 +17,14 @@
* under the License.
*/
import { PureComponent } from 'react';
import { memo } from 'react';
import { TableRenderer } from './TableRenderers';
import type { ComponentProps } from 'react';
type PivotTableProps = ComponentProps<typeof TableRenderer>;
class PivotTable extends PureComponent<PivotTableProps> {
render() {
return <TableRenderer {...this.props} />;
}
function PivotTable(props: PivotTableProps) {
return <TableRenderer {...props} />;
}
export default PivotTable;
export default memo(PivotTable);

View File

@@ -796,45 +796,63 @@ const config: ControlPanelConfig = {
},
);
}
const { colnames, coltypes } =
const { colnames: queryColnames, coltypes: queryColtypes } =
chart?.queriesResponse?.[0] ?? {};
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) => ({
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({
value: colname,
label: Array.isArray(verboseMap)
? colname
: (verboseMap[colname] ?? colname),
dataType: coltypes[index],
})),
]
: [];
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;
}, [])
: [];
});
}
return acc;
}, [])
: [];
const columnOptions = hasTimeComparison
? processComparisonColumns(
numericColumns || [],

View File

@@ -25,6 +25,7 @@ import {
ControlPanelState,
ControlState,
ColorSchemeEnum,
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import config from '../src/controlPanel';
@@ -55,11 +56,12 @@ 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: [],
columns: datasourceColumns,
} as Partial<Dataset> as Dataset,
controls: {
time_compare: createMockControlState(timeCompareValue),
@@ -206,3 +208,144 @@ 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

@@ -856,7 +856,7 @@ export function loadQueryEditor(queryEditor: QueryEditor): SqlLabAction {
return { type: LOAD_QUERY_EDITOR, queryEditor };
}
interface TableSchema {
export interface TableSchema {
description: {
columns: unknown[];
selectStar: string;
@@ -1284,7 +1284,7 @@ export function addTable(
};
}
interface NewTable {
export interface NewTable {
id?: string;
dbId: number | string;
catalog?: string | null;
@@ -1346,7 +1346,7 @@ export function runTablePreviewQuery(
};
}
interface TableMetaData {
export interface TableMetaData {
columns?: unknown[];
selectStar?: string;
primaryKey?: unknown;
@@ -1660,7 +1660,7 @@ export function createDatasourceFailed(err: string): SqlLabAction {
return { type: CREATE_DATASOURCE_FAILED, err };
}
interface VizOptions {
export interface VizOptions {
dbId: number;
catalog?: string | null;
schema: string;

View File

@@ -53,7 +53,7 @@ describe('QueryAutoRefresh', () => {
const refreshApi = 'glob:*/api/v1/query/updated_since?*';
beforeEach(() => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
});
afterEach(() => {

View File

@@ -30,7 +30,7 @@ import fetchMock from 'fetch-mock';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
import { user, testQuery, mockdatasets } from 'src/SqlLab/fixtures';
import { FeatureFlag } from '@superset-ui/core';
import { FeatureFlag, SupersetClient } from '@superset-ui/core';
const mockedProps = {
visible: true,
@@ -45,7 +45,7 @@ fetchMock.get('glob:*/api/v1/dataset/?*', {
dataset_count: 3,
});
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
// Mock the user
const useSelectorMock = jest.spyOn(reactRedux, 'useSelector');
@@ -354,6 +354,131 @@ describe('SaveDatasetModal', () => {
});
});
const setupOverwriteFlow = async () => {
// Select the "Overwrite existing" radio
await userEvent.click(
screen.getByRole('radio', { name: /overwrite existing/i }),
);
// Open the select to load existing-dataset options
await userEvent.click(
screen.getByRole('combobox', { name: /existing dataset/i }),
);
// Advance timers to flush debounced fetches in AsyncSelect
await act(async () => {
jest.runAllTimers();
});
// Wait for the loading indicator to clear
await waitFor(() => {
const loading = screen.queryByText('Loading...');
expect(loading === null || !loading.checkVisibility()).toBe(true);
});
// Pick an existing dataset (use the listbox item, not the input mirror)
const options = await screen.findAllByText('coolest table 0');
await userEvent.click(options[1]);
// First overwrite click → confirmation screen
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
// Wait for the confirmation screen to render
await screen.findByText(/are you sure you want to overwrite this dataset/i);
// Second overwrite click → triggers the PUT
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
};
test('sends template_params when overwriting a dataset with include template parameters checked', async () => {
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Check the "Include Template Parameters" checkbox
await userEvent.click(screen.getByRole('checkbox'));
await setupOverwriteFlow();
await waitFor(() => {
expect(
putSpy.mock.calls.some(([req]) =>
req.endpoint?.includes('api/v1/dataset/'),
),
).toBe(true);
});
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
req.endpoint?.includes('api/v1/dataset/'),
)!;
const [req] = datasetPutCall;
expect(req.endpoint).toContain('override_columns=true');
const body = JSON.parse(req.body as string);
// _filters should be stripped, but my_param should be preserved
expect(body.template_params).toEqual(JSON.stringify({ my_param: 12 }));
putSpy.mockRestore();
});
test('does not send template_params when overwriting a dataset with include template parameters unchecked', async () => {
// @ts-expect-error
global.featureFlags = {
[FeatureFlag.EnableTemplateProcessing]: true,
};
const putSpy = jest
.spyOn(SupersetClient, 'put')
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
const dummyDispatch = jest.fn().mockResolvedValue({});
useDispatchMock.mockReturnValue(dummyDispatch);
useSelectorMock.mockReturnValue({ ...user });
const propsWithTemplateParam = {
...mockedProps,
datasource: {
...testQuery,
templateParams: JSON.stringify({ my_param: 12 }),
},
};
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
useRedux: true,
});
// Do NOT check the "Include Template Parameters" checkbox
await setupOverwriteFlow();
await waitFor(() => {
expect(
putSpy.mock.calls.some(([req]) =>
req.endpoint?.includes('api/v1/dataset/'),
),
).toBe(true);
});
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
req.endpoint?.includes('api/v1/dataset/'),
)!;
const [req] = datasetPutCall;
const body = JSON.parse(req.body as string);
expect(body.template_params).toBeUndefined();
putSpy.mockRestore();
});
test('clears dataset cache when creating new dataset', async () => {
const clearDatasetCache = jest.spyOn(
require('src/utils/cachedSupersetGet'),

View File

@@ -149,14 +149,25 @@ const Styles = styled.div`
}
`}
`;
const updateDataset = async (
dbId: number,
datasetId: number,
sql: string,
columns: Array<Record<string, any>>,
owners: [number],
overrideColumns: boolean,
) => {
type UpdateDatasetPayload = {
dbId: number;
datasetId: number;
sql: string;
columns: Array<Record<string, any>>;
owners: number[];
overrideColumns: boolean;
templateParams?: string;
};
const updateDataset = async ({
dbId,
datasetId,
sql,
columns,
owners,
overrideColumns,
templateParams,
}: UpdateDatasetPayload) => {
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
const headers = { 'Content-Type': 'application/json' };
const body = JSON.stringify({
@@ -164,6 +175,7 @@ const updateDataset = async (
columns,
owners,
database_id: dbId,
...(templateParams !== undefined && { template_params: templateParams }),
});
const data: JsonResponse = await SupersetClient.put({
@@ -179,6 +191,26 @@ const updateDataset = async (
const UNTITLED = t('Untitled Dataset');
// The filters param is only used to test jinja templates.
// Remove the special filters entry from the templateParams
// before saving the dataset.
const sanitizeTemplateParams = (
templateParams: string | object | null | undefined,
): string | undefined => {
if (typeof templateParams !== 'string') {
return undefined;
}
try {
const parsed = JSON.parse(templateParams) as Record<string, unknown>;
// Remove the special _filters entry — it is only used to test jinja templates.
const { _filters: _ignored, ...clean } = parsed;
return JSON.stringify(clean);
} catch (e) {
// malformed templateParams, do not include it
return undefined;
}
};
export const SaveDatasetModal = ({
visible,
onHide,
@@ -232,22 +264,27 @@ export const SaveDatasetModal = ({
}
setLoading(true);
const templateParams = includeTemplateParameters
? sanitizeTemplateParams(datasource?.templateParams)
: undefined;
try {
const [, key] = await Promise.all([
updateDataset(
datasource?.dbId,
datasetToOverwrite?.datasetid,
datasource?.sql,
datasource?.columns?.map(
updateDataset({
dbId: datasource?.dbId,
datasetId: datasetToOverwrite?.datasetid,
sql: datasource?.sql,
columns: datasource?.columns?.map(
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
column_name: d.column_name,
type: d.type,
is_dttm: d.is_dttm,
}),
),
datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
true,
),
owners: datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
overrideColumns: true,
templateParams,
}),
postFormData(datasetToOverwrite.datasetid, 'table', {
...formDataWithDefaults,
datasource: `${datasetToOverwrite.datasetid}__table`,
@@ -319,27 +356,9 @@ export const SaveDatasetModal = ({
setLoading(true);
const selectedColumns = datasource?.columns ?? [];
// The filters param is only used to test jinja templates.
// Remove the special filters entry from the templateParams
// before saving the dataset.
let templateParams;
if (
typeof datasource?.templateParams === 'string' &&
includeTemplateParameters
) {
try {
const p = JSON.parse(datasource.templateParams);
/* eslint-disable-next-line no-underscore-dangle */
if (p._filters) {
/* eslint-disable-next-line no-underscore-dangle */
delete p._filters;
}
templateParams = JSON.stringify(p);
} catch (e) {
// malformed templateParams, do not include it
templateParams = undefined;
}
}
const templateParams = includeTemplateParameters
? sanitizeTemplateParams(datasource?.templateParams)
: undefined;
dispatch(
createDatasource({

View File

@@ -18,13 +18,13 @@
*/
import {
useCallback,
useDeferredValue,
useEffect,
useState,
useRef,
type ChangeEvent,
useMemo,
} from 'react';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
import { styled, css, useTheme } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
@@ -314,7 +314,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
}, [sortedTreeData, sortedTables]);
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounceValue(searchTerm);
const deferredSearchTerm = useDeferredValue(searchTerm);
const handleSearchChange = useCallback(
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
[],
@@ -372,9 +372,9 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
// Check if any nodes match the search term
const hasMatchingNodes = useMemo(() => {
if (!debouncedSearchTerm) return true;
if (!deferredSearchTerm) return true;
const lowerTerm = debouncedSearchTerm.toLowerCase();
const lowerTerm = deferredSearchTerm.toLowerCase();
const checkNode = (node: TreeNodeData): boolean => {
if (node.type === 'empty') return false;
@@ -386,7 +386,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
};
return displayTreeData.some(node => checkNode(node));
}, [debouncedSearchTerm, displayTreeData]);
}, [deferredSearchTerm, displayTreeData]);
// Node renderer for react-arborist
const renderNode = useCallback(
@@ -395,7 +395,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
{...props}
manuallyOpenedNodes={manuallyOpenedNodes}
loadingNodes={loadingNodes}
searchTerm={debouncedSearchTerm}
searchTerm={deferredSearchTerm}
catalog={catalog}
pinnedTableKeys={pinnedTableKeys}
pinnedSchemas={pinnedSchemas}
@@ -425,7 +425,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
toggleSortColumns,
loadingNodes,
manuallyOpenedNodes,
debouncedSearchTerm,
deferredSearchTerm,
],
);
@@ -484,7 +484,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
return <Skeleton active />;
}
if (debouncedSearchTerm && !hasMatchingNodes) {
if (deferredSearchTerm && !hasMatchingNodes) {
return (
<Empty
description={t('No matching results found')}
@@ -501,7 +501,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
height={height || 500}
rowHeight={ROW_HEIGHT}
indent={16}
searchTerm={debouncedSearchTerm}
searchTerm={deferredSearchTerm}
searchMatch={searchMatch}
disableDrag
disableDrop
@@ -527,7 +527,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
// react-arborist marks all schemas as open (isOpen=true) even before any
// user interaction. Using treeRef in that case would treat every first
// click as a close action, so fall back to manuallyOpenedNodes instead.
const wasOpen = debouncedSearchTerm
const wasOpen = deferredSearchTerm
? (treeRef.current?.get(id)?.isOpen ??
manuallyOpenedNodes[id] ??
false)

View File

@@ -191,6 +191,8 @@ export function DatabaseSelector({
}: DatabaseSelectorProps) {
const showCatalogSelector = !!db?.allow_multi_catalog;
const [currentDb, setCurrentDb] = useState<DatabaseValue | undefined>();
const showSchemaSelector =
(db?.supports_schemas ?? currentDb?.supports_schemas) !== false;
const [errorPayload, setErrorPayload] = useState<SupersetError | null>();
const [currentCatalog, setCurrentCatalog] = useState<
CatalogOption | null | undefined
@@ -260,6 +262,12 @@ export function DatabaseSelector({
database_name: row.database_name,
backend: row.backend,
allow_multi_catalog: row.allow_multi_catalog,
supports_schemas:
(
row as DatabaseObject & {
engine_information?: { supports_schemas?: boolean };
}
).engine_information?.supports_schemas !== false,
order,
}));
@@ -597,7 +605,7 @@ export function DatabaseSelector({
{renderDatabaseSelect()}
{renderError()}
{showCatalogSelector && renderCatalogSelect()}
{renderSchemaSelect()}
{showSchemaSelector && renderSchemaSelect()}
</DatabaseSelectorWrapper>
);
}

View File

@@ -24,6 +24,7 @@ export type DatabaseValue = {
id: number;
database_name: string;
backend?: string;
supports_schemas?: boolean;
};
export type DatabaseObject = {
@@ -31,6 +32,7 @@ export type DatabaseObject = {
database_name: string;
backend?: string;
allow_multi_catalog?: boolean;
supports_schemas?: boolean;
};
export interface DatabaseSelectorProps {

View File

@@ -260,6 +260,52 @@ test('table multi select retain all the values selected', async () => {
expect(selections[1]).toHaveTextContent('table_c');
});
test('calls onTableSelectChange for schema-less database without schema', async () => {
fetchMock.get(catalogApiRoute, { result: [] });
fetchMock.get(schemaApiRoute, { result: [] });
fetchMock.get(tablesApiRoute, getTableMockFunction());
const callback = jest.fn();
const props = createProps({
database: {
id: 1,
database_name: 'ydb',
backend: 'ydb',
supports_schemas: false,
},
schema: undefined,
onTableSelectChange: callback,
});
render(<TableSelector {...props} />, { useRedux: true, store });
const tableSelect = screen.getByRole('combobox', {
name: 'Select table or type to search tables',
});
await act(async () => {
await userEvent.click(tableSelect);
});
await waitFor(
() => {
expect(screen.getByText('table_a')).toBeInTheDocument();
},
{ timeout: 10000 },
);
await act(async () => {
await userEvent.click(screen.getByText('table_a'));
});
await waitFor(
() => {
expect(callback).toHaveBeenCalled();
},
{ timeout: 10000 },
);
}, 15000);
test('TableOption renders correct icons for different table types', () => {
// Test regular table
const tableTable = {

View File

@@ -190,6 +190,7 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
dbId: database?.id,
catalog: currentCatalog,
schema: currentSchema,
supportsSchemas: database?.supports_schemas,
onSuccess: (data, isFetched) => {
setErrorPayload(null);
if (isFetched) {
@@ -247,7 +248,8 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
const internalTableChange = (
selectedOptions: TableOption | TableOption[] | undefined,
) => {
if (currentSchema) {
setTableSelectValue(selectedOptions);
if (currentSchema || database?.supports_schemas === false) {
onTableSelectChange?.(
Array.isArray(selectedOptions)
? selectedOptions.map(option => option?.value)
@@ -255,8 +257,6 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
currentCatalog,
currentSchema,
);
} else {
setTableSelectValue(selectedOptions);
}
};
@@ -302,7 +302,8 @@ const TableSelector: FunctionComponent<TableSelectorProps> = ({
);
function renderTableSelect() {
const disabled = (currentSchema && !formMode && readOnly) || !currentSchema;
const disabled =
readOnly || (database?.supports_schemas !== false && !currentSchema);
const label = t('Table');

View File

@@ -67,7 +67,7 @@ interface DashboardActions {
setDatasources: (datasources: unknown) => void;
}
interface DashboardProps {
export 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';
interface DashboardGridProps {
export 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();
jest.useFakeTimers({ advanceTimers: true });
onRefresh.mockResolvedValue(undefined);
const originalRequestAnimationFrame = window.requestAnimationFrame;

View File

@@ -763,6 +763,123 @@ test('Should show row count warning for table chart with server pagination when
mockUseUiConfig.mockRestore();
});
test('Should show row count warning for non-table chart when row limit is reached', () => {
const props = createProps({
formData: {
...createProps().formData,
viz_type: VizType.Bar,
row_limit: 10,
},
slice: {
...createProps().slice,
form_data: {
...createProps().slice.form_data,
viz_type: VizType.Bar,
row_limit: 10,
},
viz_type: VizType.Bar,
},
});
const barChartState = {
...initialState,
charts: {
[props.slice.slice_id]: {
id: MOCKED_CHART_ID,
chartStatus: 'rendered',
queriesResponse: [
{
sql_rowcount: 10,
data: Array(10).fill({}),
},
],
},
},
};
const mockUseUiConfig = useUiConfig as jest.MockedFunction<
typeof useUiConfig
>;
mockUseUiConfig.mockReturnValue({
hideTitle: false,
hideTab: false,
hideNav: false,
hideChartControls: false,
emitDataMasks: false,
showRowLimitWarning: true,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState: barChartState,
});
expect(screen.getByTestId('warning')).toBeInTheDocument();
mockUseUiConfig.mockRestore();
});
test('Should show row count warning for ag-grid table chart with server pagination when limit is reached', () => {
const props = createProps({
formData: {
...createProps().formData,
viz_type: VizType.TableAgGrid,
row_limit: 10,
server_pagination: true,
},
slice: {
...createProps().slice,
form_data: {
...createProps().slice.form_data,
viz_type: VizType.TableAgGrid,
row_limit: 10,
server_pagination: true,
},
viz_type: VizType.TableAgGrid,
},
});
const agGridWithPaginationState = {
...initialState,
charts: {
[props.slice.slice_id]: {
id: MOCKED_CHART_ID,
chartStatus: 'rendered',
queriesResponse: [
{
sql_rowcount: 10,
data: Array(10).fill({}),
},
{
data: [{ rowcount: 50 }],
},
],
},
},
};
const mockUseUiConfig = useUiConfig as jest.MockedFunction<
typeof useUiConfig
>;
mockUseUiConfig.mockReturnValue({
hideTitle: false,
hideTab: false,
hideNav: false,
hideChartControls: false,
emitDataMasks: false,
showRowLimitWarning: true,
});
render(<SliceHeader {...props} />, {
useRedux: true,
useRouter: true,
initialState: agGridWithPaginationState,
});
expect(screen.getByTestId('warning')).toBeInTheDocument();
mockUseUiConfig.mockRestore();
});
test('Should NOT show row count warning for table chart with server pagination when limit is NOT reached', () => {
const props = createProps({
formData: {

View File

@@ -26,7 +26,7 @@ import {
useState,
} from 'react';
import { t } from '@apache-superset/core/translation';
import { getExtensionsRegistry, QueryData } from '@superset-ui/core';
import { getExtensionsRegistry, QueryData, VizType } from '@superset-ui/core';
import {
css,
styled,
@@ -206,9 +206,12 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
const rowLimit = Number(formData.row_limit ?? 0);
const isTableChart = formData.viz_type === 'table';
const countFromSecondQuery =
isTableChart && secondQueryResponse?.data?.[0]?.rowcount;
const isTableChart =
formData.viz_type === VizType.Table ||
formData.viz_type === VizType.TableAgGrid;
const countFromSecondQuery = isTableChart
? secondQueryResponse?.data?.[0]?.rowcount
: undefined;
const sqlRowCount =
countFromSecondQuery != null

View File

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

View File

@@ -27,6 +27,7 @@ import {
RefObject,
} from 'react';
import type { ChartCustomization, JsonObject } from '@superset-ui/core';
import { VizType } from '@superset-ui/core';
import { styled } from '@apache-superset/core/theme';
import { t } from '@apache-superset/core/translation';
import { debounce } from 'lodash';
@@ -495,7 +496,9 @@ const Chart = (props: ChartProps) => {
const resultType = isPivot ? 'post_processed' : 'full';
let actualRowCount: number | undefined;
const isTableViz = (formData as JsonObject)?.viz_type === 'table';
const vizType = (formData as JsonObject)?.viz_type;
const isTableViz =
vizType === VizType.Table || vizType === VizType.TableAgGrid;
if (
isTableViz &&

View File

@@ -43,7 +43,7 @@ import {
export const CHART_MARGIN = 32;
interface ChartHolderProps {
export 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';
interface ColumnProps {
export interface ColumnProps {
id: string;
parentId: string;
component: LayoutItem;

View File

@@ -43,13 +43,13 @@ import {
GRID_BASE_UNIT,
} from 'src/dashboard/util/constants';
interface EditorInstance {
export interface EditorInstance {
resize?: (force: boolean) => void;
getSession?: () => { setUseWrapMode: (wrap: boolean) => void };
focus?: () => void;
}
interface MarkdownOwnProps {
export interface MarkdownOwnProps {
id: string;
parentId: string;
component: LayoutItem;
@@ -71,7 +71,7 @@ interface MarkdownOwnProps {
updateComponents: (components: Record<string, LayoutItem>) => void;
}
interface MarkdownStateProps {
export interface MarkdownStateProps {
logEvent: (eventName: string, eventData: JsonObject) => void;
addDangerToast: (msg: string) => void;
undoLength: number;
@@ -80,9 +80,9 @@ interface MarkdownStateProps {
htmlSchemaOverrides?: JsonObject;
}
type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
export type MarkdownProps = MarkdownOwnProps & MarkdownStateProps;
interface MarkdownState {
export 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;
interface TabProps {
export 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';
interface TabsProps {
export 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();
jest.useFakeTimers({ advanceTimers: true });
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
@@ -486,7 +486,7 @@ test('FilterBar renders correctly when filter has complete extraFormData', async
expect(screen.getByTestId(getTestId('filter-icon'))).toBeInTheDocument();
});
test('handleClearAll dispatches updateDataMask with value undefined for filter_select', async () => {
test('Clear All stages filter_select clear without dispatching until Apply', async () => {
const filterId = 'NATIVE_FILTER-clear-select';
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
const selectFilter = createFilter({
@@ -513,7 +513,9 @@ test('handleClearAll dispatches updateDataMask with value undefined for filter_s
activeTabs: ['ROOT_ID'],
},
dataMask: {
[filterId]: createDataMask(filterId, ['East']),
[filterId]: createDataMask(filterId, ['East'], {
filters: [{ col: 'region', op: 'IN', val: ['East'] }],
}),
},
nativeFilters: {
filters: { [filterId]: selectFilter },
@@ -533,14 +535,24 @@ test('handleClearAll dispatches updateDataMask with value undefined for filter_s
userEvent.click(clearBtn);
});
// Clear All must not dispatch — staging only
expect(updateDataMaskSpy).not.toHaveBeenCalled();
// Apply commits the staged clear
const applyBtn = screen.getByTestId(getTestId('apply-button'));
expect(applyBtn).not.toBeDisabled();
await act(async () => {
userEvent.click(applyBtn);
});
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
filterState: { value: undefined },
id: filterId,
filterState: { value: undefined, validateStatus: undefined },
extraFormData: {},
});
updateDataMaskSpy.mockRestore();
});
test('handleClearAll dispatches updateDataMask with [null, null] for filter_range', async () => {
test('Clear All stages filter_range clear with [null, null], dispatched on Apply', async () => {
fetchMock.post('glob:*/api/v1/chart/data', {
result: [{ data: [{ min: 0, max: 100 }] }],
});
@@ -570,7 +582,9 @@ test('handleClearAll dispatches updateDataMask with [null, null] for filter_rang
activeTabs: ['ROOT_ID'],
},
dataMask: {
[filterId]: createDataMask(filterId, [10, 50]),
[filterId]: createDataMask(filterId, [10, 50], {
filters: [{ col: 'age', op: '>=', val: 10 }],
}),
},
nativeFilters: {
filters: { [filterId]: rangeFilter },
@@ -590,14 +604,21 @@ test('handleClearAll dispatches updateDataMask with [null, null] for filter_rang
userEvent.click(clearBtn);
});
expect(updateDataMaskSpy).not.toHaveBeenCalled();
const applyBtn = screen.getByTestId(getTestId('apply-button'));
await act(async () => {
userEvent.click(applyBtn);
});
expect(updateDataMaskSpy).toHaveBeenCalledWith(filterId, {
filterState: { value: [null, null] },
id: filterId,
filterState: { value: [null, null], validateStatus: undefined },
extraFormData: {},
});
updateDataMaskSpy.mockRestore();
});
test('handleClearAll only dispatches for filters present in dataMask', async () => {
test('Clear All + Apply only dispatches for filters present in dataMask', async () => {
const idInMask = 'NATIVE_FILTER-has-value';
const idNotInMask = 'NATIVE_FILTER-no-value';
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
@@ -631,7 +652,9 @@ test('handleClearAll only dispatches for filters present in dataMask', async ()
activeTabs: ['ROOT_ID'],
},
dataMask: {
[idInMask]: createDataMask(idInMask, ['v']),
[idInMask]: createDataMask(idInMask, ['v'], {
filters: [{ col: 'x', op: 'IN', val: ['v'] }],
}),
},
nativeFilters: {
filters: {
@@ -652,10 +675,16 @@ test('handleClearAll only dispatches for filters present in dataMask', async ()
await act(async () => {
userEvent.click(clearBtn);
});
expect(updateDataMaskSpy).not.toHaveBeenCalled();
const applyBtn = screen.getByTestId(getTestId('apply-button'));
await act(async () => {
userEvent.click(applyBtn);
});
expect(updateDataMaskSpy).toHaveBeenCalledTimes(1);
expect(updateDataMaskSpy).toHaveBeenCalledWith(idInMask, {
filterState: { value: undefined },
id: idInMask,
filterState: { value: undefined, validateStatus: undefined },
extraFormData: {},
});
updateDataMaskSpy.mockRestore();
@@ -790,18 +819,86 @@ test('FilterBar Clear All only clears in-scope filters, not out-of-scope ones',
await act(async () => {
userEvent.click(clearButton);
});
expect(updateDataMaskSpy).not.toHaveBeenCalled();
// Verify only the in-scope filter was cleared, not the out-of-scope ones
const clearedFilterIds = updateDataMaskSpy.mock.calls.map(call => call[0]);
expect(clearedFilterIds).toContain(inScopeFilterId);
expect(clearedFilterIds).not.toContain(outOfScopeRequiredFilterId);
expect(clearedFilterIds).not.toContain(outOfScopeNonRequiredFilterId);
// After Apply: only the in-scope filter was cleared. Out-of-scope filters
// retain their original values (Apply re-dispatches them unchanged).
const applyButton = screen.getByTestId(getTestId('apply-button'));
await act(async () => {
userEvent.click(applyButton);
});
// Verify the in-scope filter was cleared with the correct value
expect(updateDataMaskSpy).toHaveBeenCalledWith(inScopeFilterId, {
filterState: { value: undefined },
id: inScopeFilterId,
filterState: { value: undefined, validateStatus: undefined },
extraFormData: {},
});
// Out-of-scope filters keep their existing values; not cleared
const outOfScopeRequiredCall = updateDataMaskSpy.mock.calls.find(
call => call[0] === outOfScopeRequiredFilterId,
);
expect(outOfScopeRequiredCall?.[1]?.filterState?.value).toEqual(['value2']);
const outOfScopeNonRequiredCall = updateDataMaskSpy.mock.calls.find(
call => call[0] === outOfScopeNonRequiredFilterId,
);
expect(outOfScopeNonRequiredCall?.[1]?.filterState?.value).toEqual([
'value3',
]);
updateDataMaskSpy.mockRestore();
});
test('Clear All on a required filter disables Apply via validateStatus', async () => {
const filterId = 'NATIVE_FILTER-required-clear';
const updateDataMaskSpy = jest.spyOn(dataMaskActions, 'updateDataMask');
const requiredFilter = createFilter({
id: filterId,
name: 'Required Region',
filterType: 'filter_select',
targets: [{ datasetId: 7, column: { name: 'region' } }],
controlValues: { enableEmptyFilter: true },
chartsInScope: [18],
});
const state = {
...stateWithoutNativeFilters,
dashboardInfo: {
id: 1,
dash_edit_perm: true,
filterBarOrientation: FilterBarOrientation.Vertical,
metadata: {
native_filter_configuration: [requiredFilter],
chart_configuration: {},
},
},
dashboardState: {
...stateWithoutNativeFilters.dashboardState,
activeTabs: ['ROOT_ID'],
},
dataMask: {
[filterId]: createDataMask(filterId, ['East'], {
filters: [{ col: 'region', op: 'IN', val: ['East'] }],
}),
},
nativeFilters: {
filters: { [filterId]: requiredFilter },
filtersState: {},
},
};
const props = createOpenedBarProps();
renderFilterBar(props, state);
await act(async () => {
jest.advanceTimersByTime(300);
});
const clearBtn = screen.getByTestId(getTestId('clear-button'));
await act(async () => {
userEvent.click(clearBtn);
});
// No dispatch yet; Apply should be disabled because the required filter is empty
expect(updateDataMaskSpy).not.toHaveBeenCalled();
expect(screen.getByTestId(getTestId('apply-button'))).toBeDisabled();
updateDataMaskSpy.mockRestore();
});

View File

@@ -498,17 +498,20 @@ const FilterBar: FC<FiltersBarProps> = ({
// Range filters use [null, null] as the cleared value; others use undefined
const clearedValue =
filterType === 'filter_range' ? [null, null] : undefined;
const clearedDataMask = {
filterState: { value: clearedValue },
extraFormData: {},
};
const isRequired = !!filter.controlValues?.enableEmptyFilter;
if (dataMaskSelected[id]) {
dispatch(updateDataMask(id, clearedDataMask));
// Stage the cleared value locally; do NOT dispatch to Redux here.
// Persistence happens when the user clicks Apply.
setDataMaskSelected(draft => {
if (draft[id].filterState?.value !== undefined) {
draft[id].filterState!.value = clearedValue;
}
draft[id].extraFormData = {};
if (draft[id].filterState) {
draft[id].filterState!.validateStatus = isRequired
? 'error'
: undefined;
}
});
newClearAllTriggers[id] = true;
}

View File

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

View File

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

View File

@@ -30,7 +30,7 @@ import { buildActiveFilters } from '../util/activeDashboardFilters';
import { getChartIdAndColumnFromFilterKey } from '../util/getDashboardFilterKey';
import { LayoutItem } from '../types';
interface FilterScope {
export interface FilterScope {
scope: string[];
immune: number[];
}

View File

@@ -57,7 +57,7 @@ interface DashboardInfoAction {
[key: string]: unknown;
}
interface HydrateDashboardAction {
export interface HydrateDashboardInfoAction {
type: typeof HYDRATE_DASHBOARD;
data: {
dashboardInfo: DashboardInfo;
@@ -65,7 +65,9 @@ interface HydrateDashboardAction {
};
}
type DashboardInfoReducerAction = DashboardInfoAction | HydrateDashboardAction;
type DashboardInfoReducerAction =
| DashboardInfoAction
| HydrateDashboardInfoAction;
type DashboardInfoState = Partial<DashboardInfo> & {
last_modified_time?: number;
@@ -74,7 +76,7 @@ type DashboardInfoState = Partial<DashboardInfo> & {
function isHydrateAction(
action: DashboardInfoReducerAction,
): action is HydrateDashboardAction {
): action is HydrateDashboardInfoAction {
return action.type === HYDRATE_DASHBOARD;
}

View File

@@ -66,7 +66,7 @@ interface DashboardMetadata {
chart_customization_config?: ChartCustomization[];
}
interface HydrateDashboardAction {
export interface HydrateDataMaskAction {
type: typeof HYDRATE_DASHBOARD;
data: {
dashboardInfo: {
@@ -199,7 +199,7 @@ function updateDataMaskForFilterChanges(
const dataMaskReducer = produce(
(
draft: DataMaskStateWithId,
action: AnyDataMaskAction | HydrateDashboardAction | HydrateExplore,
action: AnyDataMaskAction | HydrateDataMaskAction | HydrateExplore,
) => {
const cleanState: DataMaskStateWithId = {};
switch (action.type) {
@@ -213,7 +213,7 @@ const dataMaskReducer = produce(
};
return draft;
case HYDRATE_DASHBOARD: {
const hydrateDashboardAction = action as HydrateDashboardAction;
const hydrateDashboardAction = action as HydrateDataMaskAction;
const metadata = hydrateDashboardAction.data.dashboardInfo?.metadata;
const loadedDataMask = hydrateDashboardAction.data.dataMask;

View File

@@ -18,7 +18,7 @@
*/
import 'src/public-path';
import { lazy, Suspense } from 'react';
import { lazy, StrictMode, Suspense, useEffect } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { BrowserRouter as Router, Route } from 'react-router-dom';
import { Global } from '@emotion/react';
@@ -66,20 +66,21 @@ const LazyDashboardPage = lazy(
),
);
const EmbededLazyDashboardPage = () => {
const EmbeddedLazyDashboardPage = () => {
const uiConfig = useUiConfig();
const emitDataMasks = uiConfig?.emitDataMasks;
// Emit data mask changes to the parent window
if (uiConfig?.emitDataMasks) {
// Emit data mask changes to the parent window. Subscribing inside an effect
// (rather than during render) ensures the unsubscribe runs on unmount,
// including StrictMode's dev-mode double-mount cycle.
useEffect(() => {
if (!emitDataMasks) return undefined;
log('setting up Switchboard event emitter');
let previousDataMask = store.getState().dataMask;
store.subscribe(() => {
const currentState = store.getState();
const currentDataMask = currentState.dataMask;
// Only emit if the dataMask has changed
return store.subscribe(() => {
const currentDataMask = store.getState().dataMask;
if (previousDataMask !== currentDataMask) {
Switchboard.emit('observeDataMask', {
...currentDataMask,
@@ -88,7 +89,7 @@ const EmbededLazyDashboardPage = () => {
previousDataMask = currentDataMask;
}
});
}
}, [emitDataMasks]);
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
};
@@ -107,7 +108,7 @@ const EmbeddedRoute = () => (
/>
<Suspense fallback={<Loading />}>
<ErrorBoundary>
<EmbededLazyDashboardPage />
<EmbeddedLazyDashboardPage />
</ErrorBoundary>
<ToastContainer position="top" />
</Suspense>
@@ -196,7 +197,11 @@ function start() {
if (!root) {
root = createRoot(appMountPoint);
}
root.render(<EmbeddedApp />);
root.render(
<StrictMode>
<EmbeddedApp />
</StrictMode>,
);
},
err => {
// something is most likely wrong with the guest token; reset the guard

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { forwardRef, RefObject } from 'react';
import { QueryData } from '@superset-ui/core';
import { QueryData, VizType } from '@superset-ui/core';
import { css, SupersetTheme } from '@apache-superset/core/theme';
import {
CachedLabel,
@@ -68,7 +68,9 @@ export const ChartPills = forwardRef(
const firstQueryResponse = queriesResponse?.[0];
// For table charts with server pagination, check second query for total count
const isTableChart = formData?.viz_type === 'table';
const isTableChart =
formData?.viz_type === VizType.Table ||
formData?.viz_type === VizType.TableAgGrid;
const hasCountQuery = queriesResponse && queriesResponse.length > 1;
const countFromSecondQuery = hasCountQuery
? queriesResponse[1]?.data?.[0]?.rowcount

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useContext, useMemo, useState } from 'react';
import { useContext, useDeferredValue, useMemo, useState } from 'react';
import { t } from '@apache-superset/core/translation';
import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
import { Alert } from '@apache-superset/core/components';
@@ -26,12 +26,11 @@ import { ControlConfig } from '@superset-ui/chart-controls';
import AutoSizer from 'react-virtualized-auto-sizer';
import { matchSorter, rankings } from 'match-sorter';
import { Constants, Input } from '@superset-ui/core/components';
import { Input } from '@superset-ui/core/components';
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { ExploreActions } from 'src/explore/actions/exploreActions';
import Control from 'src/explore/components/Control';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import { DndItemType } from '../DndItemType';
import { DatasourceFolder, DatasourcePanelColumn, DndItemValue } from './types';
import { DropzoneContext } from '../ExploreContainer';
@@ -160,7 +159,7 @@ export default function DataSourcePanel({
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
const [inputValue, setInputValue] = useState('');
const searchKeyword = useDebounceValue(inputValue, Constants.FAST_DEBOUNCE);
const searchKeyword = useDeferredValue(inputValue);
const filteredColumns = useMemo(() => {
if (!searchKeyword) {

View File

@@ -50,7 +50,7 @@ interface CollectionItem {
[key: string]: unknown;
}
interface CollectionControlProps {
export interface CollectionControlProps {
name: string;
label?: string | null;
description?: string | null;

View File

@@ -94,7 +94,7 @@ interface FormData {
[key: string]: unknown;
}
interface DatasourceControlProps {
export interface DatasourceControlProps {
actions: DatasourceControlActions;
onChange?: () => void;
value?: string | null;

View File

@@ -28,7 +28,7 @@ import {
import { CustomFrame } from '../components';
const TODAY = '2024-06-03';
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
jest.setSystemTime(new Date(TODAY).getTime());
const emptyValue = '';

View File

@@ -69,7 +69,7 @@ interface Datasource {
[key: string]: unknown;
}
interface AdhocFilterControlProps {
export interface AdhocFilterControlProps {
label?: ReactNode;
name?: string;
sections?: string[];

View File

@@ -45,10 +45,6 @@ jest.mock('src/core/editors', () => ({
),
}));
jest.mock('src/hooks/useDebounceValue', () => ({
useDebounceValue: (value: string) => value,
}));
const defaultProps = {
name: 'echartOptions',
label: 'EChart Options',

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { useDeferredValue, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import ControlHeader, {
ControlHeaderProps,
@@ -28,7 +28,6 @@ import {
EChartOptionsParseError,
} from '@superset-ui/plugin-chart-echarts';
import { EditorHost } from 'src/core/editors';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
const Container = styled.div`
border: 1px solid ${({ theme }) => theme.colorBorder};
@@ -50,10 +49,10 @@ export default function JSEditorControl({
onChange,
value,
}: ControlHeaderProps & ControlComponentProps<string>) {
const debouncedValue = useDebounceValue(value);
const deferredValue = useDeferredValue(value);
const error = useMemo(() => {
try {
safeParseEChartOptions(debouncedValue ?? '');
safeParseEChartOptions(deferredValue ?? '');
return null;
} catch (err) {
if (err instanceof EChartOptionsParseError) {
@@ -61,7 +60,7 @@ export default function JSEditorControl({
}
throw err;
}
}, [debouncedValue]);
}, [deferredValue]);
const headerProps = {
name,
label: label ?? name,

View File

@@ -108,7 +108,7 @@ const getMetricsMatchingCurrentDataset = (
);
});
interface MetricsControlProps {
export interface MetricsControlProps {
name: string;
onChange: (value: unknown) => void;
multi?: boolean;

View File

@@ -45,7 +45,7 @@ import VizTypeControl, { VIZ_TYPE_CONTROL_TEST_ID } from './index';
// Mock scrollIntoView to avoid errors in test environment
jest.mock('scroll-into-view-if-needed', () => jest.fn());
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
class MainPreset extends Preset {
constructor() {

View File

@@ -19,13 +19,13 @@
import React, {
ReactElement,
useCallback,
useDeferredValue,
useMemo,
useState,
Dispatch,
SetStateAction,
} from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useDebounceValue } from 'src/hooks/useDebounceValue';
import {
isFeatureEnabled,
FeatureFlag,
@@ -213,10 +213,7 @@ export const useExploreAdditionalActionsMenu = (
const dispatch = useDispatch();
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
const [dashboardSearchTerm, setDashboardSearchTerm] = useState('');
const debouncedDashboardSearchTerm = useDebounceValue(
dashboardSearchTerm,
300,
);
const deferredDashboardSearchTerm = useDeferredValue(dashboardSearchTerm);
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
);
@@ -304,7 +301,7 @@ export const useExploreAdditionalActionsMenu = (
const dashboardMenuItems = useDashboardsMenuItems({
chartId: slice?.slice_id,
dashboards,
searchTerm: debouncedDashboardSearchTerm,
searchTerm: deferredDashboardSearchTerm,
});
const showDashboardSearch = (dashboards?.length ?? 0) > SEARCH_THRESHOLD;
@@ -1054,7 +1051,7 @@ export const useExploreAdditionalActionsMenu = (
dashboards,
dashboardMenuItems,
dashboardSearchTerm,
debouncedDashboardSearchTerm,
deferredDashboardSearchTerm,
datasource,
dispatch,
exportCSV,

View File

@@ -293,10 +293,17 @@ export const StyledInputContainer = styled.div`
`}
`;
// Named-reference type annotation: TypeScript 6.0 declaration emit (TS2883)
// won't let us leak react-ace's IAceOptions/ICommand/IEditorProps/IMarker
// through the inferred type because they live in @superset-ui/core's nested
// node_modules and aren't portable. Aliasing to `typeof JsonEditor` emits a
// named reference in the .d.ts instead of the expanded structural type.
// The styled-components and ForwardRefExoticComponent shapes don't overlap
// structurally, so we bounce through `unknown` to widen the cast.
export const StyledJsonEditor = styled(JsonEditor)`
flex: 1 1 auto;
/* Border is already applied by AceEditor itself */
`;
` as unknown as typeof JsonEditor;
export const StyledExpandableForm = styled.div`
padding-top: ${({ theme }) => theme.sizeUnit}px;

View File

@@ -0,0 +1,59 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from 'spec/helpers/testing-library';
import { SupersetClient } from '@superset-ui/core';
import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) => (
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
afterEach(() => {
jest.restoreAllMocks();
});
test('fetches table metadata for schema-less database without schema', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
name: 'my_table',
columns: [{ name: 'id', type: 'INTEGER', longType: 'INTEGER' }],
},
} as any);
render(
<DatasetPanelWrapper
tableName="my_table"
dbId={1}
database={{ supports_schemas: false }}
/>,
{ useRouter: true },
);
await waitFor(() => {
expect(getSpy).toHaveBeenCalledWith(
expect.objectContaining({
endpoint: expect.stringContaining('/api/v1/database/1/table_metadata/'),
}),
);
});
});

View File

@@ -22,6 +22,7 @@ import { SupersetClient } from '@superset-ui/core';
import { logging } from '@apache-superset/core/utils';
import { DatasetObject } from 'src/features/datasets/AddDataset/types';
import { addDangerToast } from 'src/components/MessageToasts/actions';
import { type DatabaseObject } from 'src/components';
import { toQueryString } from 'src/utils/urlUtils';
import DatasetPanel from './DatasetPanel';
import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
@@ -39,9 +40,9 @@ interface IColumnProps {
*/
tableName: string;
/**
* Name of the schema
* Name of the schema (optional for databases that don't support schemas)
*/
schema: string;
schema?: string | null;
}
export interface IDatasetPanelWrapperProps {
@@ -58,6 +59,10 @@ export interface IDatasetPanelWrapperProps {
*/
catalog?: string | null;
schema?: string | null;
/**
* The selected database object (used to check engine capabilities)
*/
database?: Partial<DatabaseObject> | null;
setHasColumns?: Function;
datasets?: DatasetObject[] | undefined;
}
@@ -67,6 +72,7 @@ const DatasetPanelWrapper = ({
dbId,
catalog,
schema,
database,
setHasColumns,
datasets,
}: IDatasetPanelWrapperProps) => {
@@ -128,12 +134,13 @@ const DatasetPanelWrapper = ({
useEffect(() => {
tableNameRef.current = tableName;
if (tableName && schema && dbId) {
getTableMetadata({ tableName, dbId, schema });
const schemaRequired = database?.supports_schemas !== false;
if (tableName && dbId && (schema || !schemaRequired)) {
getTableMetadata({ tableName, dbId, schema: schema || undefined });
}
// getTableMetadata is a const and should not be in dependency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema]);
}, [tableName, dbId, schema, database]);
return (
<DatasetPanel

View File

@@ -358,6 +358,66 @@ test('useDatasetsList skips fetching when db.id is undefined', () => {
expect(result.current.datasetNames).toEqual([]);
});
test('useDatasetsList fetches datasets for schema-less databases without schema filter', async () => {
const schemalessDb = {
id: 2,
database_name: 'ydb',
owners: [1] as [number],
supports_schemas: false,
};
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
count: 1,
result: [{ id: 10, table_name: 'my_table', schema: null }],
},
} as unknown as JsonResponse);
const { result } = renderHook(() => useDatasetsList(schemalessDb, null));
await waitFor(() => {
expect(result.current.datasets).toHaveLength(1);
});
expect(result.current.datasetNames).toEqual(['my_table']);
expect(getSpy).toHaveBeenCalledTimes(1);
// Verify the API was called without a schema filter
const callArg = getSpy.mock.calls[0]?.[0]?.endpoint;
expect(callArg).toBeDefined();
const risonParam = new URL(callArg!, 'http://localhost').searchParams.get(
'q',
);
expect(risonParam).toBeTruthy();
const decoded = rison.decode(risonParam!) as {
filters: Array<{ col: string; opr: string; value: unknown }>;
};
// Only database filter and sql filter — no schema filter
const schemaFilter = decoded.filters.find(f => f.col === 'schema');
expect(schemaFilter).toBeUndefined();
const dbFilter = decoded.filters.find(f => f.col === 'database');
expect(dbFilter).toEqual({ col: 'database', opr: 'rel_o_m', value: 2 });
});
test('useDatasetsList skips fetching when schema-less database id is undefined', () => {
const getSpy = jest.spyOn(SupersetClient, 'get');
const schemalessDb = {
database_name: 'ydb',
owners: [1] as [number],
supports_schemas: false,
} as typeof mockDb & { supports_schemas: boolean };
const { result } = renderHook(() => useDatasetsList(schemalessDb, null));
// No db.id — should NOT call API even for schema-less DB
expect(getSpy).not.toHaveBeenCalled();
expect(result.current.datasets).toEqual([]);
});
test('useDatasetsList encodes schemas with spaces and special characters in endpoint URL', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { count: 0, result: [] },

View File

@@ -37,7 +37,8 @@ const useDatasetsList = (
schema: string | null | undefined,
) => {
const [datasets, setDatasets] = useState<DatasetObject[]>([]);
const encodedSchema = schema ? encodeURIComponent(schema) : undefined;
const supportsSchemas = db?.supports_schemas !== false;
const encodedSchema = schema ? encodeURIComponent(schema) : null;
const getDatasetsList = useCallback(async (filters: object[]) => {
let results: DatasetObject[] = [];
@@ -77,14 +78,16 @@ const useDatasetsList = (
useEffect(() => {
const filters = [
{ col: 'database', opr: 'rel_o_m', value: db?.id },
{ col: 'schema', opr: 'eq', value: encodedSchema },
...(supportsSchemas
? [{ col: 'schema', opr: 'eq', value: encodedSchema }]
: []),
{ col: 'sql', opr: 'dataset_is_null_or_empty', value: true },
];
if (schema && db?.id !== undefined) {
if (db?.id !== undefined && (schema || !supportsSchemas)) {
getDatasetsList(filters);
}
}, [db?.id, schema, encodedSchema, getDatasetsList]);
}, [db?.id, schema, encodedSchema, supportsSchemas, getDatasetsList]);
const datasetNames = useMemo(
() => datasets?.map(dataset => dataset.table_name),

View File

@@ -67,10 +67,13 @@ export const StyledScheduleTitle = styled.div`
}
`;
// Named-reference type annotation: TypeScript 6.0 declaration emit (TS2883)
// can't name CronProps from react-js-cron via its nested node_modules path.
// Aliasing to `typeof CronPicker` emits a named reference in the .d.ts.
export const StyledCronPicker = styled(CronPicker)`
margin-bottom: ${({ theme }) => theme.sizeUnit * 3}px;
width: ${({ theme }) => theme.sizeUnit * 120}px;
`;
` as typeof CronPicker;
export const StyledCronError = styled.p`
color: ${({ theme }) => theme.colorError};

View File

@@ -62,7 +62,7 @@ const props = {
beforeEach(() => {
mockJsonFormsChangeTriggered = false;
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
mockedGet.mockReset();
mockedPost.mockReset();

View File

@@ -37,7 +37,7 @@ import {
PluginFilterSelectQueryFormData,
} from './types';
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const selectMultipleProps = {
formData: {
@@ -1286,7 +1286,7 @@ test('resets dependent filter to first item when value does not exist in data',
});
test('renders text input instead of dropdown when operatorType is ILIKE contains', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1316,7 +1316,7 @@ test('renders text input instead of dropdown when operatorType is ILIKE contains
});
test('renders text input with starts-with placeholder', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.StartsWith },
@@ -1345,7 +1345,7 @@ test('renders text input with starts-with placeholder', () => {
});
test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1391,7 +1391,7 @@ test('typing in LIKE input calls setDataMask with ILIKE Contains payload', async
});
test('typing in LIKE input with inverse selection calls setDataMask with NOT ILIKE payload', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: {
@@ -1440,7 +1440,7 @@ test('typing in LIKE input with inverse selection calls setDataMask with NOT ILI
});
test('clear-all resets LIKE input value and calls setDataMask with empty state', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1506,7 +1506,7 @@ test('clear-all resets LIKE input value and calls setDataMask with empty state',
});
test('pending LIKE debounce still applies after rerender recreates updateDataMask', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1573,7 +1573,7 @@ test('pending LIKE debounce still applies after rerender recreates updateDataMas
});
test('pending LIKE debounce is canceled when operatorType switches back to Exact', async () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const likeProps = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Contains },
@@ -1628,7 +1628,7 @@ test('pending LIKE debounce is canceled when operatorType switches back to Exact
});
test('renders standard Select dropdown when operatorType is Exact', () => {
jest.useFakeTimers();
jest.useFakeTimers({ advanceTimers: true });
const setDataMaskMock = jest.fn();
const props = buildSelectFilterProps({
formData: { operatorType: SelectFilterOperatorType.Exact },

View File

@@ -240,6 +240,35 @@ describe('useTables hook', () => {
expect(fetchMock.callHistory.calls(tableApiRoute).length).toBe(1);
});
test('fetches tables without schema when supportsSchemas is false', async () => {
const expectDbId = 'db1';
const tableApiRoute = `glob:*/api/v1/database/${expectDbId}/tables/?q=*`;
fetchMock.get(tableApiRoute, fakeApiResult);
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/catalogs/*`, {
count: 0,
result: [],
});
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
result: fakeSchemaApiResult,
});
const { result } = renderHook(
() =>
useTables({
dbId: expectDbId,
supportsSchemas: false,
}),
{
wrapper: createWrapper({
useRedux: true,
store,
}),
},
);
// Tables are fetched even though no schema is provided or validated against schemaOptions
await waitFor(() => expect(result.current.data).toEqual(expectedData));
expect(fetchMock.callHistory.calls(tableApiRoute).length).toBe(1);
});
test('returns refreshed data after expires', async () => {
const expectDbId = 'db1';
const expectedSchema = 'schema1';

View File

@@ -96,7 +96,9 @@ type TableMetadataResponse = {
export type TableExtendedMetadata = Record<string, string>;
type Params = Omit<FetchTablesQueryParams, 'forceRefresh'>;
type Params = Omit<FetchTablesQueryParams, 'forceRefresh'> & {
supportsSchemas?: boolean;
};
const tableApi = api.injectEndpoints({
endpoints: builder => ({
@@ -166,7 +168,14 @@ export const {
} = tableApi;
export function useTables(options: Params) {
const { dbId, catalog, schema, onSuccess, onError } = options || {};
const {
dbId,
catalog,
schema,
supportsSchemas = true,
onSuccess,
onError,
} = options || {};
const isMountedRef = useRef(false);
const { currentData: schemaOptions, isFetching } = useSchemas({
dbId,
@@ -177,9 +186,9 @@ export function useTables(options: Params) {
[schemaOptions],
);
const enabled = Boolean(
dbId && schema && !isFetching && schemaOptionsMap.has(schema),
);
const enabled = supportsSchemas
? Boolean(dbId && schema && !isFetching && schemaOptionsMap.has(schema))
: Boolean(dbId);
const result = useTablesQuery(
{ dbId, catalog, schema, forceRefresh: false },

View File

@@ -122,6 +122,7 @@ export default function AddDataset() {
dbId={dataset?.db?.id}
catalog={dataset?.catalog}
schema={dataset?.schema}
database={dataset?.db}
setHasColumns={setHasColumns}
datasets={datasets}
/>

View File

@@ -42,15 +42,16 @@ beforeEach(() => {
});
afterEach(async () => {
// Restore real timers FIRST so the flush below uses real setTimeout,
// preventing a deadlock if a test threw while fake timers were active.
jest.useRealTimers();
// Flush pending React state updates within act() to prevent warnings
// and "document global undefined" errors from async operations
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 0));
});
// Restore real timers in case a test using fake timers threw early
jest.useRealTimers();
// Reset browser history state to prevent query params leaking between tests
window.history.replaceState({}, '', '/');

View File

@@ -115,6 +115,12 @@ type LaunchQueue = {
) => void;
};
const pendingTimerIds = new Set<ReturnType<typeof setTimeout>>();
const MAX_CONSUMER_POLL_ATTEMPTS = 50;
// Defer the consumer call to a macrotask so it doesn't fire synchronously inside
// the component's useEffect — calling it inline deadlocks Jest because the
// MessageChannel mock in jsDomWithFetchAPI forces React to schedule via setTimeout.
const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
let savedConsumer:
| ((params: { files?: MockFileHandle[] }) => void | Promise<void>)
@@ -123,7 +129,11 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
setConsumer: (consumer: (params: { files?: MockFileHandle[] }) => void) => {
savedConsumer = consumer;
if (fileHandle) {
consumer({ files: [fileHandle] });
const id = setTimeout(() => {
pendingTimerIds.delete(id);
consumer({ files: [fileHandle] });
}, 0);
pendingTimerIds.add(id);
}
},
};
@@ -132,25 +142,34 @@ const setupLaunchQueue = (fileHandle: MockFileHandle | null = null) => {
// In slower CI runners, useEffect may not have registered the consumer yet.
// Wait briefly for it before triggering.
let attempts = 0;
while (!savedConsumer && attempts < 50) {
while (!savedConsumer && attempts < MAX_CONSUMER_POLL_ATTEMPTS) {
// eslint-disable-next-line no-await-in-loop
await new Promise(resolve => {
setTimeout(resolve, 0);
});
attempts += 1;
}
await savedConsumer?.(params);
if (!savedConsumer) {
throw new Error(
`LaunchQueue consumer was never registered after ${MAX_CONSUMER_POLL_ATTEMPTS} polling attempts`,
);
}
await savedConsumer(params);
},
};
};
beforeEach(() => {
jest.clearAllMocks();
delete (window as any).launchQueue;
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
afterEach(() => {
delete (window as any).launchQueue;
pendingTimerIds.forEach(id => clearTimeout(id));
pendingTimerIds.clear();
delete (window as unknown as Window & { launchQueue?: LaunchQueue })
.launchQueue;
});
test('shows error when launchQueue is not supported', async () => {

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