mirror of
https://github.com/apache/superset.git
synced 2026-08-16 13:01:29 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9058b49971 | ||
|
|
c1b5d05f83 | ||
|
|
e16bb29faf | ||
|
|
09b4bc51a3 | ||
|
|
379435b7eb | ||
|
|
4b96b91b53 | ||
|
|
073599bd0c | ||
|
|
9e2c4533c8 | ||
|
|
1ae115981f | ||
|
|
b078ae4b51 | ||
|
|
48b755470f | ||
|
|
dad7dae4f6 | ||
|
|
11e35eca3b | ||
|
|
8093197c97 | ||
|
|
919c6eddc1 | ||
|
|
886bb200d0 | ||
|
|
b5ca00d06b | ||
|
|
5719f8e349 | ||
|
|
9d72a39e10 | ||
|
|
66733a5d72 | ||
|
|
a435002293 | ||
|
|
2d8447af42 | ||
|
|
bf5daf0a1e | ||
|
|
b656b1d477 | ||
|
|
5a97e01d6e | ||
|
|
38cc70de2f | ||
|
|
a1bc3c67ed | ||
|
|
e5b6642b18 | ||
|
|
dd3a61156b | ||
|
|
820e3d18d3 | ||
|
|
2dd8fe362f | ||
|
|
9d2f625e55 | ||
|
|
d66895c566 | ||
|
|
2734bde504 | ||
|
|
f88f1fad04 | ||
|
|
9ec9c8658b | ||
|
|
9f81565625 | ||
|
|
c934498696 | ||
|
|
99bdacd2e1 | ||
|
|
48feb432de | ||
|
|
669ddb2913 | ||
|
|
e7ecf5479a | ||
|
|
c0fbe243e8 | ||
|
|
df0668eda5 | ||
|
|
6b682b1aa1 | ||
|
|
13145e8ad8 | ||
|
|
02f32469b0 | ||
|
|
d120b1c250 | ||
|
|
9d167dfada | ||
|
|
3c6239363b | ||
|
+3 |
5fb13f102a |
@@ -56,7 +56,7 @@ jobs:
|
||||
|
||||
- name: Upload coverage reports to Codecov
|
||||
if: steps.check.outputs.superset-extensions-cli
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
flags: superset-extensions-cli
|
||||
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
run: npx nyc merge coverage/ merged-output/coverage-summary.json
|
||||
|
||||
- name: Upload Code Coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: javascript
|
||||
use_oidc: true
|
||||
|
||||
@@ -85,7 +85,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,mysql
|
||||
verbose: true
|
||||
@@ -173,7 +173,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,postgres
|
||||
verbose: true
|
||||
@@ -222,7 +222,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,sqlite
|
||||
verbose: true
|
||||
|
||||
@@ -90,7 +90,7 @@ jobs:
|
||||
run: |
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,presto
|
||||
verbose: true
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
pip install -e .[hive]
|
||||
./scripts/python_tests.sh -m 'chart_data_flow or sql_json_flow'
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,hive
|
||||
verbose: true
|
||||
|
||||
@@ -72,7 +72,7 @@ jobs:
|
||||
pytest --durations-min=0.5 --cov=superset/sql/ ./tests/unit_tests/sql/ --cache-clear --cov-fail-under=100
|
||||
pytest --durations-min=0.5 --cov=superset/semantic_layers/ ./tests/unit_tests/semantic_layers/ --cache-clear --cov-fail-under=100
|
||||
- name: Upload code coverage
|
||||
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
|
||||
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||
with:
|
||||
flags: python,unit
|
||||
verbose: true
|
||||
|
||||
+70
@@ -24,6 +24,15 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
### Webhook alerts/reports block private/internal hosts by default
|
||||
|
||||
Webhook alert/report dispatch (`WebhookNotification.send`) now validates the target URL's host against the same private/internal-IP block applied to dataset import URLs. If the resolved host is in a loopback, link-local, private (RFC-1918), shared-CGNAT, or multicast range, the webhook is rejected with `NotificationParamException`.
|
||||
|
||||
Deployments that intentionally point webhooks at internal targets (chatops bridges, internal automation servers, on-premises Mattermost/Rocket.Chat, etc.) can opt out by setting `ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS = True` in `superset_config.py`. This mirrors the existing `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` opt-out for dataset imports.
|
||||
|
||||
### Impala cancel_query blocks private/internal hosts by default
|
||||
|
||||
The Impala engine spec's `cancel_query` issues an HTTP request from the Superset backend to the host configured on the Impala database connection. That host is now validated before the request: if it resolves to a private/internal IP range, the cancel call is refused and a warning is logged. Operators whose Impala cluster runs on an internal network can opt out by setting `IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS = True` in `superset_config.py`. This mirrors the dataset-import and webhook opt-out flags.
|
||||
### Map chart renderer and OpenStreetMap migration behavior
|
||||
|
||||
The MapLibre migration for deck.gl charts preserves saved non-Mapbox styles on
|
||||
@@ -45,6 +54,23 @@ service requires visible `© OpenStreetMap contributors` attribution and should
|
||||
be used through normal browser map tile requests and caching; it is not intended
|
||||
for bulk prefetch or offline tile downloads.
|
||||
|
||||
### Password complexity policy enabled by default
|
||||
|
||||
Superset now ships a default password-complexity policy, enforced (via Flask-AppBuilder) across self-registration, the user create/edit/reset forms, and the User REST API. The policy requires a minimum password length of 8 characters and rejects a built-in blocklist of common/guessable passwords.
|
||||
|
||||
This is enabled by default (`FAB_PASSWORD_COMPLEXITY_ENABLED = True`), so new or reset passwords that are too short or appear in the blocklist will be rejected where they were previously accepted. Existing stored passwords are unaffected until they are next changed.
|
||||
|
||||
Operators can tune or disable the policy via config:
|
||||
|
||||
- `AUTH_PASSWORD_MIN_LENGTH` — minimum length (default `8`).
|
||||
- `AUTH_PASSWORD_COMMON_BLOCKLIST` — extra passwords to reject, in addition to the built-in list.
|
||||
- `FAB_PASSWORD_COMPLEXITY_VALIDATOR` — replace with your own callable for custom rules.
|
||||
- `FAB_PASSWORD_COMPLEXITY_ENABLED = False` — disable enforcement entirely.
|
||||
|
||||
### Data uploads bounded by UPLOAD_MAX_FILE_SIZE_BYTES
|
||||
|
||||
Single data-file uploads (CSV, Excel, columnar) are now bounded by the `UPLOAD_MAX_FILE_SIZE_BYTES` config option, which defaults to `100 * 1024 * 1024` (100 MB). Files larger than this are rejected with a `413` before their contents are buffered into memory. Set `UPLOAD_MAX_FILE_SIZE_BYTES = None` to disable the check and restore unbounded uploads.
|
||||
|
||||
### Duration formatter precision
|
||||
|
||||
The `DURATION` number formatter now uses `Intl.DurationFormat` for locale-aware output. By default, sub-second fields are omitted, so values that previously displayed fractional seconds with `pretty-ms`, such as `10500` milliseconds rendering as `10.5s`, now render as `10s`.
|
||||
@@ -95,6 +121,20 @@ This change is backward compatible. The feature is off by default, and even when
|
||||
|
||||
Disabling a user account (setting `active` to `False`, via the admin UI, REST API, or CLI) now terminates that user's outstanding sessions on their next request, instead of relying on a passive check. This works for both client-side cookie sessions and server-side session stores via a per-user invalidation epoch (`user_attribute.sessions_invalidated_at`, added by a migration). The mechanism is inert for users that were never disabled (NULL epoch), so there is no behavior change for active users. Re-enabling an account and logging in again starts a fresh, valid session. The migration backfills the epoch for accounts that are already disabled at upgrade time, so re-enabling such an account does not revive a session that predates this feature.
|
||||
|
||||
### Opt-in SSH tunnel server host key verification
|
||||
|
||||
SSH tunnels can now optionally pin the expected SSH server host key as a defense-in-depth measure against man-in-the-middle attacks. paramiko's transport performs no known-hosts checking by default, so previously the SSH server's identity was not verified. This feature is opt-in and off by default; existing tunnels are unaffected.
|
||||
|
||||
- A new nullable `server_host_key` column on the `ssh_tunnels` table stores the expected host key in authorized-key form (e.g. `ssh-ed25519 AAAA...`). It is a public key and is stored in plaintext. It can be set via the SSH tunnel POST/PUT payloads (`ssh_tunnel.server_host_key`).
|
||||
- When a tunnel has `server_host_key` set, Superset connects to the SSH server, reads the host key it presents, and rejects the tunnel if it does not match.
|
||||
- A new config flag `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING` (default `False`) controls fail-closed behavior. When `True`, every tunnel must declare a `server_host_key`; a tunnel without one is rejected.
|
||||
|
||||
Runbook to adopt:
|
||||
|
||||
1. Capture the SSH server's host key, e.g. `ssh-keyscan -t ed25519 ssh.example.com` (verify it out-of-band).
|
||||
2. Set that value on the tunnel's `server_host_key` (via the database/SSH tunnel API or UI payload).
|
||||
3. Optionally set `SSH_TUNNEL_STRICT_HOST_KEY_CHECKING = True` in `superset_config.py` to require host-key verification on all tunnels.
|
||||
|
||||
### Dataset import validates catalog against the target connection
|
||||
|
||||
Importing a dataset now validates the `catalog` field against the target database connection. When the connection has multi-catalog disabled (`allow_multi_catalog` off) and the dataset's catalog is not the connection's default catalog, the import fails instead of silently persisting the non-default catalog. This matches the validation already enforced on the dataset update path and prevents imported datasets from querying an unintended database.
|
||||
@@ -114,6 +154,36 @@ Both default to empty (no behavior change). They apply to both the `LOCAL_EXTENS
|
||||
|
||||
The Dynamic Group By chart customization now orders its display values according to the "Sort display control values" toggle: ascending (A–Z), descending (Z–A), or the dataset's source order when the toggle is unset. Previously the dropdown always sorted alphabetically. Existing dashboards where the toggle was never set will show options in source order instead of A–Z; open the customization and enable the toggle to restore alphabetical ordering.
|
||||
|
||||
### Selectable encryption engine for app-encrypted fields (AES-GCM)
|
||||
|
||||
App-encrypted fields (database passwords, SSH tunnel credentials, OAuth tokens, etc.) can now use authenticated **AES-GCM** encryption instead of the historical unauthenticated **AES-CBC**. A new config selects the engine for the default adapter:
|
||||
|
||||
```python
|
||||
# "aes" (AES-CBC, historical default) | "aes-gcm" (authenticated, recommended for new installs)
|
||||
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes"
|
||||
```
|
||||
|
||||
**No action required / no behavior change:** the default remains `"aes"`, so existing installs are unaffected.
|
||||
|
||||
**Opting in on an existing install:** flipping the engine on a populated database without re-encrypting first will make stored secrets undecryptable, because the two ciphertext formats are not compatible. A migrator is provided. Recommended runbook:
|
||||
|
||||
1. Take a metadata-DB backup.
|
||||
2. Re-encrypt existing secrets into the new engine (the `SECRET_KEY` is unchanged):
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
3. Set `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` in your config.
|
||||
4. Restart Superset.
|
||||
5. Re-run the migrator once more after the restart:
|
||||
```bash
|
||||
superset re-encrypt-secrets --engine aes-gcm
|
||||
```
|
||||
A live instance keeps writing *new* secrets as AES-CBC during the window between step 2 and the restart in step 4; this second pass sweeps those up (it is idempotent, so already-migrated values are skipped).
|
||||
|
||||
Schedule the cutover in a quiet window. Runtime reads use only the single configured engine, so in a multi-worker deployment there is an unavoidable brief decrypt-outage between the migration commit and the last worker restarting with the new config — each migrator run is transactional, but the fleet-wide cutover is not zero-downtime.
|
||||
|
||||
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
|
||||
|
||||
### Granular Export Controls
|
||||
|
||||
A new feature flag `GRANULAR_EXPORT_CONTROLS` introduces three fine-grained permissions that replace the legacy `can_csv` permission:
|
||||
|
||||
+2
-2
@@ -72,8 +72,8 @@
|
||||
"@superset-ui/core": "^0.20.4",
|
||||
"@swc/core": "^1.15.40",
|
||||
"antd": "^6.4.3",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"caniuse-lite": "^1.0.30001793",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"caniuse-lite": "^1.0.30001797",
|
||||
"docusaurus-plugin-openapi-docs": "^5.0.2",
|
||||
"docusaurus-theme-openapi-docs": "^5.0.2",
|
||||
"js-yaml": "^4.2.0",
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
# SIP: Authenticated encryption (AES-GCM) for app-encrypted fields
|
||||
|
||||
## [DRAFT — proposal for discussion]
|
||||
|
||||
This document is a draft proposal accompanying the code in this PR. It is
|
||||
intended to seed the formal SIP discussion. The code here ships the
|
||||
backward-compatible engine selection **and** the re-encryption migrator
|
||||
(Phases 1–2 below); both are opt-in and change nothing for existing installs by
|
||||
default. Flipping the default for fresh installs (Phase 3) remains future work.
|
||||
|
||||
## Motivation
|
||||
|
||||
Superset app-encrypts a number of sensitive fields before persisting them to
|
||||
the metadata database, including:
|
||||
|
||||
- database connection passwords and `encrypted_extra` (`superset/models/core.py`),
|
||||
- SSH tunnel credentials — password, private key, private-key password
|
||||
(`superset/databases/ssh_tunnel/models.py`),
|
||||
- OAuth2 tokens and other secrets stored via `EncryptedType`.
|
||||
|
||||
These fields are encrypted with `sqlalchemy_utils.EncryptedType`, which
|
||||
**defaults to `AesEngine` (AES-CBC)**. AES-CBC provides confidentiality but is
|
||||
**unauthenticated**: it has no integrity tag. An attacker with write access to
|
||||
the ciphertext (e.g. direct metadata-DB access, a backup, or a compromised
|
||||
replica) can perform **bit-flipping / chosen-ciphertext manipulation** to
|
||||
silently alter the decrypted plaintext of a secret without detection.
|
||||
|
||||
`AesGcmEngine` (AES-GCM) is authenticated encryption: tampering causes
|
||||
decryption to fail loudly rather than yielding attacker-influenced plaintext.
|
||||
Using authenticated encryption for secrets at rest is an ASVS L1 expectation
|
||||
(11.3.2 / cryptography best practice).
|
||||
|
||||
`config.py` already documents that operators *can* switch to GCM by writing a
|
||||
custom `AbstractEncryptedFieldAdapter`, but:
|
||||
|
||||
1. it is opt-in, undocumented as a security recommendation, and easy to miss;
|
||||
2. there is **no migration path** — flipping the engine on a populated database
|
||||
makes every existing secret undecryptable, because GCM ciphertext is not
|
||||
format-compatible with CBC.
|
||||
|
||||
## Proposed change
|
||||
|
||||
A three-part change, delivered incrementally so existing deployments are never
|
||||
broken:
|
||||
|
||||
### Phase 1 — engine selection (this PR)
|
||||
|
||||
- Add a `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE` config (`"aes"` | `"aes-gcm"`),
|
||||
**defaulting to `"aes"`** (no behavior change for existing installs).
|
||||
- Teach the default `SQLAlchemyUtilsAdapter` to honor it (an explicit `engine`
|
||||
kwarg still wins, so the migrator can pin an engine).
|
||||
- This lets **new** deployments choose AES-GCM from day one with a one-line
|
||||
config, instead of writing a custom adapter.
|
||||
|
||||
### Phase 2 — CBC→GCM re-encryption migrator (this PR)
|
||||
|
||||
The existing `SecretsMigrator` (previously only used for `SECRET_KEY` rotation)
|
||||
gains an **engine migration** mode that:
|
||||
|
||||
1. discovers every `EncryptedType` column (via `discover_encrypted_fields()`),
|
||||
2. decrypts each value with the **source** engine (AES-CBC) under the current
|
||||
`SECRET_KEY`,
|
||||
3. re-encrypts with the **target** engine (AES-GCM),
|
||||
4. runs transactionally per the existing all-or-nothing semantics, and is
|
||||
idempotent per column (already-migrated values are skipped), so a run can be
|
||||
safely repeated or resumed.
|
||||
|
||||
Exposed via a new `--engine` option on the existing CLI command:
|
||||
`superset re-encrypt-secrets --engine aes-gcm`, runnable by operators with a DB
|
||||
backup in hand. The `SECRET_KEY` is unchanged; an engine change and a key
|
||||
rotation can also be combined (pass `--previous_secret_key` as well).
|
||||
|
||||
### Phase 3 — flip the default for new installs
|
||||
|
||||
Once the migrator and docs are in place, change the default to `"aes-gcm"` for
|
||||
**fresh** installs only (e.g. keyed off an empty metadata DB / documented in
|
||||
`UPDATING.md`), keeping existing installs on `"aes"` until they run Phase 2.
|
||||
|
||||
## New or changed public interfaces
|
||||
|
||||
- New config: `SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"]`.
|
||||
- New (Phase 2) CLI: `superset re-encrypt-secrets --engine <name>`.
|
||||
- No schema changes; ciphertext format changes per migrated column.
|
||||
|
||||
## Migration plan and compatibility
|
||||
|
||||
- **Backward compatible by default.** Phase 1 changes nothing unless the
|
||||
operator opts in.
|
||||
- Switching an existing deployment to `"aes-gcm"` **without** running the Phase
|
||||
2 migrator will make existing secrets undecryptable — this is called out in
|
||||
the config comment and must be in `UPDATING.md`.
|
||||
- Recommended operator runbook: take a metadata-DB backup → run
|
||||
`re-encrypt-secrets --engine aes-gcm` → set
|
||||
`SQLALCHEMY_ENCRYPTED_FIELD_ENGINE = "aes-gcm"` → restart → re-run
|
||||
`re-encrypt-secrets --engine aes-gcm` once more to sweep up any secrets a live
|
||||
instance wrote as AES-CBC during the cutover window. The canonical, more
|
||||
detailed version of this runbook lives in `UPDATING.md`; this is a summary.
|
||||
- `AesEngine` allows queryability over encrypted fields; AES-GCM does not.
|
||||
Any code that filters/queries on an encrypted column directly must be audited
|
||||
before Phase 3 (none is expected, but it must be verified).
|
||||
|
||||
## Rejected alternatives
|
||||
|
||||
- **Flip the default immediately.** Rejected: bricks every existing
|
||||
deployment's secrets with no migration path.
|
||||
- **Document-only (custom adapter).** Status quo; high friction and no
|
||||
migration tooling — most operators will never do it.
|
||||
|
||||
## Open questions
|
||||
|
||||
- GCM→CBC rollback (for operators who need queryability) already works via the
|
||||
same command (`re-encrypt-secrets --engine aes`), since the migrator is
|
||||
engine-symmetric. Should rollback be documented as a supported path or
|
||||
discouraged?
|
||||
- The migrator already supports a concurrent `SECRET_KEY` rotation + engine
|
||||
change in a single pass (pass `--previous_secret_key` alongside `--engine`).
|
||||
Is that combination worth calling out in the operator docs, or kept advanced?
|
||||
+142
-32
@@ -265,6 +265,15 @@
|
||||
js-tokens "^4.0.0"
|
||||
picocolors "^1.1.1"
|
||||
|
||||
"@babel/code-frame@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.7.tgz#f2fbbfea87c44a21590ec515b778b2c26d8866e7"
|
||||
integrity sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==
|
||||
dependencies:
|
||||
"@babel/helper-validator-identifier" "^7.29.7"
|
||||
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"
|
||||
@@ -275,20 +284,25 @@
|
||||
resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz"
|
||||
integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg==
|
||||
|
||||
"@babel/compat-data@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.7.tgz#6f0237f0f36d2e51c0570a636faed9d2d0efe629"
|
||||
integrity sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==
|
||||
|
||||
"@babel/core@^7.21.3", "@babel/core@^7.25.9":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz"
|
||||
integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.29.7.tgz#80c10b17248082968b57a857b91640971f2070f7"
|
||||
integrity sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.28.6"
|
||||
"@babel/generator" "^7.28.6"
|
||||
"@babel/helper-compilation-targets" "^7.28.6"
|
||||
"@babel/helper-module-transforms" "^7.28.6"
|
||||
"@babel/helpers" "^7.28.6"
|
||||
"@babel/parser" "^7.28.6"
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/traverse" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
"@babel/code-frame" "^7.29.7"
|
||||
"@babel/generator" "^7.29.7"
|
||||
"@babel/helper-compilation-targets" "^7.29.7"
|
||||
"@babel/helper-module-transforms" "^7.29.7"
|
||||
"@babel/helpers" "^7.29.7"
|
||||
"@babel/parser" "^7.29.7"
|
||||
"@babel/template" "^7.29.7"
|
||||
"@babel/traverse" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
"@jridgewell/remapping" "^2.3.5"
|
||||
convert-source-map "^2.0.0"
|
||||
debug "^4.1.0"
|
||||
@@ -318,6 +332,17 @@
|
||||
"@jridgewell/trace-mapping" "^0.3.28"
|
||||
jsesc "^3.0.2"
|
||||
|
||||
"@babel/generator@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3"
|
||||
integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==
|
||||
dependencies:
|
||||
"@babel/parser" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
"@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"
|
||||
@@ -325,7 +350,7 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.27.3"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6":
|
||||
"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz"
|
||||
integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==
|
||||
@@ -336,6 +361,17 @@
|
||||
lru-cache "^5.1.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-compilation-targets@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz#7a1def704302401c47f64fa85589e974ae217042"
|
||||
integrity sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==
|
||||
dependencies:
|
||||
"@babel/compat-data" "^7.29.7"
|
||||
"@babel/helper-validator-option" "^7.29.7"
|
||||
browserslist "^4.24.0"
|
||||
lru-cache "^5.1.1"
|
||||
semver "^6.3.1"
|
||||
|
||||
"@babel/helper-create-class-features-plugin@^7.27.1", "@babel/helper-create-class-features-plugin@^7.28.3":
|
||||
version "7.28.3"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.3.tgz"
|
||||
@@ -374,6 +410,11 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz"
|
||||
integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==
|
||||
|
||||
"@babel/helper-globals@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.29.7.tgz#f04a96fbd8473241b1079243f5b3f03a3010ab7b"
|
||||
integrity sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==
|
||||
|
||||
"@babel/helper-member-expression-to-functions@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.27.1.tgz"
|
||||
@@ -398,6 +439,14 @@
|
||||
"@babel/traverse" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/helper-module-imports@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz#ef25048a518e828d7393fac5882ddd73921d7396"
|
||||
integrity sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==
|
||||
dependencies:
|
||||
"@babel/traverse" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
|
||||
"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz"
|
||||
@@ -407,6 +456,15 @@
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
"@babel/traverse" "^7.28.6"
|
||||
|
||||
"@babel/helper-module-transforms@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz#b062747a5997ba138637201328bbff77960574ae"
|
||||
integrity sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==
|
||||
dependencies:
|
||||
"@babel/helper-module-imports" "^7.29.7"
|
||||
"@babel/helper-validator-identifier" "^7.29.7"
|
||||
"@babel/traverse" "^7.29.7"
|
||||
|
||||
"@babel/helper-optimise-call-expression@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz"
|
||||
@@ -455,16 +513,31 @@
|
||||
resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz"
|
||||
integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==
|
||||
|
||||
"@babel/helper-string-parser@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz#7f0871d99824d23137d60f86fcf6130fd5a1b51f"
|
||||
integrity sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==
|
||||
|
||||
"@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"
|
||||
integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==
|
||||
|
||||
"@babel/helper-validator-identifier@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2"
|
||||
integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==
|
||||
|
||||
"@babel/helper-validator-option@^7.27.1":
|
||||
version "7.27.1"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz"
|
||||
integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==
|
||||
|
||||
"@babel/helper-validator-option@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz#cf315be940213b354eb4abcc0bd01ebe3f73bc2a"
|
||||
integrity sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==
|
||||
|
||||
"@babel/helper-wrap-function@^7.27.1":
|
||||
version "7.28.3"
|
||||
resolved "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.28.3.tgz"
|
||||
@@ -474,13 +547,13 @@
|
||||
"@babel/traverse" "^7.28.3"
|
||||
"@babel/types" "^7.28.2"
|
||||
|
||||
"@babel/helpers@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz"
|
||||
integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==
|
||||
"@babel/helpers@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.29.7.tgz#45abfde7548997e34376c3e69feb475cffb4a607"
|
||||
integrity sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==
|
||||
dependencies:
|
||||
"@babel/template" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
"@babel/template" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
|
||||
"@babel/parser@^7.28.6":
|
||||
version "7.28.6"
|
||||
@@ -496,6 +569,13 @@
|
||||
dependencies:
|
||||
"@babel/types" "^7.29.0"
|
||||
|
||||
"@babel/parser@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334"
|
||||
integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==
|
||||
dependencies:
|
||||
"@babel/types" "^7.29.7"
|
||||
|
||||
"@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"
|
||||
@@ -1172,6 +1252,15 @@
|
||||
"@babel/parser" "^7.28.6"
|
||||
"@babel/types" "^7.28.6"
|
||||
|
||||
"@babel/template@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.29.7.tgz#4d9d4004f645cdd304de958c725162784ecac700"
|
||||
integrity sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.29.7"
|
||||
"@babel/parser" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
|
||||
"@babel/traverse@^7.25.9", "@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.6":
|
||||
version "7.28.6"
|
||||
resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.6.tgz"
|
||||
@@ -1198,6 +1287,19 @@
|
||||
"@babel/types" "^7.29.0"
|
||||
debug "^4.3.1"
|
||||
|
||||
"@babel/traverse@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d"
|
||||
integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==
|
||||
dependencies:
|
||||
"@babel/code-frame" "^7.29.7"
|
||||
"@babel/generator" "^7.29.7"
|
||||
"@babel/helper-globals" "^7.29.7"
|
||||
"@babel/parser" "^7.29.7"
|
||||
"@babel/template" "^7.29.7"
|
||||
"@babel/types" "^7.29.7"
|
||||
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"
|
||||
@@ -1214,6 +1316,14 @@
|
||||
"@babel/helper-string-parser" "^7.27.1"
|
||||
"@babel/helper-validator-identifier" "^7.28.5"
|
||||
|
||||
"@babel/types@^7.29.7":
|
||||
version "7.29.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92"
|
||||
integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==
|
||||
dependencies:
|
||||
"@babel/helper-string-parser" "^7.29.7"
|
||||
"@babel/helper-validator-identifier" "^7.29.7"
|
||||
|
||||
"@braintree/sanitize-url@^7.1.1":
|
||||
version "7.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f"
|
||||
@@ -5578,10 +5688,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.33, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.33"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz#27c299b096404978831958d429f48390424c4f9b"
|
||||
integrity sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==
|
||||
baseline-browser-mapping@^2.10.34, baseline-browser-mapping@^2.9.0, baseline-browser-mapping@^2.9.19:
|
||||
version "2.10.34"
|
||||
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.34.tgz#dedb606362446777cfe328d30d4ee15056d06303"
|
||||
integrity sha512-IMDedajPifLnHNY0X9n8hKxRTQ6/eTHwr5bDo04WnuqxyKw6LYtQywCuuqPZwhl3aBXMvQpJov42GLCwRRdQzw==
|
||||
|
||||
batch@0.6.1:
|
||||
version "0.6.1"
|
||||
@@ -5824,10 +5934,10 @@ caniuse-api@^3.0.0:
|
||||
lodash.memoize "^4.1.2"
|
||||
lodash.uniq "^4.5.0"
|
||||
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001793:
|
||||
version "1.0.30001793"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz#238887ddf5fcfc8c36d872394d0a78a517312a72"
|
||||
integrity sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==
|
||||
caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001797:
|
||||
version "1.0.30001797"
|
||||
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz#1332709e1439f01ff92085dd17001e0a45897ec0"
|
||||
integrity sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==
|
||||
|
||||
ccount@^2.0.0:
|
||||
version "2.0.1"
|
||||
@@ -9532,12 +9642,12 @@ latest-version@^7.0.0:
|
||||
package-json "^8.1.0"
|
||||
|
||||
launch-editor@^2.6.1:
|
||||
version "2.11.1"
|
||||
resolved "https://registry.npmjs.org/launch-editor/-/launch-editor-2.11.1.tgz"
|
||||
integrity sha512-SEET7oNfgSaB6Ym0jufAdCeo3meJVeCaaDyzRygy0xsp2BFKCprcfHljTq4QkzTLUxEKkFK6OK4811YM2oSrRg==
|
||||
version "2.14.1"
|
||||
resolved "https://registry.yarnpkg.com/launch-editor/-/launch-editor-2.14.1.tgz#f7e0da3f58aaea03fea01074d840b5f739ed7ddc"
|
||||
integrity sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==
|
||||
dependencies:
|
||||
picocolors "^1.1.1"
|
||||
shell-quote "^1.8.3"
|
||||
shell-quote "^1.8.4"
|
||||
|
||||
layout-base@^1.0.0:
|
||||
version "1.0.2"
|
||||
@@ -13489,7 +13599,7 @@ shebang-regex@^3.0.0:
|
||||
resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz"
|
||||
integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
|
||||
|
||||
shell-quote@^1.8.3:
|
||||
shell-quote@^1.8.4:
|
||||
version "1.8.4"
|
||||
resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.4.tgz#2edd9a4dcefc96649e2e2cb12f637b1f1d92a190"
|
||||
integrity sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==
|
||||
|
||||
+1
-1
@@ -55,7 +55,7 @@ dependencies = [
|
||||
"flask-login>=0.6.0, < 1.0",
|
||||
"flask-migrate>=3.1.0, <5.0",
|
||||
"flask-session>=0.4.0, <1.0",
|
||||
"flask-wtf>=1.1.0, <2.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet>=3.0.3, <=3.5.0",
|
||||
"gunicorn>=25.3.0, <26; sys_platform != 'win32'",
|
||||
|
||||
@@ -151,7 +151,7 @@ flask-sqlalchemy==2.5.1
|
||||
# flask-migrate
|
||||
flask-talisman==1.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
flask-wtf==1.2.2
|
||||
flask-wtf==1.3.0
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# flask-appbuilder
|
||||
|
||||
@@ -312,7 +312,7 @@ flask-talisman==1.1.0
|
||||
# apache-superset
|
||||
flask-testing==0.8.1
|
||||
# via apache-superset
|
||||
flask-wtf==1.2.2
|
||||
flask-wtf==1.3.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
Generated
+140
-148
@@ -27,19 +27,6 @@
|
||||
"webpack-cli": "^5.1.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/cli": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
|
||||
@@ -71,12 +58,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -85,32 +72,30 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
|
||||
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
|
||||
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
|
||||
"version": "7.29.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
|
||||
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
"@babel/generator": "^7.25.0",
|
||||
"@babel/helper-compilation-targets": "^7.25.2",
|
||||
"@babel/helper-module-transforms": "^7.25.2",
|
||||
"@babel/helpers": "^7.25.0",
|
||||
"@babel/parser": "^7.25.0",
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/traverse": "^7.25.2",
|
||||
"@babel/types": "^7.25.2",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.6",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.29.2",
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
@@ -126,13 +111,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -169,15 +154,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
|
||||
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.25.2",
|
||||
"@babel/helper-validator-option": "^7.24.8",
|
||||
"browserslist": "^4.23.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
},
|
||||
@@ -382,29 +366,28 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
|
||||
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
@@ -425,26 +408,25 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
|
||||
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/types": "^7.25.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -1843,14 +1825,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1875,13 +1857,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2649,6 +2631,16 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"node_modules/@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
@@ -7983,16 +7975,6 @@
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
|
||||
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@babel/cli": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.25.6.tgz",
|
||||
@@ -8011,38 +7993,38 @@
|
||||
}
|
||||
},
|
||||
"@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
}
|
||||
},
|
||||
"@babel/compat-data": {
|
||||
"version": "7.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.4.tgz",
|
||||
"integrity": "sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/core": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz",
|
||||
"integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==",
|
||||
"version": "7.29.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.6.tgz",
|
||||
"integrity": "sha512-QdxmAo/ikZqqRGA8s43ww8lcql6naWRvEz0FFrl6MIlc7Gi6TroXnSdWa5U/kq6fzcpqpHesicQxFZIieZbyIA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@ampproject/remapping": "^2.2.0",
|
||||
"@babel/code-frame": "^7.24.7",
|
||||
"@babel/generator": "^7.25.0",
|
||||
"@babel/helper-compilation-targets": "^7.25.2",
|
||||
"@babel/helper-module-transforms": "^7.25.2",
|
||||
"@babel/helpers": "^7.25.0",
|
||||
"@babel/parser": "^7.25.0",
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/traverse": "^7.25.2",
|
||||
"@babel/types": "^7.25.2",
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.6",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.29.2",
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
"gensync": "^1.0.0-beta.2",
|
||||
@@ -8051,13 +8033,13 @@
|
||||
}
|
||||
},
|
||||
"@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -8083,14 +8065,14 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-compilation-targets": {
|
||||
"version": "7.25.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz",
|
||||
"integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/compat-data": "^7.25.2",
|
||||
"@babel/helper-validator-option": "^7.24.8",
|
||||
"browserslist": "^4.23.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
}
|
||||
@@ -8229,21 +8211,21 @@
|
||||
}
|
||||
},
|
||||
"@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-validator-option": {
|
||||
"version": "7.24.8",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz",
|
||||
"integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true
|
||||
},
|
||||
"@babel/helper-wrap-function": {
|
||||
@@ -8258,22 +8240,22 @@
|
||||
}
|
||||
},
|
||||
"@babel/helpers": {
|
||||
"version": "7.25.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.6.tgz",
|
||||
"integrity": "sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/template": "^7.25.0",
|
||||
"@babel/types": "^7.25.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/plugin-bugfix-firefox-class-in-computed-class-key": {
|
||||
@@ -9157,14 +9139,14 @@
|
||||
}
|
||||
},
|
||||
"@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@babel/traverse": {
|
||||
@@ -9183,13 +9165,13 @@
|
||||
}
|
||||
},
|
||||
"@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
}
|
||||
},
|
||||
"@bcoe/v8-coverage": {
|
||||
@@ -9771,6 +9753,16 @@
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/remapping": {
|
||||
"version": "2.3.5",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
|
||||
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@jridgewell/gen-mapping": "^0.3.5",
|
||||
"@jridgewell/trace-mapping": "^0.3.24"
|
||||
}
|
||||
},
|
||||
"@jridgewell/resolve-uri": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
#
|
||||
# Licensed to the Apache Software Foundation (ASF) under one or more
|
||||
# contributor license agreements. See the NOTICE file distributed with
|
||||
# this work for additional information regarding copyright ownership.
|
||||
# The ASF licenses this file to You under the Apache License, Version 2.0
|
||||
# (the "License"); you may not use this file except in compliance with
|
||||
# the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
**/*{.,-}min.js
|
||||
**/*.sh
|
||||
coverage/**
|
||||
dist/*
|
||||
src/assets/images/*
|
||||
node_modules/*
|
||||
node_modules*/*
|
||||
vendor/*
|
||||
docs/*
|
||||
src/dashboard/deprecated/*
|
||||
src/temp/*
|
||||
**/node_modules
|
||||
*.d.ts
|
||||
coverage/
|
||||
esm/
|
||||
lib/
|
||||
tmp/
|
||||
storybook-static/
|
||||
@@ -1,537 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
const packageConfig = require('./package.json');
|
||||
|
||||
const importCoreModules = [];
|
||||
Object.entries(packageConfig.dependencies).forEach(([pkg]) => {
|
||||
if (/@superset-ui/.test(pkg)) {
|
||||
importCoreModules.push(pkg);
|
||||
}
|
||||
});
|
||||
|
||||
// ignore files in production mode
|
||||
let ignorePatterns = [];
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
ignorePatterns = [
|
||||
'*.test.{js,ts,jsx,tsx}',
|
||||
'plugins/**/test/**/*',
|
||||
'packages/**/test/**/*',
|
||||
'packages/generator-superset/**/*',
|
||||
];
|
||||
}
|
||||
|
||||
const restrictedImportsRules = {
|
||||
'no-design-icons': {
|
||||
name: '@ant-design/icons',
|
||||
message:
|
||||
'Avoid importing icons directly from @ant-design/icons. Use the src/components/Icons component instead.',
|
||||
},
|
||||
'no-moment': {
|
||||
name: 'moment',
|
||||
message:
|
||||
'Please use the dayjs library instead of moment.js. See https://day.js.org',
|
||||
},
|
||||
'no-lodash-memoize': {
|
||||
name: 'lodash/memoize',
|
||||
message: 'Lodash Memoize is unsafe! Please use memoize-one instead',
|
||||
},
|
||||
'no-testing-library-react': {
|
||||
name: '@superset-ui/core/spec',
|
||||
message: 'Please use spec/helpers/testing-library instead',
|
||||
},
|
||||
'no-testing-library-react-dom-utils': {
|
||||
name: '@testing-library/react-dom-utils',
|
||||
message: 'Please use spec/helpers/testing-library instead',
|
||||
},
|
||||
'no-antd': {
|
||||
name: 'antd',
|
||||
message: 'Please import Ant components from the index of src/components',
|
||||
},
|
||||
'no-superset-theme': {
|
||||
name: '@superset-ui/core',
|
||||
importNames: ['supersetTheme'],
|
||||
message:
|
||||
'Please use the theme directly from the ThemeProvider rather than importing supersetTheme.',
|
||||
},
|
||||
'no-query-string': {
|
||||
name: 'query-string',
|
||||
message: 'Please use the URLSearchParams API instead of query-string.',
|
||||
},
|
||||
'no-jest-mock-console': {
|
||||
name: 'jest-mock-console',
|
||||
message: 'Please use native Jest spies, i.e. jest.spyOn(console, "warn")',
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:import/recommended',
|
||||
'plugin:react-prefer-function-component/recommended',
|
||||
'plugin:storybook/recommended',
|
||||
'prettier',
|
||||
],
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react', '@babel/preset-env'],
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2020: true,
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'],
|
||||
moduleDirectory: ['node_modules', '.'],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: [
|
||||
'./tsconfig.json',
|
||||
'./packages/superset-ui-core/tsconfig.json',
|
||||
'./packages/superset-ui-chart-controls/',
|
||||
'./plugins/*/tsconfig.json',
|
||||
],
|
||||
},
|
||||
},
|
||||
'import/core-modules': importCoreModules,
|
||||
react: {
|
||||
version: 'detect',
|
||||
},
|
||||
},
|
||||
plugins: [
|
||||
'import',
|
||||
'lodash',
|
||||
'theme-colors',
|
||||
'icons',
|
||||
'i18n-strings',
|
||||
'react-prefer-function-component',
|
||||
'react-you-might-not-need-an-effect',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
// === Essential Superset customizations ===
|
||||
|
||||
// Prettier integration
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset rules
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'i18n-strings/no-eager-t-in-config': 'off', // enabled only for controlPanel files via overrides below
|
||||
|
||||
// Core ESLint overrides for Superset
|
||||
'no-console': 'warn',
|
||||
'no-unused-vars': 'off', // TypeScript handles this
|
||||
camelcase: [
|
||||
'error',
|
||||
{
|
||||
allow: ['^UNSAFE_', '__REDUX_DEVTOOLS_EXTENSION_COMPOSE__'],
|
||||
properties: 'never',
|
||||
},
|
||||
],
|
||||
'prefer-destructuring': ['error', { object: true, array: false }],
|
||||
'no-prototype-builtins': 0,
|
||||
curly: 'off',
|
||||
|
||||
// Import plugin overrides
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
'import/no-cycle': 0,
|
||||
'import/prefer-default-export': 0,
|
||||
'import/no-named-as-default-member': 0,
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{
|
||||
devDependencies: [
|
||||
'test/**',
|
||||
'tests/**',
|
||||
'spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'*.test.{js,jsx,ts,tsx}',
|
||||
'*.spec.{js,jsx,ts,tsx}',
|
||||
'**/*.test.{js,jsx,ts,tsx}',
|
||||
'**/*.spec.{js,jsx,ts,tsx}',
|
||||
'**/jest.config.js',
|
||||
'**/jest.setup.js',
|
||||
'**/webpack.config.js',
|
||||
'**/webpack.config.*.js',
|
||||
'**/.eslintrc*.js',
|
||||
],
|
||||
optionalDependencies: false,
|
||||
},
|
||||
],
|
||||
|
||||
// React plugin overrides
|
||||
'react-prefer-function-component/react-prefer-function-component': 1,
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-empty-effect': 'error',
|
||||
'react-you-might-not-need-an-effect/no-pass-live-state-to-parent': 'error',
|
||||
'react-you-might-not-need-an-effect/no-initialize-state': 'error',
|
||||
|
||||
// Lodash
|
||||
'lodash/import-scope': [2, 'member'],
|
||||
|
||||
// React effect best practices
|
||||
'react-you-might-not-need-an-effect/no-reset-all-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-chain-state-updates': 'error',
|
||||
'react-you-might-not-need-an-effect/no-event-handler': 'error',
|
||||
'react-you-might-not-need-an-effect/no-derived-state': 'error',
|
||||
|
||||
// Storybook
|
||||
'storybook/prefer-pascal-case': 'error',
|
||||
|
||||
// File progress
|
||||
'file-progress/activate': 1,
|
||||
|
||||
// React effect rules
|
||||
'react-you-might-not-need-an-effect/no-adjust-state-on-prop-change':
|
||||
'error',
|
||||
'react-you-might-not-need-an-effect/no-pass-data-to-parent': 'error',
|
||||
|
||||
// Restricted imports
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: Object.values(restrictedImportsRules).filter(Boolean),
|
||||
patterns: ['antd/*'],
|
||||
},
|
||||
],
|
||||
|
||||
// Temporarily disabled for migration
|
||||
'no-unsafe-optional-chaining': 0,
|
||||
'no-import-assign': 0,
|
||||
'import/no-relative-packages': 0,
|
||||
'no-promise-executor-return': 0,
|
||||
'import/no-import-module-exports': 0,
|
||||
|
||||
// Restrict certain syntax patterns
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
|
||||
message:
|
||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||
},
|
||||
{
|
||||
selector: 'ImportNamespaceSpecifier[parent.source.value!=/^(\\.|src)/]',
|
||||
message: 'Wildcard imports are not allowed',
|
||||
},
|
||||
],
|
||||
},
|
||||
overrides: [
|
||||
// Eager t()/tn() in `label`/`description` config props is captured at
|
||||
// module-load time, before i18n initializes — labels stay in the fallback
|
||||
// language even after the user switches. Surfaced as a warning (with
|
||||
// autofix to `() => t(...)`) wherever this is a real foot-gun:
|
||||
// controlPanel files. Many pre-existing call sites need conversion;
|
||||
// run `eslint --fix` on a controlPanel file to sweep it. Promote to
|
||||
// `'error'` once the codebase is clean.
|
||||
{
|
||||
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'i18n-strings/no-eager-t-in-config': 'warn',
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in src/ - all new code must be TypeScript
|
||||
{
|
||||
files: ['src/**/*.js', 'src/**/*.jsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in src/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in plugins/ - all plugin source code must be TypeScript
|
||||
{
|
||||
files: ['plugins/**/src/**/*.js', 'plugins/**/src/**/*.jsx'],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in plugins/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
// Ban JavaScript files in packages/ - with exceptions for config files and generators
|
||||
{
|
||||
files: ['packages/**/src/**/*.js', 'packages/**/src/**/*.jsx'],
|
||||
excludedFiles: [
|
||||
'packages/generator-superset/**/*', // Yeoman generator templates run via Node
|
||||
'packages/**/__mocks__/**/*', // Test mocks
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Program',
|
||||
message:
|
||||
'JavaScript files are not allowed in packages/. Please use TypeScript (.ts/.tsx) instead.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['*.ts', '*.tsx'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
tsconfigRootDir: __dirname,
|
||||
project: ['./tsconfig.json'],
|
||||
},
|
||||
extends: ['plugin:@typescript-eslint/recommended', 'prettier'],
|
||||
plugins: ['@typescript-eslint/eslint-plugin'],
|
||||
rules: {
|
||||
// TypeScript-specific rule overrides
|
||||
'@typescript-eslint/ban-ts-ignore': 0,
|
||||
'@typescript-eslint/ban-ts-comment': 0,
|
||||
'@typescript-eslint/ban-types': 0,
|
||||
'@typescript-eslint/naming-convention': [
|
||||
'error',
|
||||
{
|
||||
selector: 'enum',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
{
|
||||
selector: 'enumMember',
|
||||
format: ['PascalCase'],
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-empty-function': 0,
|
||||
'@typescript-eslint/no-explicit-any': 0,
|
||||
'@typescript-eslint/no-use-before-define': 'error',
|
||||
'@typescript-eslint/no-non-null-assertion': 0,
|
||||
'@typescript-eslint/explicit-function-return-type': 0,
|
||||
'@typescript-eslint/explicit-module-boundary-types': 0,
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
|
||||
// Disable base rules that conflict with TS versions
|
||||
'no-unused-vars': 'off',
|
||||
'no-use-before-define': 'off',
|
||||
'no-shadow': 'off',
|
||||
|
||||
// Import overrides for TypeScript
|
||||
'import/extensions': [
|
||||
'error',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: 'never',
|
||||
tsx: 'never',
|
||||
},
|
||||
],
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
typescript: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['packages/**'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
restrictedImportsRules['no-moment'],
|
||||
restrictedImportsRules['no-lodash-memoize'],
|
||||
restrictedImportsRules['no-superset-theme'],
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['plugins/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: [
|
||||
restrictedImportsRules['no-moment'],
|
||||
restrictedImportsRules['no-lodash-memoize'],
|
||||
],
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/**', 'src/theme/**'],
|
||||
rules: {
|
||||
'no-restricted-imports': [
|
||||
'error',
|
||||
{
|
||||
paths: Object.values(restrictedImportsRules).filter(
|
||||
r => r.name !== 'antd',
|
||||
),
|
||||
patterns: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.test.ts',
|
||||
'*.test.tsx',
|
||||
'*.test.js',
|
||||
'*.test.jsx',
|
||||
'*.stories.tsx',
|
||||
'*.stories.jsx',
|
||||
'fixtures.*',
|
||||
'**/test/**/*',
|
||||
'**/tests/**/*',
|
||||
'spec/**/*',
|
||||
'**/fixtures/**/*',
|
||||
'**/__mocks__/**/*',
|
||||
'**/spec/**/*',
|
||||
],
|
||||
excludedFiles: 'cypress-base/cypress/**/*',
|
||||
plugins: ['jest-dom', 'no-only-tests', 'testing-library'],
|
||||
extends: ['plugin:jest-dom/recommended', 'plugin:testing-library/react'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
'prefer-promise-reject-errors': 0,
|
||||
'max-classes-per-file': 0,
|
||||
|
||||
// Temporary for migration
|
||||
'testing-library/await-async-queries': 0,
|
||||
'testing-library/await-async-utils': 0,
|
||||
'testing-library/no-await-sync-events': 0,
|
||||
'testing-library/no-render-in-lifecycle': 0,
|
||||
'testing-library/no-unnecessary-act': 0,
|
||||
'testing-library/no-wait-for-multiple-assertions': 0,
|
||||
'testing-library/prefer-screen-queries': 0,
|
||||
'testing-library/await-async-events': 0,
|
||||
'testing-library/no-node-access': 0,
|
||||
'testing-library/no-wait-for-side-effects': 0,
|
||||
'testing-library/prefer-presence-queries': 0,
|
||||
'testing-library/render-result-naming-convention': 0,
|
||||
'testing-library/no-container': 0,
|
||||
'testing-library/prefer-find-by': 0,
|
||||
'testing-library/no-manual-cleanup': 0,
|
||||
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector:
|
||||
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
|
||||
message:
|
||||
'Default React import is not required due to automatic JSX runtime in React 16.4',
|
||||
},
|
||||
],
|
||||
'no-restricted-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'*.test.ts',
|
||||
'*.test.tsx',
|
||||
'*.test.js',
|
||||
'*.test.jsx',
|
||||
'*.stories.tsx',
|
||||
'*.stories.jsx',
|
||||
'fixtures.*',
|
||||
'**/test/**/*',
|
||||
'**/tests/**/*',
|
||||
'spec/**/*',
|
||||
'**/fixtures/**/*',
|
||||
'**/__mocks__/**/*',
|
||||
'**/spec/**/*',
|
||||
'cypress-base/cypress/**/*',
|
||||
'Stories.tsx',
|
||||
'packages/superset-ui-core/src/theme/index.tsx',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 0,
|
||||
'icons/no-fa-icons-usage': 0,
|
||||
'i18n-strings/no-template-vars': 0,
|
||||
'no-restricted-imports': 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'packages/**/*.stories.*',
|
||||
'packages/**/*.overview.*',
|
||||
'packages/**/fixtures.*',
|
||||
],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['playwright/**/*.ts', 'playwright/**/*.js'],
|
||||
rules: {
|
||||
'import/no-extraneous-dependencies': [
|
||||
'error',
|
||||
{ devDependencies: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
ignorePatterns,
|
||||
};
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
// Register TypeScript require hook so ESLint can load .ts plugin files
|
||||
require('tsx/cjs');
|
||||
|
||||
/**
|
||||
* MINIMAL ESLint config - ONLY for rules OXC doesn't support
|
||||
* This config is designed to be run alongside OXC linter
|
||||
*
|
||||
* Only covers:
|
||||
* - Custom Superset plugins (theme-colors, icons, i18n)
|
||||
* - Prettier formatting
|
||||
* - File progress indicator
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
root: true,
|
||||
// Don't report on eslint-disable comments for rules we don't have
|
||||
reportUnusedDisableDirectives: false,
|
||||
// Simple parser - no TypeScript needed since OXC handles that
|
||||
parser: '@babel/eslint-parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
requireConfigFile: false,
|
||||
babelOptions: {
|
||||
presets: ['@babel/preset-react', '@babel/preset-env'],
|
||||
},
|
||||
},
|
||||
env: {
|
||||
browser: true,
|
||||
node: true,
|
||||
es2020: true,
|
||||
},
|
||||
plugins: [
|
||||
// ONLY custom Superset plugins that OXC doesn't support
|
||||
'theme-colors',
|
||||
'icons',
|
||||
'i18n-strings',
|
||||
'file-progress',
|
||||
'prettier',
|
||||
],
|
||||
rules: {
|
||||
// === ONLY rules that OXC cannot handle ===
|
||||
|
||||
// Prettier integration (formatting)
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset plugins
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
'file-progress/activate': 1,
|
||||
|
||||
// Explicitly turn off all other rules to avoid conflicts
|
||||
// when the config gets merged with other configs
|
||||
'import/no-unresolved': 'off',
|
||||
'import/extensions': 'off',
|
||||
'@typescript-eslint/naming-convention': 'off',
|
||||
},
|
||||
overrides: [
|
||||
{
|
||||
// Disable custom rules in test/story files
|
||||
files: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/*.stories.*',
|
||||
'**/test/**',
|
||||
'**/tests/**',
|
||||
'**/spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'cypress-base/**',
|
||||
'packages/superset-ui-core/src/theme/index.tsx',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 0,
|
||||
'icons/no-fa-icons-usage': 0,
|
||||
'i18n-strings/no-template-vars': 0,
|
||||
'file-progress/activate': 0,
|
||||
},
|
||||
},
|
||||
],
|
||||
// Only check src/ files where theme/icon rules matter
|
||||
ignorePatterns: [
|
||||
'node_modules',
|
||||
'dist',
|
||||
'build',
|
||||
'.next',
|
||||
'coverage',
|
||||
'*.min.js',
|
||||
'vendor',
|
||||
// Skip packages/plugins since they have different theming rules
|
||||
'packages/**',
|
||||
'plugins/**',
|
||||
// Skip generated/external files
|
||||
'*.generated.*',
|
||||
'*.config.js',
|
||||
'webpack.*',
|
||||
// Temporary analysis files
|
||||
'*.js', // Skip all standalone JS files in root
|
||||
'*.json',
|
||||
],
|
||||
};
|
||||
+18
-21
@@ -4708,16 +4708,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"license": "MIT",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"dependencies": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
@@ -5029,10 +5028,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
@@ -10228,7 +10226,7 @@
|
||||
"camelcase": "^5.3.1",
|
||||
"find-up": "^4.1.0",
|
||||
"get-package-type": "^0.1.0",
|
||||
"js-yaml": "4.1.1",
|
||||
"js-yaml": "^3.13.1",
|
||||
"resolve-from": "^5.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -10238,8 +10236,7 @@
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"js-yaml": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"version": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"requires": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -12345,15 +12342,15 @@
|
||||
"integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw=="
|
||||
},
|
||||
"form-data": {
|
||||
"version": "4.0.5",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
|
||||
"integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
|
||||
"version": "4.0.6",
|
||||
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz",
|
||||
"integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==",
|
||||
"requires": {
|
||||
"asynckit": "^0.4.0",
|
||||
"combined-stream": "^1.0.8",
|
||||
"es-set-tostringtag": "^2.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"mime-types": "^2.1.12"
|
||||
"hasown": "^2.0.4",
|
||||
"mime-types": "^2.1.35"
|
||||
}
|
||||
},
|
||||
"fromentries": {
|
||||
@@ -12574,9 +12571,9 @@
|
||||
}
|
||||
},
|
||||
"hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
|
||||
"integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
|
||||
"requires": {
|
||||
"function-bind": "^1.1.2"
|
||||
}
|
||||
|
||||
@@ -31,12 +31,13 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
|
||||
// Tests
|
||||
//------------------------------------------------------------------------------
|
||||
|
||||
const ruleTester = new RuleTester({ parserOptions: { ecmaVersion: 6 } });
|
||||
const ruleTester = new RuleTester({ languageOptions: { ecmaVersion: 6 } });
|
||||
const rule: Rule.RuleModule = plugin.rules['no-template-vars'];
|
||||
|
||||
const errors: Array<{ type: string }> = [
|
||||
const errors: Array<{ message: string }> = [
|
||||
{
|
||||
type: 'CallExpression',
|
||||
message:
|
||||
"Don't use variables in translation string templates. Flask-babel is a static translation service, so it can't handle strings that include variables",
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -31,7 +31,10 @@ const plugin: { rules: Record<string, Rule.RuleModule> } = require('.');
|
||||
// Tests
|
||||
//------------------------------------------------------------------------------
|
||||
const ruleTester = new RuleTester({
|
||||
parserOptions: { ecmaVersion: 6, ecmaFeatures: { jsx: true } },
|
||||
languageOptions: {
|
||||
ecmaVersion: 6,
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
});
|
||||
const rule: Rule.RuleModule = plugin.rules['no-fa-icons-usage'];
|
||||
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* MINIMAL ESLint flat config - ONLY for rules OXC doesn't support.
|
||||
*
|
||||
* This config is run alongside the OXC (oxlint) linter, which handles the
|
||||
* bulk of linting. ESLint here only covers the custom Superset plugins and
|
||||
* Prettier formatting that oxlint cannot express. It is consumed by
|
||||
* `scripts/oxlint-metrics-uploader.js` (`npm run lint-stats`).
|
||||
*
|
||||
* Migrated from the legacy `.eslintrc.minimal.js` (eslintrc) format to flat
|
||||
* config for ESLint v9+/v10, where eslintrc is no longer supported.
|
||||
*
|
||||
* Only covers:
|
||||
* - Custom Superset plugins (theme-colors, icons, i18n-strings)
|
||||
* - Prettier formatting
|
||||
*/
|
||||
|
||||
// Register the TypeScript require hook so ESLint can load the .ts plugin files
|
||||
// from eslint-rules/*.
|
||||
require('tsx/cjs');
|
||||
|
||||
const tsParser = require('@typescript-eslint/parser');
|
||||
const prettierPlugin = require('eslint-plugin-prettier');
|
||||
const themeColorsPlugin = require('eslint-plugin-theme-colors');
|
||||
const iconsPlugin = require('eslint-plugin-icons');
|
||||
const i18nStringsPlugin = require('eslint-plugin-i18n-strings');
|
||||
|
||||
module.exports = [
|
||||
// Files this config applies to. Flat config has no `--ext`; globs live here.
|
||||
// Only check src/ files where the theme/icon/i18n rules matter.
|
||||
{
|
||||
ignores: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'build/**',
|
||||
'.next/**',
|
||||
'coverage/**',
|
||||
'**/*.min.js',
|
||||
'vendor/**',
|
||||
// Skip packages/plugins since they have different theming rules
|
||||
'packages/**',
|
||||
'plugins/**',
|
||||
// Skip generated/external/config files
|
||||
'**/*.generated.*',
|
||||
'**/*.config.js',
|
||||
'**/webpack.*',
|
||||
'*.json',
|
||||
],
|
||||
},
|
||||
{
|
||||
files: ['**/*.{js,jsx,ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
// The @typescript-eslint parser handles both TS/TSX and plain JS/JSX and
|
||||
// is compatible with ESLint v10's scope manager. (The legacy
|
||||
// @babel/eslint-parser does not support ESLint v10.) The custom rules
|
||||
// here are pure AST visitors and do not require type information, so no
|
||||
// `project` is configured — this keeps parsing fast.
|
||||
parser: tsParser,
|
||||
parserOptions: {
|
||||
ecmaFeatures: {
|
||||
jsx: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
// Don't report on eslint-disable comments for rules we don't have.
|
||||
linterOptions: {
|
||||
reportUnusedDisableDirectives: false,
|
||||
},
|
||||
plugins: {
|
||||
prettier: prettierPlugin,
|
||||
'theme-colors': themeColorsPlugin,
|
||||
icons: iconsPlugin,
|
||||
'i18n-strings': i18nStringsPlugin,
|
||||
},
|
||||
rules: {
|
||||
// Prettier integration (formatting)
|
||||
'prettier/prettier': 'error',
|
||||
|
||||
// Custom Superset plugins
|
||||
'theme-colors/no-literal-colors': 'error',
|
||||
'icons/no-fa-icons-usage': 'error',
|
||||
'i18n-strings/no-template-vars': 'error',
|
||||
// Enabled only for controlPanel files via the override below.
|
||||
'i18n-strings/no-eager-t-in-config': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Eager t()/tn() in `label`/`description` config props is captured at
|
||||
// module-load time, before i18n initializes — labels stay in the fallback
|
||||
// language even after the user switches. Surfaced as a warning (with
|
||||
// autofix to `() => t(...)`) wherever this is a real foot-gun:
|
||||
// controlPanel files. Promote to `'error'` once the codebase is clean.
|
||||
files: ['**/controlPanel.{ts,tsx,js,jsx}'],
|
||||
rules: {
|
||||
'i18n-strings/no-eager-t-in-config': 'warn',
|
||||
},
|
||||
},
|
||||
{
|
||||
// Disable custom rules in test/story files
|
||||
files: [
|
||||
'**/*.test.*',
|
||||
'**/*.spec.*',
|
||||
'**/*.stories.*',
|
||||
'**/test/**',
|
||||
'**/tests/**',
|
||||
'**/spec/**',
|
||||
'**/__tests__/**',
|
||||
'**/__mocks__/**',
|
||||
'cypress-base/**',
|
||||
],
|
||||
rules: {
|
||||
'theme-colors/no-literal-colors': 'off',
|
||||
'icons/no-fa-icons-usage': 'off',
|
||||
'i18n-strings/no-template-vars': 'off',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -287,13 +287,15 @@
|
||||
"ignorePatterns": [
|
||||
"packages/generator-superset/**/*",
|
||||
"cypress-base/**",
|
||||
"node_modules/**",
|
||||
"**/node_modules/**",
|
||||
"build/**",
|
||||
"dist/**",
|
||||
"lib/**",
|
||||
"esm/**",
|
||||
"*.min.js",
|
||||
"**/dist/**",
|
||||
"**/lib/**",
|
||||
"**/esm/**",
|
||||
"**/*.min.js",
|
||||
"**/*.d.ts",
|
||||
"coverage/**",
|
||||
"storybook-static/**",
|
||||
".git/**",
|
||||
"**/*.config.js",
|
||||
"**/*.config.ts"
|
||||
|
||||
Generated
+522
-519
File diff suppressed because it is too large
Load Diff
@@ -204,7 +204,7 @@
|
||||
"query-string": "9.4.0",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.2.0",
|
||||
"react-arborist": "^3.8.0",
|
||||
"react-arborist": "^3.10.1",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
@@ -280,11 +280,11 @@
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/js-levenshtein": "^1.1.3",
|
||||
"@types/json-bigint": "^1.0.4",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/react": "^18.2.0",
|
||||
"@types/react-dom": "^18.2.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
@@ -297,22 +297,22 @@
|
||||
"@types/rison": "0.1.0",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/unzipper": "^0.10.11",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.59.4",
|
||||
"@typescript-eslint/eslint-plugin": "^8.61.0",
|
||||
"@typescript-eslint/parser": "^8.61.0",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"babel-plugin-lodash": "^3.3.4",
|
||||
"baseline-browser-mapping": "^2.10.33",
|
||||
"baseline-browser-mapping": "^2.10.34",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.3",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
"cross-env": "^10.1.0",
|
||||
"css-loader": "^7.1.4",
|
||||
"css-minimizer-webpack-plugin": "^8.0.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^7.2.0",
|
||||
"eslint": "^10.4.1",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-import-resolver-alias": "^1.1.2",
|
||||
"eslint-import-resolver-typescript": "^4.4.5",
|
||||
"eslint-plugin-cypress": "^3.6.0",
|
||||
@@ -320,11 +320,11 @@
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.5.0",
|
||||
"eslint-plugin-lodash": "^7.4.0",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-prettier": "^5.5.6",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^0.10.4",
|
||||
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.0",
|
||||
"eslint-plugin-storybook": "10.4.2",
|
||||
"eslint-plugin-testing-library": "^7.16.2",
|
||||
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
|
||||
@@ -368,9 +368,9 @@
|
||||
"wait-on": "^9.0.10",
|
||||
"webpack": "^5.107.2",
|
||||
"webpack-bundle-analyzer": "^5.3.0",
|
||||
"webpack-cli": "^6.0.1",
|
||||
"webpack-cli": "^7.0.3",
|
||||
"webpack-dev-server": "^5.2.4",
|
||||
"webpack-manifest-plugin": "^5.0.1",
|
||||
"webpack-manifest-plugin": "^6.0.1",
|
||||
"webpack-sources": "^3.5.0",
|
||||
"webpack-visualizer-plugin2": "^2.0.0"
|
||||
},
|
||||
@@ -414,7 +414,16 @@
|
||||
"@jest/types": "^30.4.0",
|
||||
"jest-util": "^30.4.0",
|
||||
"jest-circus": "^30.4.0",
|
||||
"jest-environment-node": "^30.4.0"
|
||||
"jest-environment-node": "^30.4.0",
|
||||
"@babel/eslint-parser": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"eslint-plugin-import": {
|
||||
"eslint": "$eslint"
|
||||
},
|
||||
"eslint-plugin-jest-dom": {
|
||||
"eslint": "$eslint"
|
||||
}
|
||||
},
|
||||
"readme": "ERROR: No README data found!",
|
||||
"scarfSettings": {
|
||||
|
||||
@@ -75,9 +75,9 @@
|
||||
"@types/d3-scale": "^2.1.1",
|
||||
"@types/d3-time": "^3.0.4",
|
||||
"@types/d3-time-format": "^4.0.3",
|
||||
"@types/jquery": "^4.0.0",
|
||||
"@types/jquery": "^4.0.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/prop-types": "^15.7.15",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"@types/react-table": "^7.7.20",
|
||||
|
||||
+13
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
import { createRef } from 'react';
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import type AceEditor from 'react-ace';
|
||||
import {
|
||||
AsyncAceEditor,
|
||||
@@ -28,6 +29,7 @@ import {
|
||||
CssEditor,
|
||||
JsonEditor,
|
||||
ConfigEditor,
|
||||
aceCompletionHighlightStyles,
|
||||
} from '.';
|
||||
|
||||
import type { AceModule, AsyncAceEditorOptions } from './types';
|
||||
@@ -42,6 +44,17 @@ test('renders SQLEditor', async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('themes the autocomplete completion highlight from the theme', () => {
|
||||
// Ace ships a hardcoded `color: #000` for the matched-prefix highlight, which
|
||||
// is invisible on the dark autocomplete popup. The shared editor overrides it
|
||||
// from the theme so every Ace editor (SQL Lab, Explore Custom SQL, ...) stays
|
||||
// consistent.
|
||||
const { styles } = aceCompletionHighlightStyles(supersetTheme);
|
||||
|
||||
expect(styles).toContain('.ace_completion-highlight');
|
||||
expect(styles).toContain(supersetTheme.colorPrimaryText);
|
||||
});
|
||||
|
||||
test('SQLEditor uses fontFamilyCode from theme', async () => {
|
||||
const ref = createRef<AceEditor>();
|
||||
const { container } = render(<SQLEditor ref={ref as React.Ref<never>} />);
|
||||
|
||||
+16
-1
@@ -32,7 +32,7 @@ import {
|
||||
AsyncEsmComponent,
|
||||
PlaceholderProps,
|
||||
} from '@superset-ui/core/components/AsyncEsmComponent';
|
||||
import { useTheme, css } from '@apache-superset/core/theme';
|
||||
import { useTheme, css, type SupersetTheme } from '@apache-superset/core/theme';
|
||||
import { Global } from '@emotion/react';
|
||||
|
||||
export { getTooltipHTML } from './Tooltip';
|
||||
@@ -105,6 +105,19 @@ export type AsyncAceEditorOptions = {
|
||||
> | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Theme-aware styling for the matched-prefix highlight in the autocomplete
|
||||
* popup. Ace ships a hardcoded `color: #000` that is invisible on the dark
|
||||
* popup, so the override needs `!important` to win. Lives in the shared editor
|
||||
* so every Ace editor (SQL Lab, Explore Custom SQL, ...) stays consistent.
|
||||
*/
|
||||
export const aceCompletionHighlightStyles = (token: SupersetTheme) => css`
|
||||
.ace_completion-highlight {
|
||||
color: ${token.colorPrimaryText} !important;
|
||||
background-color: ${token.colorPrimaryBgHover};
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Get an async AceEditor with automatical loading of specified ace modules.
|
||||
*/
|
||||
@@ -370,6 +383,8 @@ export function AsyncAceEditor(
|
||||
display: flex !important;
|
||||
}
|
||||
|
||||
${aceCompletionHighlightStyles(token)}
|
||||
|
||||
&&& .tooltip-detail {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
|
||||
+37
-2
@@ -70,11 +70,46 @@ test('a change event that arrives before isEditing flips is not dropped', () =>
|
||||
});
|
||||
|
||||
test('prop changes mid-edit do not clobber unsaved typing', async () => {
|
||||
const { rerender } = render(<Harness initialTitle="Foo" />);
|
||||
// Rerender DynamicEditableTitle directly with a changed title prop so the
|
||||
// sync effect actually runs. Going through Harness would not exercise the
|
||||
// bug because Harness owns its own state and only reads initialTitle once.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
await userEvent.type(input, 'X', { delay: 1 });
|
||||
expect(input.value).toBe('FooX');
|
||||
rerender(<Harness initialTitle="Foo" />);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
expect(input.value).toBe('FooX');
|
||||
// Locks in commit semantics: blur after a real edit must persist the
|
||||
// user's typed value, even when a competing parent-driven title arrived
|
||||
// mid-edit.
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).toHaveBeenCalledWith('FooX');
|
||||
});
|
||||
|
||||
test('passive focus then parent-driven title change then blur does not revert', () => {
|
||||
// Phantom-revert scenario: user clicks the input but does not type, the
|
||||
// parent autosaves a new title from elsewhere, then the user blurs. The
|
||||
// component must NOT call onSave with the stale local value, otherwise it
|
||||
// would silently overwrite the parent's update.
|
||||
const onSave = jest.fn();
|
||||
const props = {
|
||||
placeholder: 'placeholder',
|
||||
canEdit: true,
|
||||
label: 'Title',
|
||||
onSave,
|
||||
};
|
||||
const { rerender } = render(<DynamicEditableTitle {...props} title="Foo" />);
|
||||
const input = screen.getByRole('textbox') as HTMLInputElement;
|
||||
userEvent.click(input);
|
||||
rerender(<DynamicEditableTitle {...props} title="Bar" />);
|
||||
fireEvent.blur(input);
|
||||
expect(onSave).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
+26
-3
@@ -81,12 +81,25 @@ export const DynamicEditableTitle = memo(
|
||||
|
||||
const sizerRef = useRef<HTMLSpanElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
// Tracks whether the user has actually typed since entering edit mode.
|
||||
// Gates onSave so that passive focus (click without typing) followed by a
|
||||
// parent-driven title change and blur does not silently revert the
|
||||
// parent's update with our stale currentTitle.
|
||||
const dirtyRef = useRef(false);
|
||||
const { width: containerWidth, ref: containerRef } = useResizeDetector({
|
||||
refreshMode: 'debounce',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
setCurrentTitle(title);
|
||||
// Don't overwrite in-flight user input when the parent re-renders with a
|
||||
// new title prop mid-edit. handleBlur already syncs currentTitle on commit;
|
||||
// re-running this effect when isEditing flips would resync to a stale
|
||||
// title prop, so isEditing is intentionally read via closure rather than
|
||||
// listed as a dep.
|
||||
if (!isEditing) {
|
||||
setCurrentTitle(title);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [title]);
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
@@ -138,10 +151,19 @@ export const DynamicEditableTitle = memo(
|
||||
return;
|
||||
}
|
||||
const formattedTitle = currentTitle.trim();
|
||||
setCurrentTitle(formattedTitle);
|
||||
if (title !== formattedTitle) {
|
||||
// Only commit when the user actually typed. Passive focus must not
|
||||
// overwrite a parent-driven title change that landed mid-edit.
|
||||
if (dirtyRef.current && title !== formattedTitle) {
|
||||
setCurrentTitle(formattedTitle);
|
||||
onSave(formattedTitle);
|
||||
} else if (!dirtyRef.current) {
|
||||
// Drop any stale local state and resync to the latest title prop so a
|
||||
// subsequent edit starts from the current parent value.
|
||||
setCurrentTitle(title);
|
||||
} else {
|
||||
setCurrentTitle(formattedTitle);
|
||||
}
|
||||
dirtyRef.current = false;
|
||||
setIsEditing(false);
|
||||
}, [canEdit, currentTitle, onSave, title]);
|
||||
|
||||
@@ -158,6 +180,7 @@ export const DynamicEditableTitle = memo(
|
||||
if (!isEditing) {
|
||||
setIsEditing(true);
|
||||
}
|
||||
dirtyRef.current = true;
|
||||
setCurrentTitle(ev.target.value);
|
||||
},
|
||||
[canEdit, isEditing],
|
||||
|
||||
@@ -29,12 +29,33 @@ export class ExplorePage {
|
||||
private static readonly SELECTORS = {
|
||||
DATASOURCE_CONTROL: '[data-test="datasource-control"]',
|
||||
VIZ_SWITCHER: '[data-test="fast-viz-switcher"]',
|
||||
CHART_CONTAINER: '[data-test="chart-container"]',
|
||||
} as const;
|
||||
|
||||
constructor(page: Page) {
|
||||
this.page = page;
|
||||
}
|
||||
|
||||
/**
|
||||
* Navigates to the Explore page for a given chart and waits for it to load.
|
||||
*
|
||||
* @param chartId - ID of the chart (slice) to open
|
||||
* @param options - Optional wait options
|
||||
*/
|
||||
async goto(chartId: number, options?: { timeout?: number }): Promise<void> {
|
||||
await this.page.goto(`explore/?slice_id=${chartId}`);
|
||||
await this.waitForPageLoad(options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the chart container locator (where the rendered viz appears).
|
||||
*
|
||||
* @returns Locator for the chart container
|
||||
*/
|
||||
getChartContainer(): Locator {
|
||||
return this.page.locator(ExplorePage.SELECTORS.CHART_CONTAINER);
|
||||
}
|
||||
|
||||
/**
|
||||
* Waits for the Explore page to load.
|
||||
* Validates URL contains /explore/ and datasource control is visible.
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #32960: the `formatDate` Handlebars helper (provided by
|
||||
* just-handlebars-helpers) stopped working after 4.1.2, rendering
|
||||
* "i is not a function" (minified) / "moment is not a function" (dev) instead
|
||||
* of the formatted date. The library helper resolves `moment` lazily via
|
||||
* `global.moment` / `require('moment/min/moment-with-locales')`, which the
|
||||
* bundled HandlebarsViewer no longer satisfies (it switched to dayjs).
|
||||
*
|
||||
* The fix registers a dayjs-backed `formatDate` override in HandlebarsViewer
|
||||
* (superset-frontend/plugins/plugin-chart-handlebars). This spec guards it: it
|
||||
* creates a Handlebars chart whose template uses `{{formatDate 'DD.MM.YYYY' ds}}`
|
||||
* and asserts the chart renders a real formatted date rather than the helper
|
||||
* error. Because the failure was a bundling/minification artifact (moment
|
||||
* resolves fine under Jest's Node `require`), an E2E test is required to cover it.
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPostChart } from '../../helpers/api/chart';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { ExplorePage } from '../../pages/ExplorePage';
|
||||
import { TIMEOUT } from '../../utils/constants';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
testWithAssets(
|
||||
'Handlebars formatDate helper renders a formatted date (#32960)',
|
||||
async ({ page, testAssets }) => {
|
||||
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
|
||||
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
const params = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'handlebars',
|
||||
query_mode: 'aggregate',
|
||||
groupby: ['ds'],
|
||||
metrics: ['count'],
|
||||
adhoc_filters: [],
|
||||
row_limit: 5,
|
||||
// Note: HTML_SANITIZATION (on by default) strips non-allowlisted
|
||||
// attributes such as `class`, so the rendered markup is plain
|
||||
// <ul>/<li> elements. The assertions below target `li` directly.
|
||||
handlebarsTemplate:
|
||||
'<ul>{{#each data}}' +
|
||||
"<li>{{formatDate 'DD.MM.YYYY' ds}}</li>" +
|
||||
'{{/each}}</ul>',
|
||||
styleTemplate: '',
|
||||
};
|
||||
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: `handlebars_format_date_${Date.now()}`,
|
||||
viz_type: 'handlebars',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
// The chart API may return either a top-level `{ id }` or a wrapped
|
||||
// `{ result: { id } }` shape; handle both and fail explicitly otherwise.
|
||||
const chartBody = await chartResp.json();
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
expect(chartId, 'chart creation should return an id').toBeTruthy();
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
const explorePage = new ExplorePage(page);
|
||||
await explorePage.goto(chartId);
|
||||
|
||||
const panel = explorePage.getChartContainer();
|
||||
await panel.waitFor({ state: 'visible', timeout: TIMEOUT.PAGE_LOAD });
|
||||
|
||||
// The helper error surfaces as a "... is not a function" message rendered
|
||||
// in place of the chart content.
|
||||
await expect(panel).not.toContainText('is not a function', {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
|
||||
// At least one list item should contain a DD.MM.YYYY formatted date.
|
||||
await expect(panel.locator('li').first()).toHaveText(/\d{2}\.\d{2}\.\d{4}/, {
|
||||
timeout: TIMEOUT.API_RESPONSE,
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,202 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #28766: a Gauge chart configured with interval bounds and
|
||||
* interval colors (mapped to a categorical color scheme) sometimes renders the
|
||||
* wrong interval colors when first loaded on a dashboard — a refresh fixes it.
|
||||
*
|
||||
* The gauge renders to a <canvas>, so this test reads pixels back from the
|
||||
* rendered gauge and asserts the configured interval colors are present in the
|
||||
* correct mapping. With `color_scheme: supersetColors` and
|
||||
* `interval_color_indices: '1,2'`, the gauge axis must paint the scheme's 1st
|
||||
* and 2nd colors (#1FA8C9 and #454E7C) and must NOT paint the 3rd (#5AC189),
|
||||
* which would indicate a shifted / fallback palette.
|
||||
*
|
||||
* CI green => the gauge paints the configured interval colors on first load;
|
||||
* merging closes #28766 and guards against regressions.
|
||||
* CI red => the interval colors are wrong on first load; the bug is live in
|
||||
* plugin-chart-echarts/src/Gauge (color-scheme resolution).
|
||||
*/
|
||||
import { testWithAssets, expect } from '../../helpers/fixtures';
|
||||
import { apiPostChart, apiPutChart } from '../../helpers/api/chart';
|
||||
import { apiPostDashboard } from '../../helpers/api/dashboard';
|
||||
import { getDatasetByName } from '../../helpers/api/dataset';
|
||||
import { DashboardPage } from '../../pages/DashboardPage';
|
||||
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
// supersetColors palette (1-based, matching interval_color_indices):
|
||||
// index 1 = #1FA8C9, index 2 = #454E7C, index 3 = #5AC189
|
||||
const COLOR_INTERVAL_1: [number, number, number] = [31, 168, 201];
|
||||
const COLOR_INTERVAL_2: [number, number, number] = [69, 78, 124];
|
||||
const COLOR_UNUSED_3: [number, number, number] = [90, 193, 137];
|
||||
|
||||
testWithAssets(
|
||||
'Gauge renders configured interval colors on a dashboard (#28766)',
|
||||
async ({ page, testAssets }) => {
|
||||
const dataset = await getDatasetByName(page, DATASET_NAME);
|
||||
if (!dataset) {
|
||||
throw new Error(`Dataset ${DATASET_NAME} not found`);
|
||||
}
|
||||
const datasetId = dataset.id;
|
||||
|
||||
const sliceName = `gauge_interval_colors_${Date.now()}`;
|
||||
const chartParams = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'gauge_chart',
|
||||
metric: 'count',
|
||||
adhoc_filters: [],
|
||||
row_limit: 10,
|
||||
color_scheme: 'supersetColors',
|
||||
min_val: 0,
|
||||
max_val: 100,
|
||||
start_angle: 225,
|
||||
end_angle: -45,
|
||||
intervals: '50,100',
|
||||
interval_color_indices: '1,2',
|
||||
show_pointer: true,
|
||||
number_format: 'SMART_NUMBER',
|
||||
value_formatter: '{value}',
|
||||
};
|
||||
const chartResp = await apiPostChart(page, {
|
||||
slice_name: sliceName,
|
||||
viz_type: 'gauge_chart',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(chartParams),
|
||||
});
|
||||
expect(chartResp.ok()).toBe(true);
|
||||
const chartBody = await chartResp.json();
|
||||
// Normalize: API may return id at top level or inside result.
|
||||
const chartId: number = chartBody.result?.id ?? chartBody.id;
|
||||
if (!chartId) {
|
||||
throw new Error(
|
||||
`Chart creation returned no id. Response: ${JSON.stringify(chartBody)}`,
|
||||
);
|
||||
}
|
||||
testAssets.trackChart(chartId);
|
||||
|
||||
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: 60,
|
||||
sliceName,
|
||||
},
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(page, {
|
||||
dashboard_title: `gauge_interval_colors_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
json_metadata: JSON.stringify({ color_scheme: 'supersetColors' }),
|
||||
});
|
||||
expect(dashResp.ok()).toBe(true);
|
||||
const dashBody = await dashResp.json();
|
||||
const dashboardId: number = dashBody.result?.id ?? dashBody.id;
|
||||
testAssets.trackDashboard(dashboardId);
|
||||
|
||||
await apiPutChart(page, chartId, { dashboards: [dashboardId] });
|
||||
|
||||
const dashboardPage = new DashboardPage(page);
|
||||
await dashboardPage.gotoById(dashboardId);
|
||||
await dashboardPage.waitForLoad();
|
||||
await dashboardPage.waitForChartsToLoad();
|
||||
|
||||
const canvas = page.locator('[data-test="chart-container"] canvas').first();
|
||||
await canvas.waitFor({ state: 'visible', timeout: 30_000 });
|
||||
|
||||
// Read the configured interval colors back from the rendered canvas.
|
||||
// Poll because the gauge paints shortly after the chart container appears.
|
||||
const countColors = () =>
|
||||
canvas.evaluate(
|
||||
(el: HTMLCanvasElement, targets: Array<[number, number, number]>) => {
|
||||
const ctx = el.getContext('2d');
|
||||
if (!ctx) return targets.map(() => 0);
|
||||
const { data } = ctx.getImageData(0, 0, el.width, el.height);
|
||||
const counts = targets.map(() => 0);
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
if (data[i + 3] < 200) continue;
|
||||
for (let t = 0; t < targets.length; t += 1) {
|
||||
const [r, g, b] = targets[t];
|
||||
if (
|
||||
Math.abs(data[i] - r) < 12 &&
|
||||
Math.abs(data[i + 1] - g) < 12 &&
|
||||
Math.abs(data[i + 2] - b) < 12
|
||||
) {
|
||||
counts[t] += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
return counts;
|
||||
},
|
||||
[COLOR_INTERVAL_1, COLOR_INTERVAL_2, COLOR_UNUSED_3],
|
||||
);
|
||||
|
||||
// Capture the counts inside the poll so the assertions below run against the
|
||||
// exact paint snapshot that satisfied the poll, not a second canvas read.
|
||||
let counts: number[] = [0, 0, 0];
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
counts = await countColors();
|
||||
return counts[0];
|
||||
},
|
||||
{ timeout: 20_000 },
|
||||
)
|
||||
.toBeGreaterThan(50);
|
||||
|
||||
const [interval1, interval2, unused3] = counts;
|
||||
|
||||
expect(
|
||||
interval1,
|
||||
'Gauge should paint the 1st interval color (#1FA8C9)',
|
||||
).toBeGreaterThan(50);
|
||||
expect(
|
||||
interval2,
|
||||
'Gauge should paint the 2nd interval color (#454E7C)',
|
||||
).toBeGreaterThan(50);
|
||||
expect(
|
||||
unused3,
|
||||
'Gauge must not paint the 3rd palette color (#5AC189) — indicates a shifted/fallback palette (#28766)',
|
||||
).toBe(0);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Regression for #33406: in a Pivot Table (v2) with nested rows, collapsing a
|
||||
* row group with the [-] toggle should stay collapsed after the collapsed rows
|
||||
* scroll out of the viewport and back. The bug reproduces specifically when the
|
||||
* dashboard is embedded via an iframe — the collapse/expand state lives in the
|
||||
* pivot renderer's local React state (`collapsedRows` initialised to `{}`), so
|
||||
* anything that remounts the chart resets it and the rows re-expand.
|
||||
*
|
||||
* This spec runs on the embedded harness (the only place the bug is reported to
|
||||
* reproduce). It collapses a top-level row, scrolls the embedded dashboard so
|
||||
* the pivot leaves and re-enters the viewport, and asserts the row is still
|
||||
* collapsed.
|
||||
*
|
||||
* CI green => collapse state survives the scroll round-trip; merging closes
|
||||
* #33406 and guards against regressions.
|
||||
* CI red => the rows re-expanded; the bug is live and the fix belongs in
|
||||
* plugin-chart-pivot-table (lift collapse state out of transient
|
||||
* component state, e.g. persist `collapsedRows`/`collapsedCols`).
|
||||
*
|
||||
* NOTE: the embedded suite only runs when the embedded SDK bundle is built and
|
||||
* INCLUDE_EMBEDDED=true (CI sets both). It is skipped otherwise.
|
||||
*/
|
||||
import { test, expect, Browser, BrowserContext, Page } from '@playwright/test';
|
||||
import { createServer, IncomingMessage, ServerResponse, Server } from 'http';
|
||||
import { AddressInfo, Socket } from 'net';
|
||||
import { readFileSync, existsSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import {
|
||||
apiEnableEmbedding,
|
||||
getAccessToken,
|
||||
getGuestToken,
|
||||
} from '../../helpers/api/embedded';
|
||||
import { apiPost, apiPut } from '../../helpers/api/requests';
|
||||
import { apiPostDashboard, apiDeleteDashboard } from '../../helpers/api/dashboard';
|
||||
import { apiDeleteChart } from '../../helpers/api/chart';
|
||||
import { EmbeddedPage } from '../../pages/EmbeddedPage';
|
||||
import { EMBEDDED } from '../../utils/constants';
|
||||
|
||||
const SUPERSET_DOMAIN = (() => {
|
||||
const url = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:8088';
|
||||
return url.replace(/\/+$/, '');
|
||||
})();
|
||||
const SUPERSET_BASE_URL = SUPERSET_DOMAIN.endsWith('/')
|
||||
? SUPERSET_DOMAIN
|
||||
: `${SUPERSET_DOMAIN}/`;
|
||||
|
||||
const SDK_BUNDLE_PATH = join(
|
||||
__dirname,
|
||||
'../../../../superset-embedded-sdk/bundle/index.js',
|
||||
);
|
||||
const EMBED_APP_DIR = join(__dirname, '../../embedded-app');
|
||||
const INDEX_HTML_PATH = join(EMBED_APP_DIR, 'index.html');
|
||||
const DATASET_NAME = 'birth_names';
|
||||
|
||||
interface EmbedAppServer {
|
||||
server: Server;
|
||||
url: string;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startEmbedAppServer(): Promise<EmbedAppServer> {
|
||||
const sockets = new Set<Socket>();
|
||||
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const urlPath = req.url?.split('?')[0] || '/';
|
||||
if (urlPath === '/sdk/index.js') {
|
||||
if (!existsSync(SDK_BUNDLE_PATH)) {
|
||||
res.writeHead(404);
|
||||
res.end(
|
||||
'SDK bundle not found. Run: cd superset-embedded-sdk && npm ci && npm run build',
|
||||
);
|
||||
return;
|
||||
}
|
||||
res.writeHead(200, { 'Content-Type': 'text/javascript' });
|
||||
res.end(readFileSync(SDK_BUNDLE_PATH));
|
||||
return;
|
||||
}
|
||||
if (urlPath === '/' || urlPath === '/index.html') {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(readFileSync(INDEX_HTML_PATH));
|
||||
return;
|
||||
}
|
||||
res.writeHead(404);
|
||||
res.end('Not found');
|
||||
});
|
||||
server.on('connection', socket => {
|
||||
sockets.add(socket);
|
||||
socket.once('close', () => sockets.delete(socket));
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address() as AddressInfo;
|
||||
return {
|
||||
server,
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
close: () =>
|
||||
new Promise<void>(resolve => {
|
||||
for (const socket of sockets) socket.destroy();
|
||||
sockets.clear();
|
||||
server.close(() => resolve());
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createAdminContext(browser: Browser): Promise<BrowserContext> {
|
||||
return browser.newContext({
|
||||
storageState: 'playwright/.auth/user.json',
|
||||
baseURL: SUPERSET_BASE_URL,
|
||||
});
|
||||
}
|
||||
|
||||
async function findDatasetIdByName(page: Page, name: string): Promise<number> {
|
||||
const query = `(filters:!((col:table_name,opr:eq,value:'${name}')))`;
|
||||
const resp = await page.request.get(`api/v1/dataset/?q=${query}`);
|
||||
const body = await resp.json();
|
||||
if (!body.result?.length) {
|
||||
throw new Error(`Dataset ${name} not found`);
|
||||
}
|
||||
return body.result[0].id;
|
||||
}
|
||||
|
||||
test.describe('Embedded Pivot Table collapse state (#33406)', () => {
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
test.setTimeout(90000);
|
||||
|
||||
let appServer: EmbedAppServer;
|
||||
let accessToken: string;
|
||||
let embedUuid: string;
|
||||
let dashboardId: number;
|
||||
let chartId: number;
|
||||
|
||||
test.beforeAll(async ({ browser }) => {
|
||||
test.skip(
|
||||
!existsSync(SDK_BUNDLE_PATH),
|
||||
'Embedded SDK bundle not found. Build it with: cd superset-embedded-sdk && npm ci && npm run build',
|
||||
);
|
||||
|
||||
appServer = await startEmbedAppServer();
|
||||
const context = await createAdminContext(browser);
|
||||
const setupPage = await context.newPage();
|
||||
try {
|
||||
const datasetId = await findDatasetIdByName(setupPage, DATASET_NAME);
|
||||
|
||||
const params = {
|
||||
datasource: `${datasetId}__table`,
|
||||
viz_type: 'pivot_table_v2',
|
||||
groupbyRows: ['state', 'name'],
|
||||
groupbyColumns: [],
|
||||
metrics: ['count'],
|
||||
metricsLayout: 'COLUMNS',
|
||||
aggregateFunction: 'Count',
|
||||
rowSubTotals: true,
|
||||
rowTotals: true,
|
||||
valueFormat: 'SMART_NUMBER',
|
||||
row_limit: 1000,
|
||||
order_desc: true,
|
||||
};
|
||||
const chartResp = await apiPost(setupPage, 'api/v1/chart/', {
|
||||
slice_name: `pivot_collapse_repro_${Date.now()}`,
|
||||
viz_type: 'pivot_table_v2',
|
||||
datasource_id: datasetId,
|
||||
datasource_type: 'table',
|
||||
params: JSON.stringify(params),
|
||||
});
|
||||
chartId = (await chartResp.json()).id;
|
||||
|
||||
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: 80,
|
||||
sliceName: 'pivot_collapse_repro',
|
||||
},
|
||||
},
|
||||
};
|
||||
const dashResp = await apiPostDashboard(setupPage, {
|
||||
dashboard_title: `pivot_collapse_repro_${Date.now()}`,
|
||||
published: true,
|
||||
position_json: JSON.stringify(positionJson),
|
||||
});
|
||||
const dashBody = await dashResp.json();
|
||||
dashboardId = dashBody.id;
|
||||
await apiPut(setupPage, `api/v1/chart/${chartId}`, {
|
||||
dashboards: [dashboardId],
|
||||
});
|
||||
|
||||
const embedded = await apiEnableEmbedding(setupPage, dashboardId);
|
||||
embedUuid = embedded.uuid;
|
||||
accessToken = await getAccessToken(setupPage);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
|
||||
test.afterAll(async ({ browser }) => {
|
||||
const context = await createAdminContext(browser);
|
||||
try {
|
||||
const cleanupPage = await context.newPage();
|
||||
if (dashboardId !== undefined) {
|
||||
await apiDeleteDashboard(cleanupPage, dashboardId, {
|
||||
failOnStatusCode: false,
|
||||
});
|
||||
}
|
||||
if (chartId !== undefined) {
|
||||
await apiDeleteChart(cleanupPage, chartId, { failOnStatusCode: false });
|
||||
}
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('[pivot-collapse teardown] cleanup failed:', err);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
if (appServer) await appServer.close();
|
||||
});
|
||||
|
||||
test('collapsed rows stay collapsed after a scroll round-trip', async ({
|
||||
page,
|
||||
}) => {
|
||||
const embeddedPage = new EmbeddedPage(page);
|
||||
await embeddedPage.exposeTokenFetcher(async () =>
|
||||
getGuestToken(page, dashboardId, { accessToken }),
|
||||
);
|
||||
await embeddedPage.goto({
|
||||
appUrl: appServer.url,
|
||||
uuid: embedUuid,
|
||||
supersetDomain: SUPERSET_DOMAIN,
|
||||
});
|
||||
await embeddedPage.waitForIframe();
|
||||
await embeddedPage.waitForDashboardContent();
|
||||
await embeddedPage.waitForChartRendered();
|
||||
|
||||
const rowLabels = embeddedPage.iframe.locator('.pvtRowLabel');
|
||||
await expect
|
||||
.poll(() => rowLabels.count(), { timeout: EMBEDDED.CHART_RENDER })
|
||||
.toBeGreaterThan(1);
|
||||
const expandedCount = await rowLabels.count();
|
||||
|
||||
// Collapse the first top-level row group via its [-] toggle. Scope to
|
||||
// `.pvtTable` so we never match a stray `toggle` class elsewhere in the DOM.
|
||||
await embeddedPage.iframe.locator('.pvtTable .toggle').first().click();
|
||||
await expect
|
||||
.poll(() => embeddedPage.iframe.locator('.pvtRowLabel').count(), {
|
||||
timeout: EMBEDDED.CHART_RENDER,
|
||||
})
|
||||
.toBeLessThan(expandedCount);
|
||||
const collapsedCount = await embeddedPage.iframe
|
||||
.locator('.pvtRowLabel')
|
||||
.count();
|
||||
|
||||
// Scroll the embedded dashboard so the pivot leaves the viewport, then back.
|
||||
await embeddedPage.iframe.locator('body').evaluate(() => {
|
||||
window.scrollTo(0, document.body.scrollHeight);
|
||||
});
|
||||
await page.waitForTimeout(800);
|
||||
await embeddedPage.iframe.locator('body').evaluate(() => {
|
||||
window.scrollTo(0, 0);
|
||||
});
|
||||
await page.waitForTimeout(1200);
|
||||
|
||||
// The collapsed group must remain collapsed (row-label count unchanged).
|
||||
await expect(embeddedPage.iframe.locator('.pvtRowLabel')).toHaveCount(
|
||||
collapsedCount,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -165,6 +165,16 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
return '';
|
||||
};
|
||||
|
||||
const updatePopupPosition = () => {
|
||||
const svgHeight = svg.node().getBoundingClientRect().height;
|
||||
const [x, y] = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.style('top', `${y + 30}px`)
|
||||
.style('left', `${x}px`)
|
||||
.classed('popup-at-bottom', y > (svgHeight * 2) / 3);
|
||||
};
|
||||
|
||||
const mouseenter = function mouseenter(this: SVGPathElement, d: GeoFeature) {
|
||||
// Darken color
|
||||
let c: string = colorFn(d);
|
||||
@@ -177,21 +187,14 @@ function CountryMap(element: HTMLElement, props: CountryMapProps) {
|
||||
region => region.country_id === d.properties.ISO,
|
||||
);
|
||||
|
||||
const position = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
.style('display', 'block')
|
||||
.style('top', `${position[1] + 30}px`)
|
||||
.style('left', `${position[0]}px`)
|
||||
.html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
hoverPopup.style('display', 'block').html(
|
||||
`<div><strong>${getNameOfRegion(d)}</strong><br>${result.length > 0 ? formatter(result[0].metric) : ''}</div>`,
|
||||
);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mousemove = function mousemove() {
|
||||
const position = d3.mouse(svg.node());
|
||||
hoverPopup
|
||||
.style('top', `${position[1] + 30}px`)
|
||||
.style('left', `${position[0]}px`);
|
||||
updatePopupPosition();
|
||||
};
|
||||
|
||||
const mouseout = function mouseout(this: SVGPathElement) {
|
||||
|
||||
@@ -86,5 +86,10 @@ export default styled(CountryMap)`
|
||||
cursor: pointer;
|
||||
stroke: ${theme.colorSplit};
|
||||
}
|
||||
|
||||
.superset-legacy-chart-country-map .hover-popup.popup-at-bottom {
|
||||
transform: translateY(-150%);
|
||||
}
|
||||
|
||||
`}
|
||||
`;
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
/** @jest-environment jsdom */
|
||||
import '@testing-library/jest-dom';
|
||||
import React from 'react';
|
||||
import { getDefaultTooltip } from './tooltip';
|
||||
import type { Refs } from '../types';
|
||||
|
||||
test('getDefaultTooltip computes height and sets scroll styles', () => {
|
||||
const div = document.createElement('div');
|
||||
jest.spyOn(div, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
top: 0,
|
||||
right: 100,
|
||||
bottom: 100,
|
||||
left: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect);
|
||||
|
||||
const divRef = React.createRef<HTMLDivElement>();
|
||||
Object.defineProperty(divRef, 'current', { value: div });
|
||||
|
||||
const refs: Refs = { divRef };
|
||||
|
||||
// Set viewport dimensions
|
||||
Object.defineProperty(document.documentElement, 'clientWidth', {
|
||||
value: 1200,
|
||||
configurable: true,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, 'clientHeight', {
|
||||
value: 1000,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const tooltip = getDefaultTooltip(refs);
|
||||
|
||||
// Test behavioral properties
|
||||
expect(tooltip.enterable).toBe(true);
|
||||
expect(tooltip.confine).toBe(true);
|
||||
expect(tooltip.hideDelay).toBe(50);
|
||||
|
||||
// Test that position function exists and is callable
|
||||
expect(typeof tooltip.position).toBe('function');
|
||||
|
||||
// Test position function behavior - returns coordinates
|
||||
const tooltipDom = document.createElement('div');
|
||||
const result = tooltip.position(
|
||||
[200, 300],
|
||||
{} as any,
|
||||
tooltipDom,
|
||||
{} as any,
|
||||
{ contentSize: [300, 600], viewSize: [1200, 1000] },
|
||||
);
|
||||
|
||||
// Verify the actual computed position, not just its shape. The cursor sits
|
||||
// in the right half of the chart, so the tooltip is placed to its left;
|
||||
// that pushes it past the left edge, so it clamps to the overflow margin.
|
||||
// Vertically it would overflow the top, so it flips to just below the cursor.
|
||||
expect(result).toEqual([5, 310]);
|
||||
|
||||
const computedMaxHeight = parseInt(tooltipDom.style.maxHeight, 10);
|
||||
const viewportHeight = 1000;
|
||||
const expectedMaxHeight = Math.min(800, viewportHeight * 0.8);
|
||||
|
||||
expect(computedMaxHeight).toBe(expectedMaxHeight);
|
||||
expect(tooltipDom).toHaveStyle({ overflow: 'auto' });
|
||||
});
|
||||
@@ -34,6 +34,12 @@ export function getDefaultTooltip(refs: Refs) {
|
||||
borderColor: 'transparent',
|
||||
// CSS hack applied on this class to resolve https://github.com/apache/superset/issues/30058
|
||||
className: 'echarts-tooltip',
|
||||
// allow scrolling inside tooltip without re-triggering the chart
|
||||
enterable: true,
|
||||
// keep within viewport and above header; enable internal scroll
|
||||
confine: true,
|
||||
// optional: reduce flicker when moving in/out of tooltip
|
||||
hideDelay: 50,
|
||||
position: (
|
||||
canvasMousePos: [number, number],
|
||||
params: CallbackDataParams,
|
||||
@@ -41,43 +47,76 @@ export function getDefaultTooltip(refs: Refs) {
|
||||
rect: any,
|
||||
sizes: { contentSize: [number, number]; viewSize: [number, number] },
|
||||
) => {
|
||||
// algorithm partially based on this snippet:
|
||||
// Algorithm partially based on this snippet:
|
||||
// https://github.com/apache/echarts/issues/5004#issuecomment-559668309
|
||||
|
||||
// The chart canvas position
|
||||
const divRect = refs.divRef?.current?.getBoundingClientRect();
|
||||
|
||||
// The mouse coordinates relative to the whole window
|
||||
// The first parameter to the position function is the mouse position relative to the canvas
|
||||
const mouseX = canvasMousePos[0] + (divRect?.x || 0);
|
||||
const mouseY = canvasMousePos[1] + (divRect?.y || 0);
|
||||
|
||||
// The width and height of the tooltip dom element
|
||||
const viewportWidth = document.documentElement.clientWidth;
|
||||
const viewportHeight = document.documentElement.clientHeight;
|
||||
const tooltipWidth = sizes.contentSize[0];
|
||||
const tooltipHeight = sizes.contentSize[1];
|
||||
|
||||
// Start by placing the tooltip top and right relative to the mouse position
|
||||
let xPos = mouseX + TOOLTIP_POINTER_MARGIN;
|
||||
let yPos = mouseY - TOOLTIP_POINTER_MARGIN - tooltipHeight;
|
||||
// Cap tooltip height to reduce blocking adjacent elements
|
||||
const maxAllowedHeight = Math.min(800, Math.floor(viewportHeight * 0.8));
|
||||
const needsScrolling = tooltipHeight > maxAllowedHeight;
|
||||
|
||||
// The tooltip is overflowing past the right edge of the window
|
||||
if (xPos + tooltipWidth >= document.documentElement.clientWidth) {
|
||||
// Attempt to place the tooltip to the left of the mouse position
|
||||
xPos = mouseX - TOOLTIP_POINTER_MARGIN - tooltipWidth;
|
||||
|
||||
// The tooltip is overflowing past the left edge of the window
|
||||
if (xPos <= 0)
|
||||
// Place the tooltip a fixed distance from the left edge of the window
|
||||
xPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
if (tooltipDom) {
|
||||
tooltipDom.style.maxHeight = `${maxAllowedHeight}px`;
|
||||
tooltipDom.style.overflow = 'auto';
|
||||
// Only enable pointer events when tooltip is scrollable
|
||||
// This prevents blocking adjacent chart elements when scrolling isn't needed
|
||||
tooltipDom.style.pointerEvents = needsScrolling ? 'auto' : 'none';
|
||||
}
|
||||
|
||||
const effectiveTooltipHeight = Math.min(tooltipHeight, maxAllowedHeight);
|
||||
let xPos: number;
|
||||
let yPos: number;
|
||||
|
||||
// For scrollable tooltips, position further away horizontally to avoid blocking chart navigation
|
||||
const horizontalMargin = needsScrolling
|
||||
? TOOLTIP_POINTER_MARGIN * 3
|
||||
: TOOLTIP_POINTER_MARGIN;
|
||||
|
||||
// Determine if cursor is in the right half of the chart to intelligently position tooltip
|
||||
const chartWidth = divRect?.width || viewportWidth;
|
||||
const cursorXInChart = canvasMousePos[0];
|
||||
const isInRightHalfOfChart = cursorXInChart > chartWidth / 2;
|
||||
|
||||
// Position tooltip on the left when in right half, right when in left half
|
||||
// This prevents blocking chart navigation
|
||||
if (isInRightHalfOfChart) {
|
||||
xPos = mouseX - horizontalMargin - tooltipWidth;
|
||||
|
||||
// If tooltip would go off left edge of viewport, push it back in
|
||||
if (xPos <= 0) {
|
||||
xPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
} else {
|
||||
xPos = mouseX + horizontalMargin;
|
||||
|
||||
// If tooltip would go off right edge of viewport, position on left instead
|
||||
if (xPos + tooltipWidth >= viewportWidth) {
|
||||
xPos = mouseX - horizontalMargin - tooltipWidth;
|
||||
|
||||
// If still overflowing left edge, clamp to margin
|
||||
if (xPos <= 0) {
|
||||
xPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Position tooltip above cursor, or below if no space
|
||||
yPos = mouseY - TOOLTIP_POINTER_MARGIN - effectiveTooltipHeight;
|
||||
|
||||
// The tooltip is overflowing past the top edge of the window
|
||||
if (yPos <= 0) {
|
||||
// Attempt to place the tooltip to the bottom of the mouse position
|
||||
yPos = mouseY + TOOLTIP_POINTER_MARGIN;
|
||||
|
||||
// The tooltip is overflowing past the bottom edge of the window
|
||||
if (yPos + tooltipHeight >= document.documentElement.clientHeight)
|
||||
if (yPos + effectiveTooltipHeight >= viewportHeight)
|
||||
// Place the tooltip a fixed distance from the top edge of the window
|
||||
yPos = TOOLTIP_OVERFLOW_MARGIN;
|
||||
}
|
||||
|
||||
@@ -86,6 +86,49 @@ describe('Pie transformProps', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('renders every slice when a NULL group value is mixed with named ones', () => {
|
||||
// Regression guard for https://github.com/apache/superset/issues/33174:
|
||||
// a Pie chart whose groupby dimension contains a NULL/empty value alongside
|
||||
// named values reportedly dropped the named slices (or rendered only the
|
||||
// NULL one). This asserts the transform keeps one slice per row, mapping the
|
||||
// NULL group to the `<NULL>` placeholder and preserving every other slice.
|
||||
const nullMixedChartProps = new ChartProps({
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
datasource: '3__table',
|
||||
granularity_sqla: 'ds',
|
||||
metric: 'sum__num',
|
||||
groupby: ['region'],
|
||||
viz_type: 'pie',
|
||||
} as SqlaFormData,
|
||||
width: 800,
|
||||
height: 600,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ region: '국내', sum__num: 817280006121 },
|
||||
{ region: '해외', sum__num: 118777753521 },
|
||||
{ region: null, sum__num: 20596314924 },
|
||||
],
|
||||
},
|
||||
],
|
||||
theme: supersetTheme,
|
||||
});
|
||||
|
||||
const series = (
|
||||
transformProps(nullMixedChartProps as EchartsPieChartProps).echartOptions
|
||||
.series as PieSeriesOption[]
|
||||
)[0];
|
||||
const data = series.data as PieChartDataItem[];
|
||||
|
||||
// every input row must still produce a slice -- none are dropped
|
||||
expect(data).toHaveLength(3);
|
||||
expect(data.map(d => d.name)).toEqual(['국내', '해외', '<NULL>']);
|
||||
expect(data.map(d => d.value)).toEqual([
|
||||
817280006121, 118777753521, 20596314924,
|
||||
]);
|
||||
});
|
||||
|
||||
test('falls back to scroll for plain legends with overlong labels', () => {
|
||||
const longLegendChartProps = new ChartProps({
|
||||
formData: {
|
||||
|
||||
+19
@@ -116,3 +116,22 @@ Handlebars.registerHelper('parseJson', (jsonString: string) => {
|
||||
|
||||
Helpers.registerHelpers(Handlebars);
|
||||
HandlebarsGroupBy.register(Handlebars);
|
||||
|
||||
// `just-handlebars-helpers` registers a `formatDate` helper that lazily
|
||||
// resolves `moment` via `global.moment` / `require('moment/min/moment-with-locales')`.
|
||||
// The bundled viewer switched to dayjs and never satisfies that lookup, so the
|
||||
// original helper throws "... is not a function" (see #32960). Re-register a
|
||||
// dayjs-backed `formatDate` with the same `{{formatDate formatString date [locale]}}`
|
||||
// signature so existing templates keep rendering.
|
||||
Handlebars.registerHelper('formatDate', (formatString, date, localeString) => {
|
||||
const format = typeof formatString === 'string' ? formatString : '';
|
||||
const instance = dayjs(date ?? new Date());
|
||||
// Handlebars always passes its options object as the final argument, so a
|
||||
// locale is only present when the caller supplied an explicit string.
|
||||
// Note: `extendedDayjs` only loads the `en` locale, so passing a non-English
|
||||
// locale here quietly falls back to English unless that locale bundle has
|
||||
// been imported elsewhere; dayjs's instance `.locale()` is a no-op otherwise.
|
||||
return typeof localeString === 'string'
|
||||
? instance.locale(localeString).format(format)
|
||||
: instance.format(format);
|
||||
});
|
||||
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* 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 Handlebars from 'handlebars';
|
||||
|
||||
// Importing the viewer registers the dayjs-backed `formatDate` override (#32960).
|
||||
// The end-to-end behavior (the bundling/minification regression) is covered by a
|
||||
// Playwright spec; these unit tests guard the helper's edge cases, which run fine
|
||||
// under Jest's Node environment without a browser.
|
||||
import '../../src/components/Handlebars/HandlebarsViewer';
|
||||
|
||||
// Handlebars passes its options object as the trailing argument, so callers that
|
||||
// omit the optional locale still get a non-string final arg. Mimic that here.
|
||||
const options = {} as unknown as string;
|
||||
|
||||
const formatDate = (
|
||||
format: string,
|
||||
date: unknown,
|
||||
locale: string = options,
|
||||
): string =>
|
||||
(Handlebars.helpers.formatDate as (...args: unknown[]) => string)(
|
||||
format,
|
||||
date,
|
||||
locale,
|
||||
);
|
||||
|
||||
test('formats a valid date string with the supplied format', () => {
|
||||
expect(formatDate('DD.MM.YYYY', '2024-06-14')).toBe('14.06.2024');
|
||||
});
|
||||
|
||||
test('renders "Invalid date" for an unparseable date string', () => {
|
||||
expect(formatDate('DD.MM.YYYY', 'not-a-date')).toBe('Invalid date');
|
||||
});
|
||||
|
||||
test('coerces a non-string format to dayjs default output without throwing', () => {
|
||||
// The helper guards against a non-string format by passing '' to dayjs,
|
||||
// which renders its default ISO 8601 representation rather than throwing.
|
||||
expect(() =>
|
||||
formatDate(undefined as unknown as string, '2024-06-14'),
|
||||
).not.toThrow();
|
||||
expect(formatDate(undefined as unknown as string, '2024-06-14')).toContain(
|
||||
'2024-06-14',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves the epoch-0 timestamp instead of falling back to now', () => {
|
||||
// 1970-01-01 in UTC, which is 1969 or 1970 locally depending on tz offset;
|
||||
// the point is it is NOT coerced to the current date.
|
||||
expect(formatDate('YYYY', 0)).toMatch(/^(1969|1970)$/);
|
||||
});
|
||||
|
||||
test('silently falls back to English for a locale that is not loaded', () => {
|
||||
// extendedDayjs only loads the `en` locale, so a non-English locale no-ops.
|
||||
expect(formatDate('MMMM', '2024-06-14', 'fr')).toBe('June');
|
||||
});
|
||||
+168
-5
@@ -92,6 +92,29 @@ const numberFormat = function (optsIn?: NumberFormatOptions): Formatter {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely converts any value to a number for aggregation purposes
|
||||
* Handles null/undefined, strings, and non-numeric values
|
||||
*/
|
||||
function toAggregationNumber(value: unknown): number {
|
||||
if (value == null) return 0;
|
||||
if (typeof value === 'number') {
|
||||
return Number.isNaN(value) ? 0 : value;
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
const num = parseFloat(value.trim());
|
||||
return Number.isNaN(num) ? 0 : num;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
type DataFunction = (key: string[], context: never[]) => unknown;
|
||||
|
||||
interface GroupNode {
|
||||
auto_agg_sum: number;
|
||||
[childKey: string]: GroupNode | number;
|
||||
}
|
||||
|
||||
const rx = /(\d+)|(\D+)/g;
|
||||
const rd = /\d/;
|
||||
const rz = /^0/;
|
||||
@@ -167,6 +190,125 @@ const naturalSort: SortFunction = (as, bs) => {
|
||||
return aArr.length - bArr.length;
|
||||
};
|
||||
|
||||
/**
|
||||
* Precomputes aggregate sums for all group levels using safe numeric conversion
|
||||
*/
|
||||
function buildGroupAggregates(
|
||||
keys: string[][],
|
||||
dataFunc: DataFunction,
|
||||
): GroupNode {
|
||||
const root: GroupNode = { auto_agg_sum: 0 } as GroupNode;
|
||||
|
||||
const terminalKeys = keys.filter(
|
||||
key =>
|
||||
!keys.some(
|
||||
ancestorKey =>
|
||||
ancestorKey.length > key.length &&
|
||||
key.every((segment, idx) => ancestorKey[idx] === segment),
|
||||
),
|
||||
);
|
||||
for (const key of terminalKeys) {
|
||||
let current: GroupNode = root;
|
||||
|
||||
for (let i = 0; i < key.length - 1; i += 1) {
|
||||
const segment = key[i];
|
||||
|
||||
if (!current[segment]) {
|
||||
current[segment] = { auto_agg_sum: 0 } as GroupNode;
|
||||
}
|
||||
|
||||
const childNode = current[segment] as GroupNode;
|
||||
childNode.auto_agg_sum += toAggregationNumber(dataFunc(key, []));
|
||||
current = childNode;
|
||||
}
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a comparator function for hierarchical keys with subtotal awareness
|
||||
*/
|
||||
function createHierarchicalComparator(
|
||||
groups: GroupNode,
|
||||
top: boolean | undefined,
|
||||
asc: boolean,
|
||||
dataFunc: DataFunction,
|
||||
): (a: string[], b: string[]) => number {
|
||||
const hierarchyMultiplier = top ? 1 : -1;
|
||||
const valueMultiplier = asc ? 1 : -1;
|
||||
|
||||
return (a: string[], b: string[]) => {
|
||||
const minLen = Math.min(a.length, b.length);
|
||||
let currentGroup: GroupNode = groups;
|
||||
let diffIndex = -1;
|
||||
|
||||
for (let i = 0; i < minLen; i += 1) {
|
||||
if (a[i] !== b[i]) {
|
||||
diffIndex = i;
|
||||
break;
|
||||
}
|
||||
currentGroup = currentGroup[a[i]] as GroupNode;
|
||||
}
|
||||
|
||||
if (diffIndex === -1 && a.length !== b.length) {
|
||||
return (a.length < b.length ? -1 : 1) * hierarchyMultiplier;
|
||||
}
|
||||
|
||||
const isLastLevelComparison =
|
||||
diffIndex === -1 ||
|
||||
(diffIndex === a.length - 1 && diffIndex === b.length - 1);
|
||||
|
||||
if (isLastLevelComparison) {
|
||||
const valA = dataFunc(a, []) as string | number | null;
|
||||
const valB = dataFunc(b, []) as string | number | null;
|
||||
|
||||
const result = naturalSort(valA, valB) * valueMultiplier;
|
||||
|
||||
if (result === 0) {
|
||||
const lastA = a[a.length - 1] ?? '';
|
||||
const lastB = b[b.length - 1] ?? '';
|
||||
return lastA.localeCompare(lastB) * valueMultiplier;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const segmentA = a[diffIndex];
|
||||
const segmentB = b[diffIndex];
|
||||
|
||||
const nodeA = currentGroup[segmentA] as GroupNode | undefined;
|
||||
const nodeB = currentGroup[segmentB] as GroupNode | undefined;
|
||||
|
||||
const sumA = nodeA?.auto_agg_sum ?? 0;
|
||||
const sumB = nodeB?.auto_agg_sum ?? 0;
|
||||
|
||||
if (sumA === sumB) {
|
||||
return segmentA.localeCompare(segmentB) * valueMultiplier;
|
||||
}
|
||||
return (sumA > sumB ? 1 : -1) * valueMultiplier;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param keys Hierarchical keys to sort ([[row1, row2], [row1, row3], ...])
|
||||
* @param dataFunc Function to retrieve aggregate value (may return string/number/null)
|
||||
* @param top true = subtotals at top/left, false = subtotals at bottom/right
|
||||
* @param asc true = ascending sort, false = descending sort
|
||||
*
|
||||
* @see https://github.com/apache/superset/issues/20564
|
||||
*/
|
||||
export function groupingValueSort(
|
||||
keys: string[][],
|
||||
dataFunc: DataFunction,
|
||||
top: boolean | undefined,
|
||||
asc: boolean,
|
||||
): void {
|
||||
const groups = buildGroupAggregates(keys, dataFunc);
|
||||
|
||||
const comparator = createHierarchicalComparator(groups, top, asc, dataFunc);
|
||||
keys.sort(comparator);
|
||||
}
|
||||
|
||||
const sortAs = function (order: (string | number)[]): SortFunction {
|
||||
const mapping: Record<string | number, number> = {};
|
||||
|
||||
@@ -897,7 +1039,8 @@ class PivotData {
|
||||
this.sorted = true;
|
||||
const rows = this.props.rows as string[];
|
||||
const cols = this.props.cols as string[];
|
||||
const v = (r: string[], c: string[]) => this.getAggregator(r, c).value();
|
||||
const vr = (r: string[], c: string[]) => this.getAggregator(r, c).value();
|
||||
const vc = (c: string[], r: string[]) => this.getAggregator(r, c).value();
|
||||
switch (this.props.rowOrder) {
|
||||
case 'key_z_to_a':
|
||||
this.rowKeys.sort(
|
||||
@@ -905,10 +1048,20 @@ class PivotData {
|
||||
);
|
||||
break;
|
||||
case 'value_a_to_z':
|
||||
this.rowKeys.sort((a, b) => naturalSort(v(a, []), v(b, [])));
|
||||
groupingValueSort(
|
||||
this.rowKeys,
|
||||
vr,
|
||||
this.subtotals.rowPartialOnTop,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
case 'value_z_to_a':
|
||||
this.rowKeys.sort((a, b) => -naturalSort(v(a, []), v(b, [])));
|
||||
groupingValueSort(
|
||||
this.rowKeys,
|
||||
vr,
|
||||
this.subtotals.rowPartialOnTop,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.rowKeys.sort(this.arrSort(rows, this.subtotals.rowPartialOnTop));
|
||||
@@ -920,10 +1073,20 @@ class PivotData {
|
||||
);
|
||||
break;
|
||||
case 'value_a_to_z':
|
||||
this.colKeys.sort((a, b) => naturalSort(v([], a), v([], b)));
|
||||
groupingValueSort(
|
||||
this.colKeys,
|
||||
vc,
|
||||
this.subtotals.colPartialOnTop,
|
||||
true,
|
||||
);
|
||||
break;
|
||||
case 'value_z_to_a':
|
||||
this.colKeys.sort((a, b) => -naturalSort(v([], a), v([], b)));
|
||||
groupingValueSort(
|
||||
this.colKeys,
|
||||
vc,
|
||||
this.subtotals.colPartialOnTop,
|
||||
false,
|
||||
);
|
||||
break;
|
||||
default:
|
||||
this.colKeys.sort(this.arrSort(cols, this.subtotals.colPartialOnTop));
|
||||
|
||||
+193
@@ -17,6 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { groupingValueSort } from '../../src/react-pivottable/utilities';
|
||||
|
||||
import type { ReactElement } from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
@@ -353,6 +355,197 @@ test('TableRenderer coerces numeric timestamp strings to numbers for column head
|
||||
expect(screen.getByText('col:square')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
type TestData = {
|
||||
[key: string]: number | string | null;
|
||||
};
|
||||
|
||||
const createMockAggregator =
|
||||
(data: TestData) =>
|
||||
(key: string[], _context: never[]): unknown => {
|
||||
const keyStr = key.join('|');
|
||||
return data[keyStr] ?? null;
|
||||
};
|
||||
|
||||
test('should sort flat keys in ascending order', () => {
|
||||
const keys: string[][] = [['A'], ['C'], ['B']];
|
||||
const data = {
|
||||
A: 30,
|
||||
B: 10,
|
||||
C: 20,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
|
||||
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
||||
});
|
||||
|
||||
test('should sort flat keys in descending order', () => {
|
||||
const keys: string[][] = [['A'], ['C'], ['B']];
|
||||
const data = {
|
||||
A: 30,
|
||||
B: 10,
|
||||
C: 20,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, false);
|
||||
|
||||
expect(keys).toEqual([['A'], ['C'], ['B']]);
|
||||
});
|
||||
|
||||
test('should place subtotal at top when top=true and ascending', () => {
|
||||
const keys: string[][] = [
|
||||
['Region', 'City1'],
|
||||
['Region'],
|
||||
['Region', 'City2'],
|
||||
];
|
||||
const data = {
|
||||
Region: 150,
|
||||
'Region|City1': 100,
|
||||
'Region|City2': 50,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), true, true);
|
||||
|
||||
expect(keys[0]).toEqual(['Region']);
|
||||
expect(keys[1]).toEqual(['Region', 'City2']);
|
||||
expect(keys[2]).toEqual(['Region', 'City1']);
|
||||
});
|
||||
|
||||
test('should place subtotal at bottom when top=false and descending', () => {
|
||||
const keys: string[][] = [
|
||||
['Region', 'City1'],
|
||||
['Region'],
|
||||
['Region', 'City2'],
|
||||
];
|
||||
const data = {
|
||||
'Region|City1': 100,
|
||||
'Region|City2': 50,
|
||||
Region: 150,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, false);
|
||||
|
||||
expect(keys[0]).toEqual(['Region', 'City1']);
|
||||
expect(keys[1]).toEqual(['Region', 'City2']);
|
||||
expect(keys[2]).toEqual(['Region']);
|
||||
});
|
||||
|
||||
test('should use alphabetical order for terminals with equal values', () => {
|
||||
const keys: string[][] = [
|
||||
['Group', 'Apple'],
|
||||
['Group', 'Banana'],
|
||||
['Group', 'Cherry'],
|
||||
];
|
||||
const data = {
|
||||
'Group|Apple': 50,
|
||||
'Group|Banana': 50,
|
||||
'Group|Cherry': 50,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
|
||||
expect(keys).toEqual([
|
||||
['Group', 'Apple'],
|
||||
['Group', 'Banana'],
|
||||
['Group', 'Cherry'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle null values gracefully', () => {
|
||||
const keys: string[][] = [['A'], ['B'], ['C']];
|
||||
const data = {
|
||||
A: 100,
|
||||
B: null,
|
||||
C: 50,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
||||
});
|
||||
|
||||
test('should handle string numbers', () => {
|
||||
const keys: string[][] = [['A'], ['B'], ['C']];
|
||||
const data = {
|
||||
A: '100',
|
||||
B: '50',
|
||||
C: '200',
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, false);
|
||||
expect(keys).toEqual([['C'], ['A'], ['B']]);
|
||||
});
|
||||
|
||||
test('should handle NaN values', () => {
|
||||
const keys: string[][] = [['A'], ['B'], ['C']];
|
||||
const data = {
|
||||
A: 100,
|
||||
B: NaN,
|
||||
C: 50,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
expect(keys).toEqual([['B'], ['C'], ['A']]);
|
||||
});
|
||||
|
||||
test('should handle single key', () => {
|
||||
const keys: string[][] = [['OnlyKey']];
|
||||
const data = { OnlyKey: 42 };
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
expect(keys).toEqual([['OnlyKey']]);
|
||||
});
|
||||
|
||||
test('should handle empty keys array', () => {
|
||||
const keys: string[][] = [];
|
||||
const data = {};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), false, true);
|
||||
expect(keys).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle product categories with subcategories', () => {
|
||||
const keys: string[][] = [
|
||||
['Electronics'],
|
||||
['Electronics', 'Phones'],
|
||||
['Electronics', 'Phones', 'iPhone'],
|
||||
['Electronics', 'Phones', 'Samsung'],
|
||||
['Electronics', 'Laptops'],
|
||||
['Electronics', 'Laptops', 'MacBook'],
|
||||
['Clothing'],
|
||||
['Clothing', 'Shirts'],
|
||||
['Clothing', 'Shirts', 'T-Shirt'],
|
||||
['Clothing', 'Pants'],
|
||||
['Clothing', 'Pants', 'Jeans'],
|
||||
];
|
||||
const data = {
|
||||
Electronics: 2100,
|
||||
'Electronics|Phones': 900,
|
||||
'Electronics|Phones|iPhone': 500,
|
||||
'Electronics|Phones|Samsung': 400,
|
||||
'Electronics|Laptops': 1200,
|
||||
'Electronics|Laptops|MacBook': 1200,
|
||||
Clothing: 2550,
|
||||
'Clothing|Shirts': 1400,
|
||||
'Clothing|Shirts|T-Shirt': 1400,
|
||||
'Clothing|Pants': 1150,
|
||||
'Clothing|Pants|Jeans': 1150,
|
||||
};
|
||||
|
||||
groupingValueSort(keys, createMockAggregator(data), true, true);
|
||||
|
||||
expect(keys[0]).toEqual(['Electronics']);
|
||||
expect(keys[1]).toEqual(['Electronics', 'Phones']);
|
||||
expect(keys[2]).toEqual(['Electronics', 'Phones', 'Samsung']);
|
||||
expect(keys[3]).toEqual(['Electronics', 'Phones', 'iPhone']);
|
||||
expect(keys[4]).toEqual(['Electronics', 'Laptops']);
|
||||
expect(keys[5]).toEqual(['Electronics', 'Laptops', 'MacBook']);
|
||||
expect(keys[6]).toEqual(['Clothing']);
|
||||
expect(keys[7]).toEqual(['Clothing', 'Pants']);
|
||||
expect(keys[8]).toEqual(['Clothing', 'Pants', 'Jeans']);
|
||||
expect(keys[9]).toEqual(['Clothing', 'Shirts']);
|
||||
expect(keys[10]).toEqual(['Clothing', 'Shirts', 'T-Shirt']);
|
||||
});
|
||||
|
||||
test('TableRenderer coerces numeric timestamp strings to numbers for row header date formatters', () => {
|
||||
const dateFormatter = jest.fn((val: unknown) => `row:${String(val)}`);
|
||||
const data = [
|
||||
|
||||
@@ -1371,7 +1371,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
)
|
||||
) : undefined,
|
||||
sortDescFirst: sortDesc,
|
||||
sortType: getSortTypeByDataType(dataType),
|
||||
// Metrics and percent metrics always have numeric values; use numeric sort
|
||||
// even if the backend reports the column type as String.
|
||||
sortType:
|
||||
isMetric || isPercentMetric
|
||||
? 'basic'
|
||||
: getSortTypeByDataType(dataType),
|
||||
};
|
||||
},
|
||||
[
|
||||
|
||||
@@ -2457,3 +2457,80 @@ describe('Drill-to-Detail Temporal Range Logic', () => {
|
||||
expect(filter.val).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// Numeric values with String dataType (e.g. backend mis-reports type for computed columns)
|
||||
// get 'alphanumeric' sort, which treats raw numbers as non-strings and produces unstable order.
|
||||
// They should sort numerically regardless of display format.
|
||||
test('sorts numeric-backed percentage column numerically when dataType is String', async () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
order_desc: false,
|
||||
metrics: ['pct'],
|
||||
column_config: {
|
||||
pct: { d3NumberFormat: '.1%' },
|
||||
},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['pct'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [
|
||||
{ pct: 0.4 },
|
||||
{ pct: 0.056 },
|
||||
{ pct: 0.506 },
|
||||
{ pct: 0.066 },
|
||||
{ pct: 0.41 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const header = screen.getByText('pct');
|
||||
fireEvent.click(header);
|
||||
|
||||
const cells = document.querySelectorAll('tbody td');
|
||||
const values = Array.from(cells).map(td => td.textContent);
|
||||
expect(values).toEqual(['5.6%', '6.6%', '40.0%', '41.0%', '50.6%']);
|
||||
});
|
||||
|
||||
test('sorts genuinely string columns alphanumerically', () => {
|
||||
const props = transformProps({
|
||||
...testData.raw,
|
||||
rawFormData: {
|
||||
...testData.raw.rawFormData,
|
||||
order_desc: false,
|
||||
metrics: [],
|
||||
columns: ['label'],
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
...testData.raw.queriesData[0],
|
||||
colnames: ['label'],
|
||||
coltypes: [GenericDataType.String],
|
||||
data: [{ label: 'banana' }, { label: 'apple' }, { label: 'cherry' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
render(
|
||||
ProviderWrapper({
|
||||
children: <TableChart {...props} sticky={false} />,
|
||||
}),
|
||||
);
|
||||
|
||||
const header = screen.getByText('label');
|
||||
fireEvent.click(header);
|
||||
|
||||
const cells = document.querySelectorAll('tbody td');
|
||||
const values = Array.from(cells).map(td => td.textContent);
|
||||
expect(values).toEqual(['apple', 'banana', 'cherry']);
|
||||
});
|
||||
|
||||
@@ -134,9 +134,11 @@ async function runOxlintAndProcess() {
|
||||
console.log('Running minimal ESLint for custom rules...');
|
||||
let eslintOutput = '[]';
|
||||
try {
|
||||
// Run ESLint and capture output directly
|
||||
// Run ESLint and capture output directly.
|
||||
// Flat config (eslint.config.minimal.js) is explicitly selected via
|
||||
// --config; ESLint v9+/v10 no longer support eslintrc or --no-eslintrc.
|
||||
eslintOutput = execSync(
|
||||
'npx eslint --no-eslintrc --config .eslintrc.minimal.js --no-inline-config --format json src',
|
||||
'npx eslint --config eslint.config.minimal.js --no-inline-config --format json src',
|
||||
{
|
||||
encoding: 'utf8',
|
||||
maxBuffer: 50 * 1024 * 1024,
|
||||
|
||||
@@ -349,11 +349,6 @@ const EditorWrapper = ({
|
||||
width: ${theme.sizeUnit * 130}px !important;
|
||||
}
|
||||
|
||||
.ace_completion-highlight {
|
||||
color: ${theme.colorPrimaryText} !important;
|
||||
background-color: ${theme.colorPrimaryBgHover};
|
||||
}
|
||||
|
||||
.ace_tooltip {
|
||||
max-width: ${SQL_EDITOR_LEFTBAR_WIDTH}px;
|
||||
}
|
||||
|
||||
@@ -425,6 +425,7 @@ const ResultSet = ({
|
||||
url: makeUrl('/api/v1/sqllab/export_streaming/'),
|
||||
payload: { client_id: query.id },
|
||||
exportType: 'csv',
|
||||
exportSource: 'sqllab',
|
||||
expectedRows: rows,
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
/**
|
||||
* 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 fetchMock from 'fetch-mock';
|
||||
import { JsonObject, QueryFormData, VizType } from '@superset-ui/core';
|
||||
import { getChartDataRequest } from 'src/components/Chart/chartAction';
|
||||
|
||||
/**
|
||||
* Integration (mocked-network) port of the deprecated Cypress spec
|
||||
* `cypress/e2e/dashboard/_skip.url_params.test.ts` (sc-107448).
|
||||
*
|
||||
* The original test loaded a dashboard with query-string params, intercepted
|
||||
* `/api/v1/chart/data`, and asserted each query in the request body carried
|
||||
* `url_params`. That assertion is request-construction logic — the form_data
|
||||
* → query-context pipeline — which is exercised here without a backend.
|
||||
*
|
||||
* Intentional narrowing: the URL-string → `form_data.url_params` hop (handled
|
||||
* in `src/dashboard/actions/hydrate.ts` via `extractUrlParams`) is not covered
|
||||
* here. This file verifies the chart-data side of the contract only; the
|
||||
* dashboard hydration side is covered by its own unit tests.
|
||||
*/
|
||||
const CHART_DATA_GLOB = 'glob:*/api/v1/chart/data*';
|
||||
const CHART_DATA_ROUTE = 'urlParamsForwarding-chartData';
|
||||
const URL_PARAMS = { param1: '123', param2: 'abc' };
|
||||
|
||||
type ChartDataRequestBody = {
|
||||
queries: JsonObject[];
|
||||
form_data: JsonObject;
|
||||
};
|
||||
|
||||
const buildFormData = (
|
||||
overrides: Partial<QueryFormData> = {},
|
||||
): QueryFormData => ({
|
||||
datasource: '1__table',
|
||||
granularity_sqla: 'ds',
|
||||
viz_type: VizType.Table,
|
||||
url_params: URL_PARAMS,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const lastChartDataBody = (): ChartDataRequestBody => {
|
||||
const calls = fetchMock.callHistory.calls(CHART_DATA_ROUTE);
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
return JSON.parse(
|
||||
calls[calls.length - 1].options.body as string,
|
||||
) as ChartDataRequestBody;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock.post(
|
||||
CHART_DATA_GLOB,
|
||||
{ result: [{ data: [] }] },
|
||||
{
|
||||
name: CHART_DATA_ROUTE,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
// Remove only this file's route so global routes registered in
|
||||
// setupSupersetClient (e.g. CSRF) survive into the next test.
|
||||
afterEach(() => {
|
||||
fetchMock.clearHistory();
|
||||
fetchMock.removeRoutes({ names: [CHART_DATA_ROUTE] });
|
||||
});
|
||||
|
||||
test('forwards url_params from form_data onto each query in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(Array.isArray(body.queries)).toBe(true);
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves url_params on form_data echoed back in the chart-data request body', async () => {
|
||||
await getChartDataRequest({ formData: buildFormData() });
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.form_data.url_params).toEqual(URL_PARAMS);
|
||||
});
|
||||
|
||||
// buildQueryObject defaults missing url_params to `{}` (see
|
||||
// packages/superset-ui-core/src/query/buildQueryObject.ts), so the chart-data
|
||||
// request body carries an empty object — not `undefined`. This test documents
|
||||
// that contract; a future change that flips the default should update both.
|
||||
test('emits an empty url_params object on each query when form_data has none', async () => {
|
||||
await getChartDataRequest({
|
||||
formData: buildFormData({ url_params: undefined }),
|
||||
});
|
||||
|
||||
const body = lastChartDataBody();
|
||||
expect(body.queries.length).toBeGreaterThan(0);
|
||||
body.queries.forEach(query => {
|
||||
expect(query.url_params).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -48,11 +48,11 @@ global.URL.revokeObjectURL = jest.fn();
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
global.fetch = jest.fn();
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
SupersetClient.getCSRFToken.mockResolvedValue('mock-csrf-token');
|
||||
SupersetClient.getGuestToken.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
@@ -228,6 +228,7 @@ test('sets ERROR status and calls onError when fetch rejects', async () => {
|
||||
// URL prefix guard tests - prevent regression of missing app root prefix
|
||||
const { applicationRoot } = jest.requireMock('src/utils/getBootstrapData');
|
||||
const { makeUrl } = jest.requireMock('src/utils/pathUtils');
|
||||
const { SupersetClient } = jest.requireMock('@superset-ui/core');
|
||||
|
||||
const createPrefixTestMockFetch = () =>
|
||||
jest.fn().mockResolvedValue({
|
||||
@@ -242,6 +243,107 @@ const createPrefixTestMockFetch = () =>
|
||||
},
|
||||
});
|
||||
|
||||
test('guest-token chart exports skip CSRF fetch and include guest_token form field', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
SupersetClient.getCSRFToken.mockRejectedValue(new Error('CSRF forbidden'));
|
||||
|
||||
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
|
||||
let readCount = 0;
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="embedded.csv"',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: jest.fn().mockImplementation(() => {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
return Promise.resolve({ done: false, value: csvData });
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
expectedRows: 100000,
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).not.toHaveBeenCalled();
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(body.get('guest_token')).toBe('guest-token');
|
||||
expect(body.get('expected_rows')).toBe('100000');
|
||||
expect(body.get('form_data')).toBe(
|
||||
JSON.stringify({ datasource: '1__table', viz_type: 'table' }),
|
||||
);
|
||||
});
|
||||
|
||||
test('non-guest chart exports fetch CSRF and include X-CSRFToken header', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
|
||||
const csvData = new TextEncoder().encode('id,name\n1,Alice\n');
|
||||
let readCount = 0;
|
||||
const mockFetch = jest.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
headers: new Headers({
|
||||
'Content-Disposition': 'attachment; filename="chart.csv"',
|
||||
}),
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: jest.fn().mockImplementation(() => {
|
||||
readCount += 1;
|
||||
if (readCount === 1) {
|
||||
return Promise.resolve({ done: false, value: csvData });
|
||||
}
|
||||
return Promise.resolve({ done: true, value: undefined });
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.progress.status).toBe(ExportStatus.COMPLETED);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect((requestInit.body as URLSearchParams).has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('chart streaming export includes guest token in form body when configured', async () => {
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
@@ -254,6 +356,7 @@ test('chart streaming export includes guest token in form body when configured',
|
||||
url: '/api/v1/chart/data',
|
||||
payload: { datasource: '1__table', viz_type: 'table' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'chart',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -268,6 +371,70 @@ test('chart streaming export includes guest token in form body when configured',
|
||||
);
|
||||
});
|
||||
|
||||
test('SQL Lab exports fetch CSRF and omit guest_token even when guest token exists', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/sqllab/export_streaming/',
|
||||
payload: { client_id: 'test-id' },
|
||||
exportType: 'csv',
|
||||
exportSource: 'sqllab',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect(body.get('client_id')).toBe('test-id');
|
||||
expect(body.has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('guest tokens do not bypass CSRF for unclassified non-client exports', async () => {
|
||||
applicationRoot.mockReturnValue('');
|
||||
SupersetClient.getGuestToken.mockReturnValue('guest-token');
|
||||
|
||||
const mockFetch = createPrefixTestMockFetch();
|
||||
global.fetch = mockFetch;
|
||||
|
||||
const { result } = renderHook(() => useStreamingExport());
|
||||
|
||||
act(() => {
|
||||
result.current.startExport({
|
||||
url: '/api/v1/other/export_streaming/',
|
||||
payload: { export_id: 'test-id' },
|
||||
exportType: 'csv',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
expect(SupersetClient.getCSRFToken).toHaveBeenCalledTimes(1);
|
||||
const [, requestInit] = mockFetch.mock.calls[0];
|
||||
const body = requestInit.body as URLSearchParams;
|
||||
|
||||
expect(requestInit.headers).toMatchObject({
|
||||
'X-CSRFToken': 'mock-csrf-token',
|
||||
});
|
||||
expect(body.has('guest_token')).toBe(false);
|
||||
});
|
||||
|
||||
test('URL prefix guard applies prefix to unprefixed relative URL when app root is configured', async () => {
|
||||
const appRoot = '/superset';
|
||||
applicationRoot.mockReturnValue(appRoot);
|
||||
|
||||
@@ -31,6 +31,8 @@ interface StreamingExportPayload {
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
type StreamingExportSource = 'chart' | 'sqllab';
|
||||
|
||||
interface StreamingExportParams {
|
||||
/**
|
||||
* The API endpoint URL for the export request.
|
||||
@@ -46,6 +48,7 @@ interface StreamingExportParams {
|
||||
payload: StreamingExportPayload;
|
||||
filename?: string;
|
||||
exportType: 'csv' | 'xlsx';
|
||||
exportSource?: StreamingExportSource;
|
||||
expectedRows?: number;
|
||||
}
|
||||
|
||||
@@ -95,6 +98,7 @@ const createFetchRequest = async (
|
||||
payload: StreamingExportPayload,
|
||||
filename: string | undefined,
|
||||
_exportType: string,
|
||||
exportSource: StreamingExportSource | undefined,
|
||||
expectedRows: number | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<RequestInit> => {
|
||||
@@ -102,10 +106,19 @@ const createFetchRequest = async (
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
};
|
||||
|
||||
// Get CSRF token using SupersetClient
|
||||
const csrfToken = await SupersetClient.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
const isGuestTokenChartExport =
|
||||
Boolean(guestToken) &&
|
||||
exportSource === 'chart' &&
|
||||
!('client_id' in payload);
|
||||
|
||||
// Embedded guest sessions cannot fetch CSRF tokens. Guest chart exports are
|
||||
// safe because chart data is CSRF-exempt and auth is carried by guest_token.
|
||||
if (!isGuestTokenChartExport) {
|
||||
const csrfToken = await SupersetClient.getCSRFToken();
|
||||
if (csrfToken) {
|
||||
headers['X-CSRFToken'] = csrfToken;
|
||||
}
|
||||
}
|
||||
|
||||
const formParams: Record<string, string> = {};
|
||||
@@ -118,8 +131,7 @@ const createFetchRequest = async (
|
||||
formParams.expected_rows = expectedRows.toString();
|
||||
}
|
||||
|
||||
const guestToken = SupersetClient.getGuestToken();
|
||||
if (guestToken) {
|
||||
if (guestToken && isGuestTokenChartExport) {
|
||||
formParams.guest_token = guestToken;
|
||||
}
|
||||
|
||||
@@ -185,7 +197,8 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
|
||||
|
||||
const executeExport = useCallback(
|
||||
async (params: StreamingExportParams) => {
|
||||
const { url, payload, filename, exportType, expectedRows } = params;
|
||||
const { url, payload, filename, exportType, exportSource, expectedRows } =
|
||||
params;
|
||||
if (isExportingRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -210,6 +223,7 @@ export const useStreamingExport = (options: UseStreamingExportOptions = {}) => {
|
||||
payload,
|
||||
filename,
|
||||
exportType,
|
||||
exportSource,
|
||||
expectedRows,
|
||||
abortControllerRef.current.signal,
|
||||
);
|
||||
|
||||
@@ -165,6 +165,62 @@ describe('DashboardBuilder', () => {
|
||||
expect(header).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should hide DashboardHeader when standalone mode hides nav and title (?standalone=2)', () => {
|
||||
// React-level equivalent of the legacy `cy.get('#app-menu').should('not.exist')`
|
||||
// Cypress assertion. The `#app-menu` node lives in Flask's spa.html template,
|
||||
// gated by `{% if standalone_mode %}`, so RTL cannot reach it directly.
|
||||
// `?standalone=2` maps to DashboardStandaloneMode.HideNavAndTitle, which the
|
||||
// DashboardBuilder honours by suppressing the React-side DashboardHeader.
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=2');
|
||||
try {
|
||||
const { queryByTestId } = setup();
|
||||
expect(
|
||||
queryByTestId('dashboard-header-container'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should keep the DashboardHeader when standalone mode only hides nav (?standalone=1)', () => {
|
||||
// `?standalone=1` maps to DashboardStandaloneMode.HideNav, which only hides the
|
||||
// Flask-rendered global app menu (#app-menu) — it must NOT suppress the React-side
|
||||
// DashboardHeader. This pins the boundary against HideNavAndTitle (?standalone=2).
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=1');
|
||||
try {
|
||||
const { queryByTestId } = setup();
|
||||
expect(queryByTestId('dashboard-header-container')).toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should keep the header hidden in standalone mode (?standalone=2) while editMode is active', () => {
|
||||
// Orthogonality analogue of the legacy `?edit=true&standalone=true` Cypress mount.
|
||||
// editMode is sourced from Redux (state.dashboardState.editMode), not the URL —
|
||||
// DashboardBuilder only reads URL_PARAMS.standalone — so the legacy `edit=true`
|
||||
// param is inert here and is intentionally omitted. Contract under test:
|
||||
// standalone=2 (HideNavAndTitle) suppresses DashboardHeader even while editMode
|
||||
// drives the `dashboard--editing` class on the wrapper.
|
||||
const originalHref = window.location.href;
|
||||
window.history.replaceState({}, '', '/?standalone=2');
|
||||
try {
|
||||
const { getByTestId, queryByTestId } = setup({
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
});
|
||||
expect(getByTestId('dashboard-content-wrapper')).toHaveClass(
|
||||
'dashboard dashboard--editing',
|
||||
);
|
||||
expect(
|
||||
queryByTestId('dashboard-header-container'),
|
||||
).not.toBeInTheDocument();
|
||||
} finally {
|
||||
window.history.replaceState({}, '', originalHref);
|
||||
}
|
||||
});
|
||||
|
||||
test('should render a Sticky top-level Tabs if the dashboard has tabs', async () => {
|
||||
const { findAllByTestId } = setup({
|
||||
dashboardLayout: undoableDashboardLayoutWithTabs,
|
||||
|
||||
@@ -319,6 +319,22 @@ test('disables overwrite option for externally managed slice', () => {
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('enables overwrite option for admin non-owner', () => {
|
||||
const { getByRole } = setup(
|
||||
{},
|
||||
mockStore({
|
||||
...initialState,
|
||||
user: {
|
||||
userId: 2,
|
||||
username: 'Admin2',
|
||||
roles: { Admin: Array(173) },
|
||||
permissions: {},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(getByRole('radio', { name: 'Save (Overwrite)' })).toBeEnabled();
|
||||
});
|
||||
|
||||
test('updates slice name and selected dashboard', async () => {
|
||||
const dashboardId = mockEvent.value;
|
||||
const saveDataset = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
@@ -49,7 +49,10 @@ import {
|
||||
} from '@apache-superset/core/theme';
|
||||
import { Radio } from '@superset-ui/core/components/Radio';
|
||||
import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants';
|
||||
import { canUserEditDashboard } from 'src/dashboard/util/permissionUtils';
|
||||
import {
|
||||
canUserEditDashboard,
|
||||
isUserAdmin,
|
||||
} from 'src/dashboard/util/permissionUtils';
|
||||
import { setSaveChartModalVisibility } from 'src/explore/actions/saveModalActions';
|
||||
import { SaveActionType, ChartStatusType } from 'src/explore/types';
|
||||
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||
@@ -125,7 +128,8 @@ class SaveModal extends Component<SaveModalProps, SaveModalState> {
|
||||
|
||||
canOverwriteSlice(): boolean {
|
||||
return (
|
||||
this.props.slice?.owners?.includes(this.props.user.userId) &&
|
||||
(isUserAdmin(this.props.user) ||
|
||||
this.props.slice?.owners?.includes(this.props.user.userId)) &&
|
||||
!this.props.slice?.is_managed_externally
|
||||
);
|
||||
}
|
||||
|
||||
@@ -99,6 +99,7 @@ interface ExportChartParams {
|
||||
url: string | null;
|
||||
payload: QueryFormData | ReturnType<typeof buildQueryContext>;
|
||||
exportType: string;
|
||||
exportSource: 'chart';
|
||||
}) => void)
|
||||
| null;
|
||||
}
|
||||
@@ -394,6 +395,7 @@ export const exportChart = async ({
|
||||
url: url ? ensureAppRoot(url) : url,
|
||||
payload,
|
||||
exportType: resultFormat,
|
||||
exportSource: 'chart',
|
||||
});
|
||||
} else {
|
||||
// SupersetClient.postForm calls getUrl({ endpoint }) internally, which prepends
|
||||
|
||||
@@ -168,6 +168,31 @@ test('non-text chart shows screenshot width and message content', () => {
|
||||
expect(screen.getByText('Screenshot width')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('screenshot width input preserves a typed zero instead of dropping it', () => {
|
||||
const lineChartProps = {
|
||||
...defaultProps,
|
||||
dashboardId: undefined,
|
||||
chart: { id: 1, sliceFormData: { viz_type: VizType.Line } },
|
||||
chartName: 'My Line Chart',
|
||||
creationMethod: 'charts' as const,
|
||||
};
|
||||
render(<ReportModal {...lineChartProps} />, { useRedux: true });
|
||||
|
||||
const widthInput = screen.getByPlaceholderText(
|
||||
'Input custom width in pixels',
|
||||
);
|
||||
|
||||
// The old `|| null` / `|| ''` logic silently coerced a typed 0 to null, so the
|
||||
// invalid width was swallowed instead of being submitted and surfaced by the
|
||||
// server's min-width validation. The field must preserve the literal value.
|
||||
userEvent.type(widthInput, '0');
|
||||
expect(widthInput).toHaveDisplayValue('0');
|
||||
|
||||
// Clearing the field still yields an empty value (parsed NaN → null).
|
||||
userEvent.clear(widthInput);
|
||||
expect(widthInput).toHaveDisplayValue('');
|
||||
});
|
||||
|
||||
test('dashboard report hides message content section', () => {
|
||||
const dashboardProps = {
|
||||
...defaultProps,
|
||||
|
||||
@@ -296,11 +296,12 @@ function ReportModal({
|
||||
<Input
|
||||
type="number"
|
||||
name="custom_width"
|
||||
value={currentReport?.custom_width || ''}
|
||||
value={currentReport?.custom_width ?? ''}
|
||||
placeholder={t('Input custom width in pixels')}
|
||||
onChange={(event: ChangeEvent<HTMLInputElement>) => {
|
||||
const parsedWidth = parseInt(event.target.value, 10);
|
||||
setCurrentReport({
|
||||
custom_width: parseInt(event.target.value, 10) || null,
|
||||
custom_width: Number.isNaN(parsedWidth) ? null : parsedWidth,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
Generated
+7
-7
@@ -23,7 +23,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
@@ -1798,9 +1798,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -7883,9 +7883,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"version": "25.9.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.2.tgz",
|
||||
"integrity": "sha512-G05zqtJhcDLb8uslf5EjCxXg9G1KQxiV8OS0R26IC//Eoyitzqe8z37I7cqvnZlrlSfgocQRfSn/AHBZJJFyGw==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@types/jest": "^29.5.14",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/node": "^25.9.2",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.60.1",
|
||||
"@typescript-eslint/parser": "^8.60.1",
|
||||
|
||||
@@ -365,7 +365,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
|
||||
return self.response_400(message=error.messages)
|
||||
try:
|
||||
new_model = CreateChartCommand(item).run()
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
return self.response(201, id=new_model.id, result=item, uuid=new_model.uuid)
|
||||
except DashboardsForbiddenError as ex:
|
||||
return self.response(ex.status, message=ex.message)
|
||||
except ChartInvalidError as ex:
|
||||
|
||||
@@ -524,8 +524,11 @@ class ChartDataRestApi(ChartRestApi):
|
||||
# return multi-query results bundled as a zip file
|
||||
def _process_data(query_data: Any) -> Any:
|
||||
if result_format == ChartDataResultFormat.CSV:
|
||||
encoding = app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
return query_data.encode(encoding)
|
||||
# CSV data is already encoded to bytes by the query context
|
||||
# processor, honoring the CSV_EXPORT encoding config.
|
||||
if isinstance(query_data, str):
|
||||
encoding = app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
return query_data.encode(encoding)
|
||||
return query_data
|
||||
|
||||
files = {
|
||||
|
||||
@@ -1454,6 +1454,21 @@ class ChartDataQueryObjectSchema(Schema):
|
||||
allow_none=True,
|
||||
)
|
||||
|
||||
@post_load
|
||||
def rename_deprecated_fields(
|
||||
self, data: dict[str, Any], **kwargs: Any
|
||||
) -> dict[str, Any]:
|
||||
_renames = (
|
||||
("groupby", "columns"),
|
||||
("granularity_sqla", "granularity"),
|
||||
("timeseries_limit", "series_limit"),
|
||||
("timeseries_limit_metric", "series_limit_metric"),
|
||||
)
|
||||
for old, new in _renames:
|
||||
if value := data.pop(old, None):
|
||||
data[new] = value
|
||||
return data
|
||||
|
||||
|
||||
class ChartDataQueryContextSchema(Schema):
|
||||
query_context_factory: QueryContextFactory | None = None
|
||||
|
||||
+33
-5
@@ -31,7 +31,7 @@ from flask_appbuilder.api.manager import resolver
|
||||
|
||||
import superset.utils.database as database_utils
|
||||
from superset.utils.decorators import transaction
|
||||
from superset.utils.encrypt import SecretsMigrator
|
||||
from superset.utils.encrypt import ENCRYPTION_ENGINES, SecretsMigrator
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -110,17 +110,45 @@ def update_api_docs() -> None:
|
||||
help="An optional previous secret key, if PREVIOUS_SECRET_KEY "
|
||||
"is not set on the config",
|
||||
)
|
||||
def re_encrypt_secrets(previous_secret_key: Optional[str] = None) -> None:
|
||||
@click.option(
|
||||
"--engine",
|
||||
"-e",
|
||||
"target_engine_name",
|
||||
required=False,
|
||||
type=click.Choice(sorted(ENCRYPTION_ENGINES), case_sensitive=False),
|
||||
help="Re-encrypt all app-encrypted fields with this encryption engine "
|
||||
"(e.g. 'aes-gcm' for authenticated encryption). The SECRET_KEY is "
|
||||
"unchanged. Take a metadata-DB backup first, then set "
|
||||
"SQLALCHEMY_ENCRYPTED_FIELD_ENGINE to the same value and restart.",
|
||||
)
|
||||
def re_encrypt_secrets(
|
||||
previous_secret_key: Optional[str] = None,
|
||||
target_engine_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Re-encrypt every app-encrypted field via :class:`SecretsMigrator`.
|
||||
|
||||
Supports key rotation (``previous_secret_key``, falling back to the
|
||||
``PREVIOUS_SECRET_KEY`` config) and engine migration (``target_engine_name``,
|
||||
a case-insensitive ``ENCRYPTION_ENGINES`` key such as ``aes-gcm``); the two
|
||||
can combine. With neither provided the command is a no-op. Exits non-zero on
|
||||
failure.
|
||||
"""
|
||||
previous_secret_key = previous_secret_key or current_app.config.get(
|
||||
"PREVIOUS_SECRET_KEY"
|
||||
)
|
||||
if previous_secret_key is None:
|
||||
target_engine = (
|
||||
ENCRYPTION_ENGINES[target_engine_name] if target_engine_name else None
|
||||
)
|
||||
if previous_secret_key is None and target_engine is None:
|
||||
click.secho(
|
||||
"No previous secret key provided; nothing to re-encrypt.",
|
||||
"No previous secret key or target engine provided; nothing to re-encrypt.",
|
||||
fg="yellow",
|
||||
)
|
||||
return
|
||||
secrets_migrator = SecretsMigrator(previous_secret_key=previous_secret_key)
|
||||
secrets_migrator = SecretsMigrator(
|
||||
previous_secret_key=previous_secret_key,
|
||||
target_engine=target_engine,
|
||||
)
|
||||
try:
|
||||
stats = secrets_migrator.run()
|
||||
except Exception as exc: # pylint: disable=broad-except
|
||||
|
||||
@@ -326,7 +326,7 @@ def import_dashboard( # noqa: C901
|
||||
if dashboard.id is None:
|
||||
db.session.flush()
|
||||
|
||||
if (user := get_user()) and user not in dashboard.owners:
|
||||
if not existing and user and user not in dashboard.owners:
|
||||
dashboard.owners.append(user)
|
||||
|
||||
# Re-attach DASHBOARD_RBAC role assignments by name. Role IDs are
|
||||
|
||||
@@ -33,6 +33,7 @@ from superset.commands.database.exceptions import (
|
||||
from superset.commands.database.ssh_tunnel.exceptions import (
|
||||
SSHTunnelCreateFailedError,
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelInvalidError,
|
||||
)
|
||||
@@ -75,6 +76,7 @@ class CreateDatabaseCommand(BaseCommand):
|
||||
SupersetErrorsException,
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
) as ex:
|
||||
event_logger.log_with_context(
|
||||
action=f"db_creation_failed.{ex.__class__.__name__}",
|
||||
|
||||
@@ -118,6 +118,11 @@ class DatabaseUploadFailed(CommandException):
|
||||
message = _("Database upload file failed")
|
||||
|
||||
|
||||
class DatabaseUploadFileTooLarge(CommandException):
|
||||
status = 413
|
||||
message = _("Database upload file exceeds the maximum allowed size.")
|
||||
|
||||
|
||||
class DatabaseUploadSaveMetadataFailed(CommandException):
|
||||
status = 500
|
||||
message = _("Database upload file failed, while saving metadata")
|
||||
|
||||
@@ -75,3 +75,11 @@ class SSHTunnelMissingCredentials(CommandInvalidError, SSHTunnelError): # noqa:
|
||||
|
||||
class SSHTunnelInvalidCredentials(CommandInvalidError, SSHTunnelError): # noqa: N818
|
||||
message = _("Cannot have multiple credentials for the SSH Tunnel")
|
||||
|
||||
|
||||
class SSHTunnelHostKeyVerificationError(CommandInvalidError, SSHTunnelError):
|
||||
"""The SSH server's host key failed opt-in verification for a tunnel."""
|
||||
|
||||
message = _(
|
||||
"The SSH server host key could not be verified against the expected key."
|
||||
)
|
||||
|
||||
@@ -29,6 +29,7 @@ from superset.commands.database.exceptions import (
|
||||
)
|
||||
from superset.commands.database.ssh_tunnel.exceptions import (
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
SSHTunnelingNotEnabledError,
|
||||
)
|
||||
from superset.commands.database.utils import ping
|
||||
@@ -221,7 +222,11 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
engine=engine_name,
|
||||
)
|
||||
raise DatabaseSecurityUnsafeError(message=str(ex)) from ex
|
||||
except (SupersetTimeoutException, SSHTunnelingNotEnabledError) as ex:
|
||||
except (
|
||||
SupersetTimeoutException,
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
) as ex:
|
||||
event_logger.log_with_context(
|
||||
action=get_log_connection_action(
|
||||
"test_connection_error",
|
||||
@@ -230,7 +235,8 @@ class TestConnectionDatabaseCommand(BaseCommand):
|
||||
),
|
||||
engine=engine_name,
|
||||
)
|
||||
# bubble up the exception to return proper status code
|
||||
# bubble up the exception (preserving its specific message and status)
|
||||
# instead of flattening it into a generic connection failure
|
||||
raise
|
||||
except Exception as ex:
|
||||
if not database:
|
||||
|
||||
@@ -20,6 +20,7 @@ from functools import partial
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
import pandas as pd
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from werkzeug.datastructures import FileStorage
|
||||
|
||||
@@ -29,6 +30,7 @@ from superset.commands.database.exceptions import (
|
||||
DatabaseNotFoundError,
|
||||
DatabaseSchemaUploadNotAllowed,
|
||||
DatabaseUploadFailed,
|
||||
DatabaseUploadFileTooLarge,
|
||||
DatabaseUploadNotSupported,
|
||||
DatabaseUploadSaveMetadataFailed,
|
||||
)
|
||||
@@ -188,6 +190,43 @@ class UploadCommand(BaseCommand):
|
||||
|
||||
sqla_table.fetch_metadata()
|
||||
|
||||
@staticmethod
|
||||
def _file_size_bytes(file: Any) -> Optional[int]:
|
||||
"""
|
||||
Return the size of an uploaded file without consuming its stream.
|
||||
|
||||
Returns ``None`` when the stream is not seekable, in which case the
|
||||
size cannot be determined cheaply and the size check is skipped in
|
||||
favour of downstream guards.
|
||||
"""
|
||||
stream = getattr(file, "stream", file)
|
||||
try:
|
||||
position = stream.tell()
|
||||
stream.seek(0, 2) # seek to end
|
||||
size = stream.tell()
|
||||
stream.seek(position) # restore the original position
|
||||
except (AttributeError, OSError):
|
||||
return None
|
||||
return size
|
||||
|
||||
@classmethod
|
||||
def validate_file_size(cls, file: Any) -> None:
|
||||
"""
|
||||
Reject a file whose size exceeds ``UPLOAD_MAX_FILE_SIZE_BYTES``.
|
||||
|
||||
Shared by the upload command and the metadata endpoint so oversized
|
||||
files are rejected before their contents are read into memory,
|
||||
regardless of which path is used.
|
||||
|
||||
:raises DatabaseUploadFileTooLarge: if the file is larger than the limit
|
||||
"""
|
||||
max_file_size = current_app.config.get("UPLOAD_MAX_FILE_SIZE_BYTES")
|
||||
if max_file_size is None or file is None:
|
||||
return
|
||||
size = cls._file_size_bytes(file)
|
||||
if size is not None and size > max_file_size:
|
||||
raise DatabaseUploadFileTooLarge()
|
||||
|
||||
def validate(self) -> None:
|
||||
self._model = DatabaseDAO.find_by_id(self._model_id)
|
||||
if not self._model:
|
||||
@@ -196,3 +235,5 @@ class UploadCommand(BaseCommand):
|
||||
raise DatabaseSchemaUploadNotAllowed()
|
||||
if not self._model.db_engine_spec.supports_file_upload:
|
||||
raise DatabaseUploadNotSupported()
|
||||
|
||||
self.validate_file_size(self._file)
|
||||
|
||||
@@ -23,7 +23,6 @@ from zipfile import BadZipfile, is_zipfile, ZipFile
|
||||
|
||||
import pandas as pd
|
||||
import pyarrow.parquet as pq
|
||||
from flask import current_app
|
||||
from flask_babel import lazy_gettext as _
|
||||
from pyarrow.lib import ArrowException
|
||||
from werkzeug.datastructures import FileStorage
|
||||
@@ -40,41 +39,6 @@ from superset.utils.core import check_is_safe_zip
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _check_file_size(file: FileStorage) -> None:
|
||||
"""
|
||||
Reject an uploaded file whose raw (on-the-wire) size exceeds the configured
|
||||
limit before its contents are buffered into memory.
|
||||
|
||||
This is complementary to the ZIP decompression-ratio guard: it bounds the
|
||||
raw bytes accepted regardless of whether the payload is compressed.
|
||||
|
||||
:param file: The uploaded file to check.
|
||||
:throws DatabaseUploadFailed: if the file exceeds the configured limit.
|
||||
"""
|
||||
max_size = current_app.config.get("UPLOAD_MAX_FILE_SIZE_BYTES")
|
||||
if not max_size:
|
||||
return
|
||||
stream = file.stream
|
||||
try:
|
||||
current_position = stream.tell()
|
||||
stream.seek(0, 2) # seek to end
|
||||
size = stream.tell()
|
||||
stream.seek(current_position)
|
||||
except (AttributeError, OSError):
|
||||
# If the stream is not seekable we cannot determine the size cheaply;
|
||||
# skip the check and rely on downstream guards.
|
||||
return
|
||||
if size > max_size:
|
||||
raise DatabaseUploadFailed(
|
||||
_(
|
||||
"File size %(size)s bytes exceeds the maximum allowed "
|
||||
"upload size of %(max_size)s bytes",
|
||||
size=size,
|
||||
max_size=max_size,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class ColumnarReaderOptions(ReaderOptions, total=False):
|
||||
columns_read: list[str]
|
||||
|
||||
@@ -118,7 +82,6 @@ class ColumnarReader(BaseDataReader):
|
||||
:param file: The file to yield files from.
|
||||
:return: A generator that yields files.
|
||||
"""
|
||||
_check_file_size(file)
|
||||
file_suffix = Path(file.filename).suffix
|
||||
if not file_suffix:
|
||||
raise DatabaseUploadFailed(_("Unexpected no file extension found"))
|
||||
|
||||
@@ -16,9 +16,12 @@
|
||||
# under the License.
|
||||
import gzip
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from urllib import request
|
||||
from urllib.parse import urljoin, urlparse
|
||||
from urllib.request import HTTPRedirectHandler
|
||||
|
||||
import pandas as pd
|
||||
from flask import current_app as app
|
||||
@@ -40,9 +43,35 @@ from superset.models.core import Database
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user
|
||||
from superset.utils.network import is_safe_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _ValidatingRedirectHandler(HTTPRedirectHandler):
|
||||
"""Re-validates the redirect target URL before following any HTTP redirect.
|
||||
|
||||
Prevents bypasses where an initial URL passes validation but a subsequent
|
||||
redirect points to a disallowed destination.
|
||||
"""
|
||||
|
||||
def redirect_request(
|
||||
self,
|
||||
req: request.Request,
|
||||
fp: Any,
|
||||
code: int,
|
||||
msg: str,
|
||||
headers: Any,
|
||||
newurl: str,
|
||||
) -> request.Request | None:
|
||||
"""Validate each redirect target before delegating to the parent handler."""
|
||||
# Resolve relative redirects against the originating request URL so that
|
||||
# validate_data_uri receives a fully-qualified URL in all cases.
|
||||
absolute_url = urljoin(req.full_url, newurl)
|
||||
validate_data_uri(absolute_url)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
CHUNKSIZE = 512
|
||||
VARCHAR = re.compile(r"VARCHAR\((\d+)\)", re.IGNORECASE)
|
||||
|
||||
@@ -88,12 +117,37 @@ def get_dtype(df: pd.DataFrame, dataset: SqlaTable) -> dict[str, VisitableType]:
|
||||
|
||||
def validate_data_uri(data_uri: str) -> None:
|
||||
"""
|
||||
Validate that the data URI is configured on DATASET_IMPORT_ALLOWED_URLS
|
||||
has a valid URL.
|
||||
Validate that the data URI is permitted for dataset import.
|
||||
|
||||
:param data_uri:
|
||||
:return:
|
||||
Local ``file://`` URIs are allowed only when the path is confined to the
|
||||
bundled examples folder. All other URIs must match a pattern in
|
||||
``DATASET_IMPORT_ALLOWED_DATA_URLS`` *and* resolve to a publicly-routable host.
|
||||
|
||||
:param data_uri: the URI to validate
|
||||
:raises DatasetForbiddenDataURI: if the URI is not permitted
|
||||
"""
|
||||
parsed = urlparse(data_uri)
|
||||
# ``urlparse`` lower-cases the scheme, so gating on it (rather than a
|
||||
# case-sensitive ``startswith("file://")``) also rejects mixed-case
|
||||
# variants like ``FiLe://`` that would otherwise skip the local-file
|
||||
# sandbox check below.
|
||||
if parsed.scheme == "file":
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from superset.examples.helpers import get_examples_folder
|
||||
|
||||
# Reject non-local authority components (e.g. file://remotehost/path).
|
||||
if parsed.netloc and parsed.netloc.lower() != "localhost":
|
||||
raise DatasetForbiddenDataURI()
|
||||
# url2pathname handles URL-encoded characters and platform path separators.
|
||||
file_path = url2pathname(parsed.path)
|
||||
# Resolve symlinks and relative components before comparing.
|
||||
real_path = os.path.realpath(file_path)
|
||||
examples_folder = os.path.realpath(get_examples_folder())
|
||||
if not real_path.startswith(examples_folder + os.sep):
|
||||
raise DatasetForbiddenDataURI()
|
||||
return
|
||||
|
||||
allowed_urls = app.config["DATASET_IMPORT_ALLOWED_DATA_URLS"]
|
||||
for allowed_url in allowed_urls:
|
||||
try:
|
||||
@@ -104,6 +158,12 @@ def validate_data_uri(data_uri: str) -> None:
|
||||
)
|
||||
raise
|
||||
if match:
|
||||
if not app.config["DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS"]:
|
||||
hostname = parsed.hostname
|
||||
# Fail-closed: reject URIs that have no parseable hostname as
|
||||
# well as those that resolve to non-public addresses.
|
||||
if not hostname or not is_safe_host(hostname):
|
||||
raise DatasetForbiddenDataURI()
|
||||
return
|
||||
raise DatasetForbiddenDataURI()
|
||||
|
||||
@@ -282,7 +342,8 @@ def load_data(data_uri: str, dataset: SqlaTable, database: Database) -> None:
|
||||
|
||||
validate_data_uri(data_uri)
|
||||
logger.info("Downloading data from %s", data_uri)
|
||||
data = request.urlopen(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
opener = request.build_opener(_ValidatingRedirectHandler)
|
||||
data = opener.open(data_uri) # pylint: disable=consider-using-with # noqa: S310
|
||||
if data_uri.endswith(".gz"):
|
||||
data = gzip.open(data)
|
||||
df = pd.read_csv(data, encoding="utf-8")
|
||||
|
||||
@@ -358,6 +358,11 @@ class BaseReportState:
|
||||
dashboard_id=str(self._report_schedule.dashboard.uuid),
|
||||
state=dashboard_state,
|
||||
).run()
|
||||
# Commit the permalink immediately so Playwright's separate DB connection
|
||||
# can resolve the URL. CreateDashboardPermalinkCommand only flushes when
|
||||
# called inside an outer @transaction(), leaving the row invisible to
|
||||
# other connections until we explicitly commit here.
|
||||
db.session.commit() # pylint: disable=consider-using-transaction
|
||||
|
||||
return get_url_path(
|
||||
"Superset.dashboard_permalink",
|
||||
|
||||
@@ -87,7 +87,7 @@ class QueryContext:
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
coltypes: list[GenericDataType],
|
||||
) -> str | list[dict[str, Any]]:
|
||||
) -> str | bytes | list[dict[str, Any]]:
|
||||
return self._processor.get_data(df, coltypes)
|
||||
|
||||
def get_payload(
|
||||
|
||||
@@ -257,7 +257,7 @@ class QueryContextProcessor:
|
||||
|
||||
def get_data(
|
||||
self, df: pd.DataFrame, coltypes: list[GenericDataType]
|
||||
) -> str | list[dict[str, Any]]:
|
||||
) -> str | bytes | list[dict[str, Any]]:
|
||||
if self._query_context.result_format in ChartDataResultFormat.table_like():
|
||||
include_index = not isinstance(df.index, pd.RangeIndex)
|
||||
columns = list(df.columns)
|
||||
@@ -270,6 +270,11 @@ class QueryContextProcessor:
|
||||
result = csv.df_to_escaped_csv(
|
||||
df, index=include_index, **current_app.config["CSV_EXPORT"]
|
||||
)
|
||||
# Encode using the configured CSV_EXPORT encoding (default utf-8)
|
||||
# so dashboard chart exports honor the same encoding as SQL Lab.
|
||||
result = result.encode(
|
||||
current_app.config["CSV_EXPORT"].get("encoding", "utf-8")
|
||||
)
|
||||
elif self._query_context.result_format == ChartDataResultFormat.XLSX:
|
||||
excel.apply_column_types(df, coltypes)
|
||||
result = excel.df_to_excel(
|
||||
|
||||
+63
-3
@@ -289,7 +289,10 @@ SQLALCHEMY_CUSTOM_PASSWORD_STORE = None
|
||||
# as key material. Do note that AesEngine allows for queryability over the
|
||||
# encrypted fields.
|
||||
#
|
||||
# To change the default engine you need to define your own adapter:
|
||||
# To switch the engine used by the default adapter, prefer the
|
||||
# ``SQLALCHEMY_ENCRYPTED_FIELD_ENGINE`` knob below (e.g. "aes-gcm"). Defining a
|
||||
# custom adapter, as shown next, is only needed for behaviour the built-in
|
||||
# engines do not cover:
|
||||
#
|
||||
# e.g.:
|
||||
#
|
||||
@@ -314,6 +317,16 @@ SQLALCHEMY_ENCRYPTED_FIELD_TYPE_ADAPTER = ( # pylint: disable=invalid-name
|
||||
SQLAlchemyUtilsAdapter
|
||||
)
|
||||
|
||||
# Encryption engine used by the default SQLAlchemyUtilsAdapter for app-encrypted
|
||||
# fields. Options:
|
||||
# "aes" - AES-CBC (historical default; unauthenticated, queryable)
|
||||
# "aes-gcm" - AES-GCM (authenticated encryption; recommended for NEW installs)
|
||||
# WARNING: changing this on a database that already holds encrypted secrets
|
||||
# (database passwords, SSH tunnel credentials, OAuth tokens, ...) will make
|
||||
# those values undecryptable unless they are re-encrypted first. See the
|
||||
# authenticated-encryption SIP/migration before switching an existing install.
|
||||
SQLALCHEMY_ENCRYPTED_FIELD_ENGINE: Literal["aes", "aes-gcm"] = "aes"
|
||||
|
||||
# Extends the default SQLGlot dialects with additional dialects
|
||||
SQLGLOT_DIALECTS_EXTENSIONS: DialectExtensions | Callable[[], DialectExtensions] = {}
|
||||
|
||||
@@ -367,6 +380,21 @@ AUTH_RATE_LIMIT = "5 per second"
|
||||
# (e.g. accounts provisioned by an administrator) are redirected to the
|
||||
# password-reset page until they set a new password. Off by default.
|
||||
ENABLE_FORCE_PASSWORD_CHANGE = False
|
||||
|
||||
# Password complexity policy, enforced (via Flask-AppBuilder) across
|
||||
# self-registration, the user edit/reset forms, and the User REST API.
|
||||
# The Superset validator requires a minimum length and rejects common
|
||||
# passwords; tune via AUTH_PASSWORD_MIN_LENGTH / AUTH_PASSWORD_COMMON_BLOCKLIST,
|
||||
# or replace FAB_PASSWORD_COMPLEXITY_VALIDATOR with your own callable.
|
||||
from superset.security.password_complexity import ( # noqa: E402
|
||||
validate_password_complexity as _validate_password_complexity,
|
||||
)
|
||||
|
||||
FAB_PASSWORD_COMPLEXITY_ENABLED = True
|
||||
FAB_PASSWORD_COMPLEXITY_VALIDATOR = _validate_password_complexity
|
||||
AUTH_PASSWORD_MIN_LENGTH = 8
|
||||
AUTH_PASSWORD_COMMON_BLOCKLIST: list[str] = []
|
||||
|
||||
# A storage location conforming to the scheme in storage-scheme. See the limits
|
||||
# library for allowed values: https://limits.readthedocs.io/en/stable/storage.html
|
||||
# RATELIMIT_STORAGE_URI = "redis://host:port"
|
||||
@@ -867,6 +895,15 @@ SSH_TUNNEL_TIMEOUT_SEC = 10.0
|
||||
#: Timeout (seconds) for transport socket (``socket.settimeout``)
|
||||
SSH_TUNNEL_PACKET_TIMEOUT_SEC = 1.0
|
||||
|
||||
#: Opt-in defense-in-depth: when enabled, every SSH tunnel must declare an expected
|
||||
#: server host key (``server_host_key`` on the tunnel) and the SSH server's presented
|
||||
#: host key is verified against it before the tunnel is opened. A mismatch, or a
|
||||
#: missing expected key while this flag is enabled, fails closed and the tunnel is
|
||||
#: rejected. When disabled (the default), tunnels without a ``server_host_key`` open
|
||||
#: without host-key verification, preserving existing behavior; tunnels that do set a
|
||||
#: ``server_host_key`` are still verified regardless of this flag.
|
||||
SSH_TUNNEL_STRICT_HOST_KEY_CHECKING: bool = False
|
||||
|
||||
|
||||
# Feature flags may also be set via 'SUPERSET_FEATURE_' prefixed environment vars.
|
||||
DEFAULT_FEATURE_FLAGS.update(
|
||||
@@ -2058,6 +2095,21 @@ REPORT_MINIMUM_INTERVAL = int(timedelta(minutes=0).total_seconds())
|
||||
# Enforce HTTPS for webhook alerts/reports
|
||||
ALERT_REPORTS_WEBHOOK_HTTPS_ONLY = True
|
||||
|
||||
# When True, webhook alert/report dispatch is permitted to call private/internal
|
||||
# IP addresses (RFC-1918, loopback, link-local). Intended for deployments where
|
||||
# the webhook target is on an internal network (a chatops bridge, an internal
|
||||
# Mattermost/Rocket.Chat, an automation server, etc.). Leave False (the default)
|
||||
# in any internet-facing deployment.
|
||||
ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS: bool = False
|
||||
|
||||
# When True, Impala's cancel_query HTTP call is permitted to target hosts in
|
||||
# private/internal IP ranges (RFC-1918, loopback, link-local). Intended for
|
||||
# operators whose Impala cluster runs on an internal network. Leave False (the
|
||||
# default) in any deployment where untrusted users can create Impala database
|
||||
# connections, so a maliciously-configured impala:// URL cannot be used to
|
||||
# trigger outbound requests to internal targets via the cancel endpoint.
|
||||
IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS: bool = False
|
||||
|
||||
# A custom prefix to use on all Alerts & Reports emails
|
||||
EMAIL_REPORTS_SUBJECT_PREFIX = "[Report] "
|
||||
|
||||
@@ -2347,13 +2399,21 @@ PREVENT_UNSAFE_DB_CONNECTIONS = True
|
||||
# If true all default urls on datasets will be handled as relative URLs by the frontend
|
||||
PREVENT_UNSAFE_DEFAULT_URLS_ON_DATASET = True
|
||||
|
||||
# Define a list of allowed URLs for dataset data imports (v1).
|
||||
# Define a list of allowed URL patterns (regex) for dataset data imports (v1).
|
||||
# Simple example to only allow URLs that belong to certain domains:
|
||||
# ALLOWED_IMPORT_URL_DOMAINS = [
|
||||
# DATASET_IMPORT_ALLOWED_DATA_URLS = [
|
||||
# r"^https://.+\.domain1\.com\/?.*", r"^https://.+\.domain2\.com\/?.*"
|
||||
# ]
|
||||
# Local file:// URIs used for bundled example data are always permitted
|
||||
# regardless of this setting.
|
||||
DATASET_IMPORT_ALLOWED_DATA_URLS = [r".*"]
|
||||
|
||||
# When True, dataset import is permitted to fetch data from private/internal
|
||||
# IP addresses (RFC-1918, loopback, link-local). Intended for air-gapped or
|
||||
# on-premises deployments where the data source is on an internal network.
|
||||
# Leave False (the default) in any internet-facing deployment.
|
||||
DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS: bool = False
|
||||
|
||||
# Path used to store SSL certificates that are generated when using custom certs.
|
||||
# Defaults to temporary directory.
|
||||
# Example: SSL_CERT_PATH = "/certs"
|
||||
|
||||
@@ -754,7 +754,7 @@ class DashboardRestApi(CustomTagsOptimizationMixin, BaseSupersetModelRestApi):
|
||||
return self.response_400(message=error.messages)
|
||||
try:
|
||||
new_model = CreateDashboardCommand(item).run()
|
||||
return self.response(201, id=new_model.id, result=item)
|
||||
return self.response(201, id=new_model.id, result=item, uuid=new_model.uuid)
|
||||
except DashboardInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except DashboardCreateFailedError as ex:
|
||||
|
||||
@@ -56,6 +56,7 @@ from superset.commands.database.importers.dispatcher import ImportDatabasesComma
|
||||
from superset.commands.database.oauth2 import OAuth2StoreTokenCommand
|
||||
from superset.commands.database.ssh_tunnel.exceptions import (
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
SSHTunnelingNotEnabledError,
|
||||
)
|
||||
from superset.commands.database.sync_permissions import SyncPermissionsCommand
|
||||
@@ -484,7 +485,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
|
||||
except (
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
) as ex:
|
||||
return self.response_400(message=str(ex))
|
||||
except SupersetException as ex:
|
||||
return self.response(ex.status, message=ex.message)
|
||||
@@ -569,7 +574,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
exc_info=True,
|
||||
)
|
||||
return self.response_422(message=str(ex))
|
||||
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
|
||||
except (
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
) as ex:
|
||||
return self.response_400(message=str(ex))
|
||||
|
||||
@expose("/<int:pk>", methods=("DELETE",))
|
||||
@@ -1291,7 +1300,11 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
try:
|
||||
TestConnectionDatabaseCommand(item).run()
|
||||
return self.response(200, message="OK")
|
||||
except (SSHTunnelingNotEnabledError, SSHTunnelDatabasePortError) as ex:
|
||||
except (
|
||||
SSHTunnelingNotEnabledError,
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
) as ex:
|
||||
return self.response_400(message=str(ex))
|
||||
|
||||
@expose("/<int:pk>/related_objects/", methods=("GET",))
|
||||
@@ -1718,6 +1731,15 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
413:
|
||||
description: Payload too large, file exceeds the maximum allowed size
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
500:
|
||||
$ref: '#/components/responses/500'
|
||||
"""
|
||||
@@ -1727,6 +1749,7 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
parameters = UploadFileMetadataPostSchema().load(request_form)
|
||||
except ValidationError as error:
|
||||
return self.response_400(message=error.messages)
|
||||
UploadCommand.validate_file_size(parameters["file"])
|
||||
if parameters["type"] == UploadFileType.CSV.value:
|
||||
metadata = CSVReader(parameters).file_metadata(parameters["file"])
|
||||
elif parameters["type"] == UploadFileType.EXCEL.value:
|
||||
@@ -1777,6 +1800,15 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
|
||||
$ref: '#/components/responses/401'
|
||||
404:
|
||||
$ref: '#/components/responses/404'
|
||||
413:
|
||||
description: Payload too large, file exceeds the maximum allowed size
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
422:
|
||||
$ref: '#/components/responses/422'
|
||||
500:
|
||||
|
||||
@@ -477,6 +477,22 @@ class DatabaseSSHTunnel(Schema):
|
||||
private_key = fields.String(required=False, load_only=True)
|
||||
private_key_password = fields.String(required=False, load_only=True)
|
||||
|
||||
# Optional expected SSH server host key in authorized-key form
|
||||
# (e.g. "ssh-rsa AAAA...", "ssh-ed25519 AAAA..."). When set, the SSH server's
|
||||
# presented host key is verified against it before the tunnel is opened. This is
|
||||
# a public key, so it is not sensitive and is not masked.
|
||||
server_host_key = fields.String(
|
||||
required=False,
|
||||
allow_none=True,
|
||||
metadata={
|
||||
"description": (
|
||||
"Expected SSH server host key in authorized-key form "
|
||||
"(e.g. 'ssh-ed25519 AAAA...'). When set, the server's host key is "
|
||||
"verified against it before the tunnel is opened."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@validates_schema
|
||||
def validate_authentication(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
@@ -72,6 +72,12 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
|
||||
encrypted_field_factory.create(Text), nullable=True
|
||||
)
|
||||
|
||||
# Optional expected SSH server host key, in authorized-key form
|
||||
# (e.g. "ssh-rsa AAAA...", "ssh-ed25519 AAAA..."). When set, the SSH server's
|
||||
# presented host key is verified against this value before the tunnel is opened.
|
||||
# This is a public key, so it is stored in plaintext (not encrypted).
|
||||
server_host_key = sa.Column(sa.Text, nullable=True)
|
||||
|
||||
export_fields = [
|
||||
"server_address",
|
||||
"server_port",
|
||||
@@ -79,6 +85,7 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
|
||||
"password",
|
||||
"private_key",
|
||||
"private_key_password",
|
||||
"server_host_key",
|
||||
]
|
||||
|
||||
extra_import_fields = [
|
||||
@@ -93,6 +100,9 @@ class SSHTunnel(AuditMixinNullable, ExtraJSONMixin, ImportExportMixin, Model):
|
||||
"server_port": self.server_port,
|
||||
"username": self.username,
|
||||
}
|
||||
if self.server_host_key is not None:
|
||||
# public key, not sensitive: returned in cleartext
|
||||
output["server_host_key"] = self.server_host_key
|
||||
if self.password is not None:
|
||||
output["password"] = PASSWORD_MASK
|
||||
if self.private_key is not None:
|
||||
|
||||
@@ -357,7 +357,13 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
||||
|
||||
try:
|
||||
new_model = CreateDatasetCommand(item).run()
|
||||
return self.response(201, id=new_model.id, result=item, data=new_model.data)
|
||||
return self.response(
|
||||
201,
|
||||
id=new_model.id,
|
||||
result=item,
|
||||
data=new_model.data,
|
||||
uuid=new_model.uuid,
|
||||
)
|
||||
except DatasetInvalidError as ex:
|
||||
return self.response_422(message=ex.normalized_messages())
|
||||
except DatasetCreateFailedError as ex:
|
||||
|
||||
@@ -195,6 +195,7 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
allows_hidden_cc_in_orderby = True
|
||||
|
||||
supports_catalog = supports_dynamic_catalog = supports_cross_catalog_queries = True
|
||||
supports_dynamic_schema = True
|
||||
|
||||
# when editing the database, mask this field in `encrypted_extra`
|
||||
# pylint: disable=invalid-name
|
||||
@@ -740,11 +741,41 @@ class BigQueryEngineSpec(BaseEngineSpec): # pylint: disable=too-many-public-met
|
||||
catalog: str | None = None,
|
||||
schema: str | None = None,
|
||||
) -> tuple[URL, dict[str, Any]]:
|
||||
if catalog:
|
||||
uri = uri.set(host=catalog, database="")
|
||||
if not uri.host:
|
||||
# Triple-slash form (e.g., bigquery:///project): project is in database.
|
||||
default_catalog = uri.database
|
||||
default_schema = None
|
||||
else:
|
||||
# Standard forms: bigquery://project, bigquery://project/dataset
|
||||
default_catalog = uri.host
|
||||
default_schema = uri.database or None # coerce empty string to None
|
||||
|
||||
uri = uri.set(
|
||||
host=catalog or default_catalog,
|
||||
database=schema or default_schema,
|
||||
)
|
||||
|
||||
return uri, connect_args
|
||||
|
||||
@classmethod
|
||||
def get_schema_from_engine_params(
|
||||
cls,
|
||||
sqlalchemy_uri: URL,
|
||||
connect_args: dict[str, Any],
|
||||
) -> str | None:
|
||||
"""
|
||||
Return the default dataset encoded in a ``bigquery://project/dataset`` URI.
|
||||
|
||||
The BigQuery SQLAlchemy driver uses the URL ``database`` component as the
|
||||
default dataset, but only when ``host`` (the project) is also present.
|
||||
The triple-slash form ``bigquery:///project`` puts the project in
|
||||
``database`` with no host, so we guard against misidentifying it as a
|
||||
dataset.
|
||||
"""
|
||||
if sqlalchemy_uri.host and sqlalchemy_uri.database:
|
||||
return sqlalchemy_uri.database
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def get_allow_cost_estimate(cls, extra: dict[str, Any]) -> bool:
|
||||
return True
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset import db
|
||||
from superset.constants import QUERY_EARLY_CANCEL_KEY, TimeGrain
|
||||
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.utils.network import is_safe_host
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.models.core import Database
|
||||
@@ -216,10 +217,25 @@ class ImpalaEngineSpec(BaseEngineSpec):
|
||||
|
||||
try:
|
||||
impala_host = query.database.url_object.host
|
||||
# The cancel call issues an outbound HTTP request from the
|
||||
# Superset backend to whatever host the DB connection was
|
||||
# configured with; validate it before the call to keep this
|
||||
# path consistent with the dataset-import and webhook URL
|
||||
# checks. Operators with internal Impala targets can opt out
|
||||
# via IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS.
|
||||
if not impala_host:
|
||||
return False
|
||||
if not app.config[
|
||||
"IMPALA_CANCEL_QUERY_ALLOW_INTERNAL_HOSTS"
|
||||
] and not is_safe_host(impala_host):
|
||||
logger.warning(
|
||||
"Impala cancel_query refused: target host is not allowed"
|
||||
)
|
||||
return False
|
||||
url = f"http://{impala_host}:25000/cancel_query?query_id={cancel_query_id}"
|
||||
response = requests.post(url, timeout=3)
|
||||
# Do not follow redirects: a validated host could otherwise 30x the
|
||||
# request to an internal target, bypassing the is_safe_host check.
|
||||
response = requests.post(url, timeout=3, allow_redirects=False)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
return False
|
||||
|
||||
|
||||
+133
-1
@@ -15,26 +15,63 @@
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import logging
|
||||
import socket
|
||||
from io import StringIO
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import paramiko
|
||||
import sshtunnel
|
||||
from flask import Flask
|
||||
from paramiko import RSAKey
|
||||
from paramiko.pkey import UnknownKeyType
|
||||
|
||||
from superset.commands.database.ssh_tunnel.exceptions import SSHTunnelDatabasePortError
|
||||
from superset.commands.database.ssh_tunnel.exceptions import (
|
||||
SSHTunnelDatabasePortError,
|
||||
SSHTunnelHostKeyVerificationError,
|
||||
)
|
||||
from superset.databases.utils import make_url_safe
|
||||
from superset.utils.class_utils import load_class_from_name
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from superset.databases.ssh_tunnel.models import SSHTunnel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_authorized_key(authorized_key: str) -> paramiko.PKey:
|
||||
"""
|
||||
Parse a host key in authorized-key form (``"<type> <base64>[ comment]"``) into a
|
||||
:class:`paramiko.PKey`. The optional trailing comment field and surrounding
|
||||
whitespace are ignored.
|
||||
|
||||
:raises ValueError: if the value is empty or cannot be parsed as a host key.
|
||||
"""
|
||||
fields = authorized_key.strip().split()
|
||||
if len(fields) < 2:
|
||||
raise ValueError("Host key must be in 'ssh-<type> <base64>' form")
|
||||
key_type, key_b64 = fields[0], fields[1]
|
||||
try:
|
||||
# validate=True so malformed characters raise instead of being silently
|
||||
# dropped, which could otherwise pin an unintended key value.
|
||||
key_bytes = base64.b64decode(key_b64, validate=True)
|
||||
except (binascii.Error, ValueError) as ex:
|
||||
raise ValueError("Host key base64 payload could not be decoded") from ex
|
||||
try:
|
||||
return paramiko.PKey.from_type_string(key_type, key_bytes)
|
||||
except (paramiko.SSHException, UnknownKeyType) as ex:
|
||||
raise ValueError(f"Host key could not be parsed: {ex}") from ex
|
||||
|
||||
|
||||
class SSHManager:
|
||||
def __init__(self, app: Flask) -> None:
|
||||
super().__init__()
|
||||
self.local_bind_address = app.config["SSH_TUNNEL_LOCAL_BIND_ADDRESS"]
|
||||
self.strict_host_key_checking = app.config.get(
|
||||
"SSH_TUNNEL_STRICT_HOST_KEY_CHECKING", False
|
||||
)
|
||||
sshtunnel.TUNNEL_TIMEOUT = app.config["SSH_TUNNEL_TIMEOUT_SEC"]
|
||||
sshtunnel.SSH_TIMEOUT = app.config["SSH_TUNNEL_PACKET_TIMEOUT_SEC"]
|
||||
|
||||
@@ -48,6 +85,87 @@ class SSHManager:
|
||||
port=server.local_bind_port,
|
||||
)
|
||||
|
||||
def _verify_host_key(self, ssh_tunnel: "SSHTunnel") -> "paramiko.PKey | None":
|
||||
"""
|
||||
Opt-in defense-in-depth: verify the SSH server's host key before opening the
|
||||
tunnel, to resist man-in-the-middle attacks (paramiko's ``Transport`` does no
|
||||
known-hosts checking by default).
|
||||
|
||||
Behavior:
|
||||
|
||||
- If the tunnel declares an expected ``server_host_key``, connect to the SSH
|
||||
server, read the host key it presents, and compare. On mismatch (or if the
|
||||
expected key cannot be parsed) raise
|
||||
:class:`SSHTunnelHostKeyVerificationError`.
|
||||
- If no expected key is set and ``SSH_TUNNEL_STRICT_HOST_KEY_CHECKING`` is
|
||||
enabled, fail closed and raise.
|
||||
- If no expected key is set and strict checking is disabled, do nothing,
|
||||
preserving existing (unverified) behavior.
|
||||
|
||||
:returns: the parsed expected host key when one is configured (so the caller
|
||||
can pin it on the tunnel's own connection), or ``None`` when no key is
|
||||
configured.
|
||||
"""
|
||||
expected_raw = ssh_tunnel.server_host_key
|
||||
|
||||
if not expected_raw or not expected_raw.strip():
|
||||
if self.strict_host_key_checking:
|
||||
raise SSHTunnelHostKeyVerificationError(
|
||||
message=(
|
||||
"SSH_TUNNEL_STRICT_HOST_KEY_CHECKING is enabled but no "
|
||||
"expected server host key is configured for this tunnel."
|
||||
)
|
||||
)
|
||||
return None
|
||||
|
||||
try:
|
||||
expected_key = _parse_authorized_key(expected_raw)
|
||||
except ValueError as ex:
|
||||
raise SSHTunnelHostKeyVerificationError(
|
||||
message=f"The configured expected server host key is invalid: {ex}"
|
||||
) from ex
|
||||
|
||||
# Build the socket ourselves with an explicit timeout so the TCP connect
|
||||
# phase is bounded too. ``paramiko.Transport((host, port))`` would connect
|
||||
# synchronously with no timeout, leaving ``start_client(timeout=...)`` to
|
||||
# govern only the SSH handshake; an unreachable host could then block for the
|
||||
# full OS-level TCP timeout.
|
||||
try:
|
||||
sock = socket.create_connection(
|
||||
(ssh_tunnel.server_address, ssh_tunnel.server_port),
|
||||
timeout=sshtunnel.SSH_TIMEOUT,
|
||||
)
|
||||
except OSError as ex:
|
||||
raise SSHTunnelHostKeyVerificationError(
|
||||
message=f"Could not connect to the SSH server: {ex}"
|
||||
) from ex
|
||||
|
||||
transport = paramiko.Transport(sock)
|
||||
try:
|
||||
transport.start_client(timeout=sshtunnel.SSH_TIMEOUT)
|
||||
remote_key = transport.get_remote_server_key()
|
||||
except Exception as ex: # noqa: BLE001
|
||||
raise SSHTunnelHostKeyVerificationError(
|
||||
message=f"Could not retrieve the SSH server host key: {ex}"
|
||||
) from ex
|
||||
finally:
|
||||
transport.close()
|
||||
|
||||
if remote_key != expected_key:
|
||||
logger.warning(
|
||||
"SSH host key mismatch for %s:%s",
|
||||
ssh_tunnel.server_address,
|
||||
ssh_tunnel.server_port,
|
||||
)
|
||||
raise SSHTunnelHostKeyVerificationError(
|
||||
message=(
|
||||
"The SSH server presented a host key that does not match the "
|
||||
"expected server host key configured for this tunnel."
|
||||
)
|
||||
)
|
||||
|
||||
return expected_key
|
||||
|
||||
def create_tunnel(
|
||||
self,
|
||||
ssh_tunnel: "SSHTunnel",
|
||||
@@ -60,6 +178,12 @@ class SSHManager:
|
||||
port = url.port or get_default_port(backend)
|
||||
if not port:
|
||||
raise SSHTunnelDatabasePortError()
|
||||
|
||||
# Opt-in host-key verification runs before the tunnel is opened. It returns
|
||||
# the parsed expected key (or None) so we can also pin it on the tunnel's own
|
||||
# connection below.
|
||||
expected_host_key = self._verify_host_key(ssh_tunnel)
|
||||
|
||||
params = {
|
||||
"ssh_address_or_host": (ssh_tunnel.server_address, ssh_tunnel.server_port),
|
||||
"ssh_username": ssh_tunnel.username,
|
||||
@@ -68,6 +192,14 @@ class SSHManager:
|
||||
"debug_level": logging.getLogger("flask_appbuilder").level,
|
||||
}
|
||||
|
||||
if expected_host_key is not None:
|
||||
# Pin the expected key on the tunnel's own connection, so paramiko verifies
|
||||
# the host that actually carries traffic on the same transport. The probe
|
||||
# above and the tunnel open separate connections, so verifying only the
|
||||
# probe would leave a TOCTOU gap (DNS re-resolution, selective
|
||||
# interception); pinning here closes it.
|
||||
params["ssh_host_key"] = expected_host_key
|
||||
|
||||
if ssh_tunnel.password:
|
||||
params["ssh_password"] = ssh_tunnel.password
|
||||
elif ssh_tunnel.private_key:
|
||||
|
||||
@@ -25,21 +25,37 @@ import io
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
from typing import Any, Callable
|
||||
|
||||
# Must redirect click output BEFORE importing anything that uses it
|
||||
import click
|
||||
|
||||
# Monkey-patch click to redirect output to stderr in stdio mode
|
||||
if os.environ.get("FASTMCP_TRANSPORT", "stdio") == "stdio":
|
||||
original_echo = click.echo
|
||||
original_secho = click.secho
|
||||
|
||||
def secho_to_stderr(*args: Any, **kwargs: Any) -> Any:
|
||||
kwargs["file"] = sys.stderr
|
||||
return original_secho(*args, **kwargs)
|
||||
def redirect_to_stderr(
|
||||
original_func: Callable[..., None],
|
||||
*args: Any,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
if len(args) >= 2:
|
||||
args = (args[0], sys.stderr, *args[2:])
|
||||
kwargs.pop("file", None)
|
||||
else:
|
||||
kwargs["file"] = sys.stderr
|
||||
|
||||
original_func(*args, **kwargs)
|
||||
|
||||
def echo_to_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
redirect_to_stderr(original_echo, *args, **kwargs)
|
||||
|
||||
def secho_to_stderr(*args: Any, **kwargs: Any) -> None:
|
||||
redirect_to_stderr(original_secho, *args, **kwargs)
|
||||
|
||||
click.echo = echo_to_stderr
|
||||
click.secho = secho_to_stderr
|
||||
click.echo = lambda *args, **kwargs: click.echo(*args, file=sys.stderr, **kwargs)
|
||||
|
||||
from superset.mcp_service.app import init_fastmcp_server, mcp
|
||||
from superset.mcp_service.middleware import create_response_size_guard_middleware
|
||||
|
||||
@@ -327,7 +327,16 @@ Chart Types You Can CREATE with generate_chart/generate_explore_link:
|
||||
- chart_type="xy", kind="scatter": Scatter plot for correlation analysis
|
||||
- chart_type="big_number": Big Number display (single metric, header only)
|
||||
- chart_type="big_number", show_trendline=True,
|
||||
temporal_column="<date_col>": Big Number with trendline
|
||||
temporal_column="<date_col>", aggregation="sum": Big Number with trendline
|
||||
(aggregation controls how the value is computed from trendline data points;
|
||||
default when omitted is "LAST_VALUE" — most recent point only.
|
||||
Use aggregation="sum" for all-time totals, "mean" for averages, "max"/"min" for extremes.
|
||||
DIAGNOSIS: if a Big Number with Trendline shows wrong values, check
|
||||
form_data["aggregation"] — missing/LAST_VALUE means the chart shows only the last data
|
||||
point, not a total. Fix by calling update_chart with a complete Big Number config:
|
||||
chart_type="big_number", metric=<metric>, show_trendline=True,
|
||||
temporal_column=<date_col>, aggregation="sum". update_chart requires the full
|
||||
config — omitting chart_type or metric causes a validation error.)
|
||||
- chart_type="table": Data table for detailed views
|
||||
- chart_type="table", viz_type="ag-grid-table": Interactive AG Grid table
|
||||
- chart_type="pie": Pie chart for proportional data (set donut=True for donut)
|
||||
|
||||
@@ -859,6 +859,9 @@ def map_big_number_config(config: BigNumberChartConfig) -> Dict[str, Any]:
|
||||
if config.time_format:
|
||||
form_data["time_format"] = config.time_format
|
||||
|
||||
if config.aggregation is not None:
|
||||
form_data["aggregation"] = config.aggregation
|
||||
|
||||
_add_adhoc_filters(form_data, config.filters)
|
||||
|
||||
return form_data
|
||||
|
||||
@@ -1417,6 +1417,32 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
),
|
||||
ge=1,
|
||||
)
|
||||
aggregation: (
|
||||
Literal["LAST_VALUE", "sum", "mean", "min", "max", "median", "raw"] | None
|
||||
) = Field(
|
||||
None,
|
||||
description=(
|
||||
"How the single big-number value is computed from the trendline "
|
||||
"data points. Only applies when show_trendline=True. "
|
||||
"Options: "
|
||||
"'sum' = Total (Sum) — add all data points; use for all-time totals. "
|
||||
"'LAST_VALUE' = most recent data point "
|
||||
"(frontend default when this field is absent). "
|
||||
"'mean' = Average (Mean). "
|
||||
"'min' = Minimum. "
|
||||
"'max' = Maximum. "
|
||||
"'median' = Median. "
|
||||
"'raw' = Overall value — single aggregate across the full period; best for "
|
||||
"non-additive metrics like ratios, averages, or distinct counts. "
|
||||
"DIAGNOSIS: if a Big Number with Trendline shows an unexpectedly low value "
|
||||
"(e.g. yesterday's revenue instead of all-time total), "
|
||||
"inspect form_data['aggregation'] "
|
||||
"— when absent or 'LAST_VALUE' the chart shows only the last data point. "
|
||||
"Fix by setting aggregation='sum'. "
|
||||
"IMPORTANT: when updating aggregation, always include "
|
||||
"show_trendline=True and temporal_column to preserve the trendline."
|
||||
),
|
||||
)
|
||||
filters: list[FilterConfig] | None = Field(
|
||||
None,
|
||||
description="Filters to apply",
|
||||
@@ -1437,6 +1463,13 @@ class BigNumberChartConfig(UnknownFieldCheckMixin):
|
||||
"Period comparison is only available for "
|
||||
"trendline charts."
|
||||
)
|
||||
if self.aggregation and not self.show_trendline:
|
||||
raise ValueError(
|
||||
"aggregation requires show_trendline=True. "
|
||||
"The aggregation field only applies to Big Number with "
|
||||
"Trendline charts. Set show_trendline=True and provide "
|
||||
"a temporal_column, or omit aggregation."
|
||||
)
|
||||
return self
|
||||
|
||||
@model_validator(mode="after")
|
||||
|
||||
@@ -115,6 +115,14 @@ _CHART_EXAMPLES: Dict[str, list[Dict[str, Any]]] = {
|
||||
"chart_type": "big_number",
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
},
|
||||
{
|
||||
"chart_type": "big_number",
|
||||
"metric": {"name": "revenue", "aggregate": "SUM"},
|
||||
"temporal_column": "order_date",
|
||||
"show_trendline": True,
|
||||
"aggregation": "sum",
|
||||
"time_grain": "P1D",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -516,6 +516,27 @@ def _fix_call_tool_arguments(tool: Any) -> Any:
|
||||
return tool
|
||||
|
||||
|
||||
def _fix_search_tool_query(tool: Any) -> Any:
|
||||
"""Fix anyOf schema in search_tools ``query`` for MCP bridge compatibility.
|
||||
|
||||
The optional ``query: str | None`` parameter emits an ``anyOf`` JSON
|
||||
Schema with no top-level ``type``. Some MCP bridges (mcp-remote,
|
||||
Claude Desktop) don't handle ``anyOf`` and strip it, leaving the field
|
||||
typeless — the same failure mode ``_fix_call_tool_arguments`` guards
|
||||
against. Replaces the ``anyOf`` with a flat ``type: string``.
|
||||
|
||||
Only the advertised schema changes; FastMCP validates calls against
|
||||
the function signature, so omitting ``query`` remains valid.
|
||||
"""
|
||||
if "query" in (props := (tool.parameters or {}).get("properties", {})):
|
||||
props["query"] = {
|
||||
"default": None,
|
||||
"description": "Natural language query. Omit to list all available tools.",
|
||||
"type": "string",
|
||||
}
|
||||
return tool
|
||||
|
||||
|
||||
def _normalize_call_tool_arguments(
|
||||
arguments: dict[str, Any] | None,
|
||||
tool_schema: dict[str, Any] | None,
|
||||
@@ -626,7 +647,7 @@ def _apply_tool_search_transform(mcp_instance: Any, config: dict[str, Any]) -> N
|
||||
)
|
||||
|
||||
|
||||
def _create_search_transform(
|
||||
def _create_search_transform( # noqa: C901
|
||||
*,
|
||||
strategy: str,
|
||||
kwargs: dict[str, Any],
|
||||
@@ -634,6 +655,32 @@ def _create_search_transform(
|
||||
) -> Any:
|
||||
"""Create the configured search transform with tool-permission filtering."""
|
||||
from fastmcp.server.context import Context
|
||||
from fastmcp.tools.tool import Tool
|
||||
|
||||
def _make_optional_query_search_tool(transform: Any) -> Any:
|
||||
"""Create search tool with optional query — returns all tools when omitted."""
|
||||
|
||||
async def search_tools(
|
||||
query: Annotated[
|
||||
str | None,
|
||||
"Natural language query. Omit to list all available tools.",
|
||||
] = None,
|
||||
ctx: Context = None,
|
||||
) -> str | list[dict[str, Any]]:
|
||||
"""Search for tools using natural language.
|
||||
|
||||
Returns matching tool definitions ranked by relevance.
|
||||
If no query is provided, returns all available tools.
|
||||
"""
|
||||
hidden = await transform._get_visible_tools(ctx)
|
||||
if not query:
|
||||
results = hidden
|
||||
else:
|
||||
results = await transform._search(hidden, query)
|
||||
return await transform._render_results(results)
|
||||
|
||||
tool = Tool.from_function(fn=search_tools, name=transform._search_tool_name)
|
||||
return _fix_search_tool_query(tool)
|
||||
|
||||
if strategy == "regex":
|
||||
from fastmcp.server.transforms.search import RegexSearchTransform
|
||||
@@ -650,6 +697,10 @@ def _create_search_transform(
|
||||
"""Build the normalized ``call_tool`` proxy for regex search."""
|
||||
return make_normalizing_call_tool(self)
|
||||
|
||||
def _make_search_tool(self) -> Any:
|
||||
"""Build the optional-query ``search_tools`` for regex search."""
|
||||
return _make_optional_query_search_tool(self)
|
||||
|
||||
return _FixedRegexSearchTransform(**kwargs)
|
||||
|
||||
from fastmcp.server.transforms.search import BM25SearchTransform
|
||||
@@ -666,6 +717,10 @@ def _create_search_transform(
|
||||
"""Build the normalized ``call_tool`` proxy for BM25 search."""
|
||||
return make_normalizing_call_tool(self)
|
||||
|
||||
def _make_search_tool(self) -> Any:
|
||||
"""Build the optional-query ``search_tools`` for BM25 search."""
|
||||
return _make_optional_query_search_tool(self)
|
||||
|
||||
return _FixedBM25SearchTransform(**kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import sqlalchemy as sa
|
||||
from alembic import op
|
||||
from flask import current_app
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy.orm import lazyload, Session
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.db_engine_specs.base import GenericDBException
|
||||
@@ -379,7 +379,15 @@ def upgrade_catalog_perms(engines: set[str] | None = None) -> None:
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
|
||||
for database in session.query(Database).all():
|
||||
# The Database model has an eager-loaded (``lazy="joined"``) ``ssh_tunnel``
|
||||
# backref. Eager-loading it here would SELECT every column on ``ssh_tunnels``,
|
||||
# including columns added by later migrations that do not yet exist at the
|
||||
# revision this helper runs in (e.g. on a fresh DB upgraded in one pass). The
|
||||
# catalog upgrade only needs scalar ``Database`` columns, so disable the eager
|
||||
# join to keep the query schema-safe across migration revisions.
|
||||
for database in (
|
||||
session.query(Database).options(lazyload(Database.ssh_tunnel)).all()
|
||||
):
|
||||
db_engine_spec = database.db_engine_spec
|
||||
if (
|
||||
engines and db_engine_spec.engine not in engines
|
||||
@@ -576,7 +584,11 @@ def downgrade_catalog_perms(engines: set[str] | None = None) -> None:
|
||||
bind = op.get_bind()
|
||||
session = db.Session(bind=bind)
|
||||
|
||||
for database in session.query(Database).all():
|
||||
# See upgrade_catalog_perms: avoid eager-loading the ``ssh_tunnel`` backref so the
|
||||
# query stays schema-safe across migration revisions.
|
||||
for database in (
|
||||
session.query(Database).options(lazyload(Database.ssh_tunnel)).all()
|
||||
):
|
||||
db_engine_spec = database.db_engine_spec
|
||||
if (
|
||||
engines and db_engine_spec.engine not in engines
|
||||
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
# Licensed to the Apache Software Foundation (ASF) under one
|
||||
# or more contributor license agreements. See the NOTICE file
|
||||
# distributed with this work for additional information
|
||||
# regarding copyright ownership. The ASF licenses this file
|
||||
# to you under the Apache License, Version 2.0 (the
|
||||
# "License"); you may not use this file except in compliance
|
||||
# with the License. You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing,
|
||||
# software distributed under the License is distributed on an
|
||||
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""update_tag_unique_constraint
|
||||
|
||||
Revision ID: b54f3bd8e69
|
||||
Revises: c233f5365c9e
|
||||
Create Date: 2025-10-06 16:05:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import enum
|
||||
|
||||
import migration_utils as utils
|
||||
from alembic import op
|
||||
from sqlalchemy import Column, Enum, Integer, MetaData, String, Table, Text
|
||||
from sqlalchemy.sql import func, select
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "b54f3bd8e69"
|
||||
down_revision = "c233f5365c9e"
|
||||
|
||||
|
||||
class TagType(enum.Enum):
|
||||
# pylint: disable=invalid-name
|
||||
custom = 1
|
||||
type = 2
|
||||
owner = 3
|
||||
favorited_by = 4
|
||||
|
||||
|
||||
# Define the tag table structure for data operations
|
||||
metadata = MetaData()
|
||||
tag_table = Table(
|
||||
"tag",
|
||||
metadata,
|
||||
Column("id", Integer, primary_key=True),
|
||||
Column("name", String(250)),
|
||||
Column("type", Enum(TagType)),
|
||||
Column("description", Text),
|
||||
)
|
||||
|
||||
old_constraint_name = "tag_name_key"
|
||||
new_constraint_name = "uix_tag_name_type"
|
||||
table_name = "tag"
|
||||
new_constraint_columns = ["name", "type"]
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""
|
||||
Change tag unique constraint from name only to (name, type) composite.
|
||||
This allows the same tag name to exist with different types (e.g., 'type:dashboard'
|
||||
can be both a system tag with type='type' and a custom tag with type='custom').
|
||||
"""
|
||||
bind = op.get_bind()
|
||||
|
||||
# Reflect the current database state to get existing tables
|
||||
metadata.reflect(bind=bind)
|
||||
|
||||
# Delete duplicate tags if any, keeping the one with the lowest ID
|
||||
min_id_subquery = (
|
||||
select(
|
||||
[
|
||||
func.min(tag_table.c.id).label("min_id"),
|
||||
tag_table.c.name,
|
||||
tag_table.c.type,
|
||||
]
|
||||
)
|
||||
.group_by(
|
||||
tag_table.c.name,
|
||||
tag_table.c.type,
|
||||
)
|
||||
.alias("min_ids")
|
||||
)
|
||||
|
||||
delete_query = tag_table.delete().where(
|
||||
tag_table.c.id.notin_(select([min_id_subquery.c.min_id]))
|
||||
)
|
||||
|
||||
bind.execute(delete_query)
|
||||
|
||||
# Drop the old unique constraint on name only
|
||||
utils.drop_unique_constraint(op, old_constraint_name, table_name)
|
||||
|
||||
# Create new composite unique constraint on (name, type)
|
||||
utils.create_unique_constraint(
|
||||
op, new_constraint_name, table_name, new_constraint_columns
|
||||
)
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""
|
||||
Revert to name-only unique constraint.
|
||||
|
||||
WARNING: This downgrade will fail if there are duplicate tag names with
|
||||
different types in the database. Before downgrading, ensure there are no
|
||||
tags with the same name but different types, or manually consolidate them.
|
||||
"""
|
||||
# Drop the composite unique constraint
|
||||
utils.drop_unique_constraint(op, new_constraint_name, table_name)
|
||||
|
||||
# Recreate the old unique constraint on name only
|
||||
utils.create_unique_constraint(op, old_constraint_name, table_name, ["name"])
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
# 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.
|
||||
"""add server_host_key to ssh_tunnels
|
||||
|
||||
Adds a nullable ``server_host_key`` column to the ``ssh_tunnels`` table. It stores the
|
||||
expected SSH server host key in authorized-key form (e.g. "ssh-ed25519 AAAA...") so
|
||||
operators can opt in to verifying the SSH server's host key before a tunnel is opened.
|
||||
This is a public key and is stored in plaintext (not encrypted). The column is
|
||||
nullable, so existing tunnels are unaffected.
|
||||
|
||||
Revision ID: 78a40c08b4be
|
||||
Revises: b7c9d1e2f3a4
|
||||
Create Date: 2026-06-03 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import add_columns, drop_columns
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "78a40c08b4be"
|
||||
down_revision = "b7c9d1e2f3a4"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the nullable ``server_host_key`` column to ``ssh_tunnels``."""
|
||||
add_columns(
|
||||
"ssh_tunnels",
|
||||
sa.Column("server_host_key", sa.Text(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the ``server_host_key`` column from ``ssh_tunnels``."""
|
||||
drop_columns("ssh_tunnels", "server_host_key")
|
||||
+21
-5
@@ -32,6 +32,7 @@ from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from inspect import signature
|
||||
from typing import Any, Callable, cast, Optional, TYPE_CHECKING
|
||||
from urllib.parse import quote
|
||||
|
||||
import numpy
|
||||
import pandas as pd
|
||||
@@ -1202,20 +1203,35 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
|
||||
@property
|
||||
def sqlalchemy_uri_decrypted(self) -> str:
|
||||
"""Return the decrypted SQLAlchemy URI with properly encoded password."""
|
||||
try:
|
||||
conn = make_url_safe(self.sqlalchemy_uri)
|
||||
except DatabaseInvalidError:
|
||||
# if the URI is invalid, ignore and return a placeholder url
|
||||
# (so users see 500 less often)
|
||||
return "dialect://invalid_uri"
|
||||
|
||||
# Determine plaintext password from config or model
|
||||
if has_app_context():
|
||||
if custom_password_store := app.config["SQLALCHEMY_CUSTOM_PASSWORD_STORE"]:
|
||||
conn = conn.set(password=custom_password_store(conn))
|
||||
custom_password_store = app.config.get("SQLALCHEMY_CUSTOM_PASSWORD_STORE")
|
||||
if custom_password_store and callable(custom_password_store):
|
||||
raw_password = custom_password_store(conn)
|
||||
else:
|
||||
conn = conn.set(password=self.password)
|
||||
raw_password = self.password
|
||||
else:
|
||||
conn = conn.set(password=self.password)
|
||||
return str(conn)
|
||||
raw_password = self.password
|
||||
|
||||
# Encode the password such that special characters
|
||||
# are preserved when rendering to string and reparsing the URL.
|
||||
if raw_password is not None:
|
||||
encoded_password = quote(raw_password, safe="")
|
||||
conn = conn.set(password=encoded_password)
|
||||
else:
|
||||
conn = conn.set(password=None)
|
||||
|
||||
# render_as_string preserves the URL encoding of special
|
||||
# characters in passwords
|
||||
return conn.render_as_string(hide_password=False)
|
||||
|
||||
@property
|
||||
def sql_url(self) -> str:
|
||||
|
||||
@@ -2982,7 +2982,9 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
) -> Column:
|
||||
label = label or tbl_column.column_name
|
||||
db_engine_spec = self.db_engine_spec
|
||||
column_spec = db_engine_spec.get_column_spec(self.type, db_extra=self.db_extra)
|
||||
column_spec = db_engine_spec.get_column_spec(
|
||||
tbl_column.type, db_extra=self.db_extra
|
||||
)
|
||||
type_ = column_spec.sqla_type if column_spec else None
|
||||
if expression := tbl_column.expression:
|
||||
if template_processor:
|
||||
|
||||
@@ -21,6 +21,7 @@ from sqlalchemy import or_
|
||||
from sqlalchemy.orm.query import Query
|
||||
|
||||
from superset import db, security_manager
|
||||
from superset.daos.base import _escape_like
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.views.base import BaseFilter
|
||||
|
||||
@@ -47,11 +48,13 @@ class ReportScheduleAllTextFilter(BaseFilter): # pylint: disable=too-few-public
|
||||
def apply(self, query: Query, value: Any) -> Query:
|
||||
if not value:
|
||||
return query
|
||||
ilike_value = f"%{value}%"
|
||||
# ``value`` may arrive as a non-string (e.g. an int in the API ``filters``
|
||||
# array); coerce it so escaping never raises on ``.replace``.
|
||||
ilike_value = f"%{_escape_like(str(value))}%"
|
||||
return query.filter(
|
||||
or_(
|
||||
ReportSchedule.name.ilike(ilike_value),
|
||||
ReportSchedule.description.ilike(ilike_value),
|
||||
ReportSchedule.sql.ilike(ilike_value),
|
||||
ReportSchedule.name.ilike(ilike_value, escape="\\"),
|
||||
ReportSchedule.description.ilike(ilike_value, escape="\\"),
|
||||
ReportSchedule.sql.ilike(ilike_value, escape="\\"),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -32,6 +32,7 @@ from superset.reports.notifications.exceptions import (
|
||||
)
|
||||
from superset.utils import json
|
||||
from superset.utils.decorators import statsd_gauge
|
||||
from superset.utils.network import is_safe_host
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -93,6 +94,39 @@ class WebhookNotification(BaseNotification):
|
||||
)
|
||||
return files
|
||||
|
||||
def _validate_webhook_url(self, url: str) -> None:
|
||||
"""
|
||||
Validate the webhook target URL before dispatch.
|
||||
|
||||
Checks that the scheme is HTTP(S) (and HTTPS when required by config),
|
||||
that a hostname is present, and, unless the operator opts out via
|
||||
``ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS``, that the host does not
|
||||
resolve to a private/internal address.
|
||||
|
||||
:raises NotificationParamException: if any of the above checks fail.
|
||||
"""
|
||||
parsed = urlparse(url)
|
||||
scheme = parsed.scheme.lower()
|
||||
if scheme not in ("http", "https"):
|
||||
raise NotificationParamException(
|
||||
"Webhook failed: only HTTP and HTTPS webhook URLs are supported."
|
||||
)
|
||||
if current_app.config["ALERT_REPORTS_WEBHOOK_HTTPS_ONLY"] and scheme != "https":
|
||||
raise NotificationParamException(
|
||||
"Webhook failed: HTTPS is required by config for webhook URLs."
|
||||
)
|
||||
if not parsed.hostname:
|
||||
raise NotificationParamException(
|
||||
"Webhook failed: URL must include a valid hostname."
|
||||
)
|
||||
# Operators with internal webhook targets (chatops bridges, internal
|
||||
# automation, etc.) can opt out of the private-IP block via
|
||||
# ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS.
|
||||
if current_app.config["ALERT_REPORTS_WEBHOOK_ALLOW_INTERNAL_HOSTS"]:
|
||||
return
|
||||
if not is_safe_host(parsed.hostname):
|
||||
raise NotificationParamException("Webhook URL target host is not allowed.")
|
||||
|
||||
@backoff.on_exception(
|
||||
backoff.expo, NotificationUnprocessableException, factor=10, base=2, max_tries=5
|
||||
)
|
||||
@@ -104,11 +138,7 @@ class WebhookNotification(BaseNotification):
|
||||
is not enabled."
|
||||
)
|
||||
wh_url = self._get_webhook_url()
|
||||
if current_app.config["ALERT_REPORTS_WEBHOOK_HTTPS_ONLY"]:
|
||||
if urlparse(wh_url).scheme.lower() != "https":
|
||||
raise NotificationParamException(
|
||||
"Webhook failed: HTTPS is required by config for webhook URLs."
|
||||
)
|
||||
self._validate_webhook_url(wh_url)
|
||||
payload = self._get_req_payload()
|
||||
files = self._get_files()
|
||||
|
||||
@@ -121,9 +151,17 @@ class WebhookNotification(BaseNotification):
|
||||
else:
|
||||
data[key] = value
|
||||
|
||||
response = requests.post(wh_url, data=data, files=files, timeout=60)
|
||||
response = requests.post(
|
||||
wh_url,
|
||||
data=data,
|
||||
files=files,
|
||||
timeout=60,
|
||||
allow_redirects=False,
|
||||
)
|
||||
else:
|
||||
response = requests.post(wh_url, json=payload, timeout=60)
|
||||
response = requests.post(
|
||||
wh_url, json=payload, timeout=60, allow_redirects=False
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Webhook sent to %s, status code: %s", wh_url, response.status_code
|
||||
@@ -139,6 +177,14 @@ class WebhookNotification(BaseNotification):
|
||||
f"Webhook failed with status code {response.status_code}: \
|
||||
{response.text}"
|
||||
)
|
||||
if response.status_code >= 300:
|
||||
# Redirects are intentionally not followed (allow_redirects=False),
|
||||
# so a 3xx means the request never reached the final target. Treat
|
||||
# it as a failure rather than silently reporting success.
|
||||
raise NotificationParamException(
|
||||
f"Webhook returned an unfollowed redirect "
|
||||
f"(status code {response.status_code})"
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as ex:
|
||||
raise NotificationUnprocessableException(str(ex)) from ex
|
||||
|
||||
@@ -342,11 +342,43 @@ PermissionModelView.include_route_methods = {RouteMethod.LIST}
|
||||
ViewMenuModelView.include_route_methods = {RouteMethod.LIST}
|
||||
|
||||
|
||||
# Keys on an adhoc column/metric that a guest may legitimately change through a
|
||||
# supported native filter, and which therefore must not count as payload
|
||||
# tampering. The time grain of a temporal x-axis is baked into its `BASE_AXIS`
|
||||
# column by `normalizeTimeColumn` on the frontend (it copies
|
||||
# `extras.time_grain_sqla` onto the column), so a Time Grain filter alters the
|
||||
# column payload without changing which data is queried.
|
||||
GUEST_OVERRIDABLE_VALUE_KEYS = frozenset({"timeGrain"})
|
||||
|
||||
|
||||
def _strip_overridable_keys(value: Any) -> Any:
|
||||
"""
|
||||
Recursively drop guest-overridable keys from a value.
|
||||
|
||||
Adhoc columns/metrics can be nested inside sequences (e.g. an ``orderby``
|
||||
entry is a ``(column, bool)`` tuple), so the overridable keys must be
|
||||
stripped at every level rather than only from a top-level dict.
|
||||
"""
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _strip_overridable_keys(val)
|
||||
for key, val in value.items()
|
||||
if key not in GUEST_OVERRIDABLE_VALUE_KEYS
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [_strip_overridable_keys(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def freeze_value(value: Any) -> str:
|
||||
"""
|
||||
Used to compare column and metric sets.
|
||||
|
||||
Guest-overridable keys (e.g. the time grain baked into a temporal x-axis
|
||||
column) are dropped so that legitimate native-filter changes don't read as
|
||||
payload tampering.
|
||||
"""
|
||||
return json.dumps(value, sort_keys=True)
|
||||
return json.dumps(_strip_overridable_keys(value), sort_keys=True)
|
||||
|
||||
|
||||
def _native_filter_allowed_targets(
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
# 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.
|
||||
"""Superset password-complexity validator.
|
||||
|
||||
Wired in via ``FAB_PASSWORD_COMPLEXITY_VALIDATOR`` (with
|
||||
``FAB_PASSWORD_COMPLEXITY_ENABLED``). Flask-AppBuilder runs this callable from
|
||||
both the WTForms password fields (self-registration, user edit, reset password)
|
||||
and the User REST API, so a single function enforces the policy across all
|
||||
password-setting flows.
|
||||
|
||||
The default policy is a minimum length plus a common-password blocklist —
|
||||
intentionally less draconian than FAB's built-in ``default_password_complexity``
|
||||
(which requires 2 uppercase, 1 special, 2 digits, 3 lowercase and length 10).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import current_app
|
||||
from flask_appbuilder.exceptions import PasswordComplexityValidationError
|
||||
from flask_babel import gettext as __
|
||||
|
||||
# A small built-in blocklist of the most common/guessable passwords. Operators
|
||||
# can extend it with AUTH_PASSWORD_COMMON_BLOCKLIST. (A fuller list or a
|
||||
# Have-I-Been-Pwned k-anonymity check is a possible follow-up.)
|
||||
COMMON_PASSWORDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"123456",
|
||||
"123456789",
|
||||
"12345678",
|
||||
"1234567890",
|
||||
"12345",
|
||||
"111111",
|
||||
"123123",
|
||||
"000000",
|
||||
"password",
|
||||
"password1",
|
||||
"password123",
|
||||
"passw0rd",
|
||||
"qwerty",
|
||||
"qwerty123",
|
||||
"qwertyuiop",
|
||||
"abc123",
|
||||
"letmein",
|
||||
"welcome",
|
||||
"welcome1",
|
||||
"admin",
|
||||
"admin123",
|
||||
"administrator",
|
||||
"root",
|
||||
"superset",
|
||||
"changeme",
|
||||
"iloveyou",
|
||||
"monkey",
|
||||
"dragon",
|
||||
"sunshine",
|
||||
"princess",
|
||||
"football",
|
||||
"baseball",
|
||||
"trustno1",
|
||||
"login",
|
||||
"master",
|
||||
"hello123",
|
||||
"secret",
|
||||
"default",
|
||||
}
|
||||
)
|
||||
|
||||
DEFAULT_MIN_LENGTH = 8
|
||||
|
||||
|
||||
def validate_password_complexity(password: str) -> None:
|
||||
"""Validate a plaintext password against the configured policy.
|
||||
|
||||
:raises PasswordComplexityValidationError: if the password is too short or
|
||||
appears in the common-password blocklist.
|
||||
"""
|
||||
raw_min_length = current_app.config.get(
|
||||
"AUTH_PASSWORD_MIN_LENGTH", DEFAULT_MIN_LENGTH
|
||||
)
|
||||
# Operators commonly wire config via env vars, so AUTH_PASSWORD_MIN_LENGTH can
|
||||
# arrive as a string (or be left unset/None). Coerce defensively and fall back
|
||||
# to the default rather than blowing up every password-setting flow with a
|
||||
# TypeError on the length comparison.
|
||||
try:
|
||||
min_length = int(raw_min_length)
|
||||
except (TypeError, ValueError):
|
||||
min_length = DEFAULT_MIN_LENGTH
|
||||
# A zero or negative value would silently disable the length check, so
|
||||
# treat non-positive values as misconfiguration and use the default.
|
||||
if min_length < 1:
|
||||
min_length = DEFAULT_MIN_LENGTH
|
||||
|
||||
if len(password) < min_length:
|
||||
raise PasswordComplexityValidationError(
|
||||
__(
|
||||
"Password must be at least %(min_length)s characters long.",
|
||||
min_length=min_length,
|
||||
)
|
||||
)
|
||||
|
||||
extra = current_app.config.get("AUTH_PASSWORD_COMMON_BLOCKLIST") or []
|
||||
# A bare string is iterable but would be split into characters, so treat a
|
||||
# misconfigured string as a single entry. casefold() gives correct
|
||||
# case-insensitive matching for non-ASCII passwords too.
|
||||
if isinstance(extra, str):
|
||||
extra = [extra]
|
||||
blocklist = COMMON_PASSWORDS | {str(item).casefold() for item in extra}
|
||||
if password.casefold() in blocklist:
|
||||
raise PasswordComplexityValidationError(
|
||||
__("This password is too common; please choose a less guessable one.")
|
||||
)
|
||||
@@ -92,7 +92,7 @@ class Tag(CoreTag, AuditMixinNullable):
|
||||
|
||||
__tablename__ = "tag"
|
||||
id = Column(Integer, primary_key=True)
|
||||
name = Column(String(250))
|
||||
name = Column(String(250), unique=True)
|
||||
type = Column(Enum(TagType))
|
||||
description = Column(Text)
|
||||
|
||||
@@ -104,8 +104,6 @@ class Tag(CoreTag, AuditMixinNullable):
|
||||
security_manager.user_model, secondary=user_favorite_tag_table
|
||||
)
|
||||
|
||||
__table_args__ = (UniqueConstraint("name", "type", name="uix_tag_name_type"),)
|
||||
|
||||
|
||||
class TaggedObject(Model, AuditMixinNullable):
|
||||
"""An association between an object and a tag."""
|
||||
|
||||
@@ -24,7 +24,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: \n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-05 12:59+0300\n"
|
||||
"POT-Creation-Date: 2026-06-08 12:27-0700\n"
|
||||
"PO-Revision-Date: 2024-07-14 15:10+0300\n"
|
||||
"Last-Translator: Abdalrahim G. Fakhouri <abdilra7eem@yahoo.com>\n"
|
||||
"Language: ar\n"
|
||||
@@ -4662,6 +4662,9 @@ msgstr "تم تحديث إعدادات قاعدة البيانات"
|
||||
msgid "Database type does not support file uploads."
|
||||
msgstr "قاعدة البيانات لا تدعم الاستعلامات الفرعية"
|
||||
|
||||
msgid "Database upload file exceeds the maximum allowed size."
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Database upload file failed"
|
||||
msgstr "حدد قاعدة بيانات لتحميل الملف إليها"
|
||||
@@ -18308,10 +18311,6 @@ msgstr "قيمة تصاعدية"
|
||||
msgid "value descending"
|
||||
msgstr "قيمة تنازلي"
|
||||
|
||||
#, fuzzy
|
||||
msgid "valuename"
|
||||
msgstr "اسم الجدول"
|
||||
|
||||
msgid "var"
|
||||
msgstr "VAR"
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-05 12:59+0300\n"
|
||||
"POT-Creation-Date: 2026-06-08 12:27-0700\n"
|
||||
"PO-Revision-Date: 2025-06-27 12:56+0200\n"
|
||||
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
|
||||
"Language: ca\n"
|
||||
@@ -4655,6 +4655,9 @@ msgstr "Configuració de base de dades actualitzada"
|
||||
msgid "Database type does not support file uploads."
|
||||
msgstr "El tipus de base de dades no suporta pujades de fitxers."
|
||||
|
||||
msgid "Database upload file exceeds the maximum allowed size."
|
||||
msgstr ""
|
||||
|
||||
msgid "Database upload file failed"
|
||||
msgstr "La pujada de fitxer de base de dades ha fallat"
|
||||
|
||||
@@ -18304,10 +18307,6 @@ msgstr "valor ascendent"
|
||||
msgid "value descending"
|
||||
msgstr "valor descendent"
|
||||
|
||||
#, fuzzy
|
||||
msgid "valuename"
|
||||
msgstr "Nom de taula"
|
||||
|
||||
msgid "var"
|
||||
msgstr "var"
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: Superset VERSION\n"
|
||||
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
|
||||
"POT-Creation-Date: 2026-06-05 12:59+0300\n"
|
||||
"POT-Creation-Date: 2026-06-08 12:27-0700\n"
|
||||
"PO-Revision-Date: 2016-05-02 08:49-0700\n"
|
||||
"Last-Translator: Jan Šmejkal <jan.smejkal@orgis.cz>\n"
|
||||
"Language: cs\n"
|
||||
@@ -4633,6 +4633,9 @@ msgstr "Nastavení databáze aktualizováno"
|
||||
msgid "Database type does not support file uploads."
|
||||
msgstr "Typ databáze nepodporuje nahrávání souborů."
|
||||
|
||||
msgid "Database upload file exceeds the maximum allowed size."
|
||||
msgstr ""
|
||||
|
||||
msgid "Database upload file failed"
|
||||
msgstr "Nahrání souboru do databáze selhalo"
|
||||
|
||||
@@ -18245,10 +18248,6 @@ msgstr "hodnota vzestupně"
|
||||
msgid "value descending"
|
||||
msgstr "hodnota sestupně"
|
||||
|
||||
#, fuzzy
|
||||
msgid "valuename"
|
||||
msgstr "Název tabulky"
|
||||
|
||||
msgid "var"
|
||||
msgstr "var"
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user