Compare commits
47 Commits
rls-splice
...
fix-flakey
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
354a143171 | ||
|
|
556a93e8f3 | ||
|
|
839b5a8d0b | ||
|
|
cfb704dbeb | ||
|
|
e77f6ece92 | ||
|
|
785a08c7d5 | ||
|
|
d90d3a2dea | ||
|
|
6ee4d694bc | ||
|
|
006a1800be | ||
|
|
2fe6269c22 | ||
|
|
26ef4b7ed3 | ||
|
|
a7aa854968 | ||
|
|
db0c5b32da | ||
|
|
96ad20318d | ||
|
|
516bb19e10 | ||
|
|
2cc20d3284 | ||
|
|
3e3c5c36c3 | ||
|
|
eed7098093 | ||
|
|
1d1a0e6fec | ||
|
|
494c29f5bf | ||
|
|
ad7075d2aa | ||
|
|
3e1cfc6d69 | ||
|
|
fcf3f6c0d5 | ||
|
|
14ba666594 | ||
|
|
1c795418d2 | ||
|
|
6271272e60 | ||
|
|
2cf4a2c31f | ||
|
|
2adb6f64eb | ||
|
|
5a453fe95d | ||
|
|
245fffca79 | ||
|
|
372b50e19d | ||
|
|
d83b0c5ce3 | ||
|
|
f81821086a | ||
|
|
f67dd4a8f3 | ||
|
|
68fa8e2733 | ||
|
|
a60860c969 | ||
|
|
d023fe1703 | ||
|
|
547660dcc4 | ||
|
|
e934f2af92 | ||
|
|
cfb0b6e811 | ||
|
|
ff7dc53853 | ||
|
|
dce3317bc9 | ||
|
|
dc22b82d88 | ||
|
|
0250092378 | ||
|
|
4311a15eb2 | ||
|
|
b899556130 | ||
|
|
2f82236b29 |
10
.github/workflows/superset-docs-deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/)
|
||||
:::
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
BIN
docs/static/img/screenshots/dashboard.jpg
vendored
|
Before Width: | Height: | Size: 134 KiB After Width: | Height: | Size: 132 KiB |
BIN
docs/static/img/screenshots/explore.jpg
vendored
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 116 KiB |
BIN
docs/static/img/screenshots/gallery.jpg
vendored
|
Before Width: | Height: | Size: 118 KiB After Width: | Height: | Size: 118 KiB |
BIN
docs/static/img/screenshots/sql_lab.jpg
vendored
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 97 KiB |
BIN
docs/static/img/tutorial/create_pivot.png
vendored
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 240 KiB |
|
Before Width: | Height: | Size: 84 KiB After Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 131 KiB |
BIN
docs/static/img/tutorial/tutorial_chart_resize.png
vendored
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 12 KiB |
BIN
docs/static/img/tutorial/tutorial_edit_button.png
vendored
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 37 KiB |
BIN
docs/static/img/tutorial/tutorial_save_slice.png
vendored
|
Before Width: | Height: | Size: 141 KiB After Width: | Height: | Size: 21 KiB |
228
docs/yarn.lock
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
359
superset-frontend/package-lock.json
generated
@@ -96,7 +96,7 @@
|
||||
"fs-extra": "^11.3.4",
|
||||
"fuse.js": "^7.3.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.5.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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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/*';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
<code>chartType="{chartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 || [],
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('QueryAutoRefresh', () => {
|
||||
const refreshApi = 'glob:*/api/v1/query/updated_since?*';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -30,7 +30,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { user, testQuery, mockdatasets } from 'src/SqlLab/fixtures';
|
||||
import { FeatureFlag } 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'),
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -67,7 +67,7 @@ interface DashboardActions {
|
||||
setDatasources: (datasources: unknown) => void;
|
||||
}
|
||||
|
||||
interface DashboardProps {
|
||||
export interface DashboardProps {
|
||||
actions: DashboardActions;
|
||||
dashboardId: number;
|
||||
editMode?: boolean;
|
||||
|
||||
@@ -32,7 +32,7 @@ import { Droppable } from './dnd/DragDroppable';
|
||||
import { GRID_GUTTER_SIZE, GRID_COLUMN_COUNT } from '../util/constants';
|
||||
import { TAB_TYPE } from '../util/componentTypes';
|
||||
|
||||
interface DashboardGridProps {
|
||||
export interface DashboardGridProps {
|
||||
depth: number;
|
||||
editMode?: boolean;
|
||||
canEdit?: boolean;
|
||||
|
||||
@@ -611,7 +611,7 @@ test('should refresh the charts', async () => {
|
||||
});
|
||||
|
||||
test('auto-refresh uses onRefresh with skipped filters and toggles refresh state', async () => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
onRefresh.mockResolvedValue(undefined);
|
||||
|
||||
const originalRequestAnimationFrame = window.requestAnimationFrame;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,7 +80,7 @@ interface FilterScopeMap {
|
||||
[key: string]: FilterScopeMapEntry;
|
||||
}
|
||||
|
||||
interface FilterScopeSelectorProps {
|
||||
export interface FilterScopeSelectorProps {
|
||||
dashboardFilters: Record<number, DashboardFilter>;
|
||||
layout: DashboardLayout;
|
||||
updateDashboardFiltersScope: (
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -43,7 +43,7 @@ import {
|
||||
|
||||
export const CHART_MARGIN = 32;
|
||||
|
||||
interface ChartHolderProps {
|
||||
export interface ChartHolderProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
dashboardId: number;
|
||||
|
||||
@@ -39,7 +39,7 @@ import backgroundStyleOptions from 'src/dashboard/util/backgroundStyleOptions';
|
||||
import { BACKGROUND_TRANSPARENT } from 'src/dashboard/util/constants';
|
||||
import { EMPTY_CONTAINER_Z_INDEX } from 'src/dashboard/constants';
|
||||
|
||||
interface ColumnProps {
|
||||
export interface ColumnProps {
|
||||
id: string;
|
||||
parentId: string;
|
||||
component: LayoutItem;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('FilterScope TreeInitialization', () => {
|
||||
let formRef: { current: FormInstance | null };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
formRef = { current: null };
|
||||
});
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('FilterScope TreeSelection', () => {
|
||||
let formRef: { current: FormInstance | null };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
formRef = { current: null };
|
||||
});
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ const TIME_GRAIN_TUPLES: [string, string][] = [
|
||||
// "state update on unmounted component" warnings. Scoped fake timers let us
|
||||
// clear pending work deterministically during teardown for this test only.
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -50,7 +50,7 @@ interface CollectionItem {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface CollectionControlProps {
|
||||
export interface CollectionControlProps {
|
||||
name: string;
|
||||
label?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
@@ -94,7 +94,7 @@ interface FormData {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface DatasourceControlProps {
|
||||
export interface DatasourceControlProps {
|
||||
actions: DatasourceControlActions;
|
||||
onChange?: () => void;
|
||||
value?: string | null;
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
import { CustomFrame } from '../components';
|
||||
|
||||
const TODAY = '2024-06-03';
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
jest.setSystemTime(new Date(TODAY).getTime());
|
||||
|
||||
const emptyValue = '';
|
||||
|
||||
@@ -69,7 +69,7 @@ interface Datasource {
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface AdhocFilterControlProps {
|
||||
export interface AdhocFilterControlProps {
|
||||
label?: ReactNode;
|
||||
name?: string;
|
||||
sections?: string[];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -108,7 +108,7 @@ const getMetricsMatchingCurrentDataset = (
|
||||
);
|
||||
});
|
||||
|
||||
interface MetricsControlProps {
|
||||
export interface MetricsControlProps {
|
||||
name: string;
|
||||
onChange: (value: unknown) => void;
|
||||
multi?: boolean;
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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/'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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: [] },
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -62,7 +62,7 @@ const props = {
|
||||
|
||||
beforeEach(() => {
|
||||
mockJsonFormsChangeTriggered = false;
|
||||
jest.useFakeTimers();
|
||||
jest.useFakeTimers({ advanceTimers: true });
|
||||
mockedGet.mockReset();
|
||||
mockedPost.mockReset();
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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({}, '', '/');
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||