mirror of
https://github.com/apache/superset.git
synced 2026-08-12 19:20:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffdaf9e6b8 | ||
|
|
3b164e4270 | ||
|
|
bf294cfa43 | ||
|
|
42e4030104 | ||
|
|
8181917f79 | ||
|
|
d14ab29dcb | ||
|
|
a75665b014 | ||
|
|
db5cd1aa38 | ||
|
|
abb6dd6e39 | ||
|
|
4a587b8539 | ||
|
|
127a6f92a6 | ||
|
|
25481799f6 | ||
|
|
1da7a7fdb1 | ||
|
|
185b5938ba | ||
|
|
503309531f | ||
|
|
44a86b725f | ||
|
|
38727906a2 | ||
|
|
2a9c5acb01 | ||
|
|
d8b97e31fa | ||
|
|
bd03440ac8 | ||
|
|
a6c478c59d | ||
|
|
fd36419ca2 | ||
|
|
d80f4c841d | ||
|
|
6baa467f59 | ||
|
|
b18015f63b | ||
|
|
eb8592d4bf | ||
|
|
55af4685a2 | ||
|
|
c901f01693 | ||
|
|
4c894affb2 | ||
|
|
0f9049608a | ||
|
|
91b1a6cac5 | ||
|
|
86a328ca9e | ||
|
|
38ba4a69bf | ||
|
|
280253b1fb | ||
|
|
79c2cd367d | ||
|
|
fd6a0a60a6 | ||
|
|
c2b5311f47 | ||
|
|
70a82a28bf | ||
|
|
f348cf425d | ||
|
|
74196cf694 | ||
|
|
fe06ebe796 | ||
|
|
f1411ccd6c | ||
|
|
1770823f0a | ||
|
|
b7b873321d | ||
|
|
d363cabec9 |
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
@@ -75,6 +75,6 @@ jobs:
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@f205ea1c3313d32999d8d6a48b4f6530d4437b38 # v4.37.4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
submodules: recursive
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "temurin"
|
||||
java-version: "11"
|
||||
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
node-version-file: "./docs/.nvmrc"
|
||||
- name: Setup Python
|
||||
uses: ./.github/actions/setup-backend/
|
||||
- uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0
|
||||
- uses: actions/setup-java@03ad4de0992f5dab5e18fcb136590ce7c4a0ac95 # v5.6.0
|
||||
with:
|
||||
distribution: "zulu"
|
||||
java-version: "21"
|
||||
|
||||
@@ -182,11 +182,6 @@ jobs:
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm ls --all --package-lock-only --depth=0 --json > /dev/null"
|
||||
|
||||
- name: Build Plugins Packages
|
||||
run: |
|
||||
docker run --rm $TAG bash -c \
|
||||
"npm run plugins:build"
|
||||
|
||||
test-storybook:
|
||||
needs: frontend-build
|
||||
if: needs.frontend-build.outputs.should-run == 'true'
|
||||
|
||||
+31
-2
@@ -24,6 +24,8 @@ assists people when migrating to a new version.
|
||||
|
||||
## Next
|
||||
|
||||
- [42393](https://github.com/apache/superset/pull/42393): Exported dataset YAML now carries a `uuid` for each metric and column so that custom folder assignments (which reference metrics/columns by UUID) survive an import into another workspace. This affects any export bundle that contains datasets, not just a dataset export: chart, dashboard, database and full-asset exports all embed the same dataset YAML, so a dashboard exported from this release also fails to import into an older one even though no dataset was exported directly. As with `folders` and `currency_code_column`, the affected `datasets/` files fail schema validation (`Unknown field: uuid`) when imported into Superset releases that predate this change; regenerate or hand-edit exports for older targets in mixed-version fleets.
|
||||
|
||||
### Soft delete is on by default, and purging is live
|
||||
|
||||
`SOFT_DELETE` now ships **on** (`DEFAULT_FEATURE_FLAGS`), so deleting a
|
||||
@@ -52,6 +54,33 @@ One caveat on turning soft delete back off: objects archived while it was on are
|
||||
**resurrected** into normal listings, since the rows were never removed — an
|
||||
emergency stop rather than a clean rollback.
|
||||
|
||||
### Version history is on by default
|
||||
|
||||
`VERSION_HISTORY` and `ENABLE_VERSIONING_CAPTURE` now both ship **on**. Every
|
||||
save of a chart, dashboard, or dataset writes version rows, and the version
|
||||
history panel appears on Explore and Dashboard pages. The two flip together
|
||||
deliberately: a panel with capture off renders an empty "No history yet" that
|
||||
misrepresents the entity as unchanged.
|
||||
|
||||
**What operators should expect:**
|
||||
|
||||
- **Storage growth.** Capture writes shadow rows per save, so the metadata
|
||||
database grows with edit volume. The `version_history.prune_old_versions`
|
||||
beat task removes rows whose transaction is older than
|
||||
`SUPERSET_VERSION_HISTORY_RETENTION_DAYS` (default 30). A deployment that
|
||||
replaces `CELERY_CONFIG` rather than inheriting it must carry both the
|
||||
`superset.tasks.version_history_retention` import and the beat entry; a
|
||||
startup warning names whichever is absent.
|
||||
- **`PUT` responses change shape.** Entity updates now return populated
|
||||
`old_version_uuid` / `new_version_uuid` fields and an `ETag` header, which
|
||||
were null or absent while capture was off.
|
||||
|
||||
`ENABLE_VERSIONING_CAPTURE` is **retained permanently** as an operational
|
||||
kill-switch — not removed with the rollout toggles. Setting it to a falsy value
|
||||
stops capture within a restart, without a revert-and-redeploy. Unlike the
|
||||
soft-delete toggle, turning it off is a clean stop: existing version rows remain
|
||||
readable and no entity state is altered.
|
||||
|
||||
### Scheduled report execution now enforces one application deadline
|
||||
|
||||
Scheduled report (not alert) executions are now governed by a single
|
||||
@@ -481,7 +510,7 @@ Entity version history (the `version_transaction` / `*_version` shadow tables th
|
||||
|---|---|---|
|
||||
| `SUPERSET_VERSION_HISTORY_RETENTION_DAYS` | `30` | Version rows whose owning `version_transaction.issued_at` is older than this many days are pruned. Each entity's live row (`end_transaction_id IS NULL`) is always preserved, as are the live rows of its children and associations; closed historical rows (including the baseline) age out. Set to `0` or a negative value to disable pruning. |
|
||||
|
||||
The task ships in the default `CeleryConfig` (both the `superset.tasks.version_history_retention` import and the beat entry). A deployment that overrides `CELERY_CONFIG` without the beat entry logs a startup warning. When the override explicitly defines `imports`, a missing retention module is also reported; an absent `imports` setting is not diagnosed because Celery may register tasks through `include`, autodiscovery, or worker startup imports. Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE` (ships off).
|
||||
The task ships in the default `CeleryConfig` (both the `superset.tasks.version_history_retention` import and the beat entry). A deployment that overrides `CELERY_CONFIG` without the beat entry logs a startup warning. When the override explicitly defines `imports`, a missing retention module is also reported; an absent `imports` setting is not diagnosed because Celery may register tasks through `include`, autodiscovery, or worker startup imports. Retention only prunes whatever history exists — capture itself is gated separately by `ENABLE_VERSIONING_CAPTURE`, which now ships on.
|
||||
|
||||
### Deletion retention (soft-deleted entities are eventually purged)
|
||||
|
||||
@@ -491,7 +520,7 @@ Purging is **live by default** (`SOFT_DELETE_PURGE_DRY_RUN=False`), so the reten
|
||||
|
||||
Deployments that replace the default `CELERY_CONFIG` must ensure workers register `superset.tasks.deletion_retention` and schedule the `deletion_retention.purge_soft_deleted` task themselves. The shipped Docker development config uses `imports` and includes both entries. While `SOFT_DELETE` is statically enabled, a missing beat entry logs a startup warning; when the override explicitly defines `imports`, a missing purge module is also reported.
|
||||
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every purge writes an immutable, content-free audit record to the new `purge_audit_log` table that survives the entity it names: the **scheduled** purge fails closed (an entity whose audit row cannot be written is skipped and retried next run), while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure.
|
||||
Operators can immediately erase a specific entity for compliance (GDPR) via `superset deletion-retention force-purge --uuid <uuid>`; this applies legacy hard-delete semantics — a live chart referencing a force-purged dataset is left without a datasource until re-pointed (the chart is not modified), and it purges the named entity even when it was never soft-deleted. Every scheduled evaluation writes a provisional, content-free record to the new `purge_audit_log` table before the cascade starts. Meaningful retained outcomes survive the entity they name. Consecutive scheduled evaluations with the same blocked outcome suppress only the redundant current provisional record; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. The **scheduled** purge fails closed when its provisional record cannot be written, while **force-purge** proceeds even if the audit write fails — the operator is present and deletion outranks audit for a compliance erasure. Operators can monitor `deletion_retention.blocked_audit_suppressed` and `deletion_retention.blocked_audit_dedupe_fallback` to verify suppression and fail-safe fallback behavior without changing the existing blocked-workload gauge.
|
||||
|
||||
### Recently Archived view and permanent delete (purge) endpoints
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ Alerts and reports are disabled by default. To turn them on, you'll need to chan
|
||||
- Users can customize the email subject by including date code placeholders, which will automatically be replaced with the corresponding UTC date when the email is sent. To enable this functionality, activate the `"DATE_FORMAT_IN_EMAIL_SUBJECT"` [feature flag](/admin-docs/configuration/configuring-superset#feature-flags). This enables date formatting in email subjects, preventing all reporting emails from being grouped into the same thread (optional for the reporting feature).
|
||||
- Use date codes from [strftime.org](https://strftime.org/) to create the email subject.
|
||||
- If no date code is provided, the original string will be used as the email subject.
|
||||
- Each alert/report has an "Include a link back to Superset" option (enabled by default) controlling whether the call-to-action link is included in email and Slack notifications. The link text in emails is configurable via `EMAIL_REPORTS_CTA`; the Slack message always uses "Explore in Superset". Uncheck the option when recipients should not receive a link to your Superset host, e.g. for external audiences.
|
||||
|
||||
##### Disable dry-run mode
|
||||
|
||||
|
||||
@@ -315,6 +315,76 @@ Here's a concrete example:
|
||||
WHERE country_code = 'US'
|
||||
```
|
||||
|
||||
**Guest User Attributes**
|
||||
|
||||
The `{{ get_guest_user_attribute('attribute_name') }}` macro returns a specific attribute value from the guest user context.
|
||||
This is useful when working with embedded Superset where guest tokens can contain custom attributes that need to be
|
||||
accessed in SQL queries.
|
||||
|
||||
This macro only works when the current user is a guest user (authenticated via guest token). If the current user is
|
||||
not a guest user, or if the specified attribute doesn't exist, the macro will return `None` or the provided default value.
|
||||
|
||||
If you have caching enabled in your Superset configuration, then by default the resolved value (whether it
|
||||
came from the guest token, a null attribute, or the provided default) will be used by Superset when
|
||||
calculating the cache key. A cache key is a unique identifier that determines if there's a cache hit in the
|
||||
future and Superset can retrieve cached data. Including the resolved value on every branch ensures two guests
|
||||
whose tokens render different SQL never share a cache entry.
|
||||
|
||||
You can disable the inclusion of the attribute value in the calculation of the
|
||||
cache key by adding the following parameter to your Jinja code, but only do so
|
||||
when the value cannot affect the query results:
|
||||
|
||||
```
|
||||
{{ get_guest_user_attribute('department', add_to_cache_keys=False) }}
|
||||
```
|
||||
|
||||
You can also provide a default value if the attribute is not found:
|
||||
|
||||
```
|
||||
{{ get_guest_user_attribute('region', default='US') }}
|
||||
```
|
||||
|
||||
Here's a concrete example of using guest user attributes in a query:
|
||||
|
||||
```sql
|
||||
SELECT *
|
||||
FROM sales_data
|
||||
WHERE region = '{{ get_guest_user_attribute("user_region", default="global") }}'
|
||||
AND department = '{{ get_guest_user_attribute("department") }}'
|
||||
```
|
||||
|
||||
:::warning[Security Warning]
|
||||
|
||||
Guest token attributes come from the embedding application. By default,
|
||||
`get_guest_user_attribute()` escapes string values — including strings nested inside
|
||||
arrays and object values, and caller-supplied defaults — through the database dialect's
|
||||
literal rendering (the same mechanism as `url_param()`). This covers dialect-specific
|
||||
escape characters such as the backslash on MySQL/MariaDB, so the example above is safe
|
||||
to interpolate directly. If you pass `escape_result=False`, or interpolate non-string
|
||||
values (numbers, booleans), you are responsible for validating or allowlisting the
|
||||
values, since they originate outside Superset.
|
||||
|
||||
If a guest attribute is an array and you plan to pipe it through the `|where_in` filter
|
||||
(for example `full_name IN {{ get_guest_user_attribute('names')|where_in }}`), call
|
||||
`get_guest_user_attribute('names', escape_result=False)`. `where_in` already applies its
|
||||
own dialect-safe quoting, so escaping the values twice can corrupt them (a value such as
|
||||
`O'Brien` would come back doubly escaped and match nothing).
|
||||
|
||||
Only individual string values are escaped as SQL literals. Interpolating an entire array
|
||||
or object directly (rather than through `|where_in`, or by accessing a specific element)
|
||||
renders Python's string form of that structure, which is not valid SQL, and object keys
|
||||
are not escaped at all. Use `|where_in` for arrays, `|tojson` where you need a
|
||||
JSON-stringified value, or read individual keys/elements out of the structure yourself.
|
||||
|
||||
The same double-escaping problem described above for `|where_in` applies to `|tojson`:
|
||||
pass `escape_result=False` before piping to `|tojson` (for example
|
||||
`{{ get_guest_user_attribute('profile', escape_result=False)|tojson }}`), since JSON
|
||||
already handles its own quoting and re-escaping a value first would corrupt it (a nested
|
||||
string such as `O'Brien` would come back as the altered `O''Brien` in the serialized
|
||||
JSON).
|
||||
|
||||
:::
|
||||
|
||||
### Explicitly Including Values in Cache Key
|
||||
|
||||
The `{{ cache_key_wrapper() }}` function explicitly instructs Superset to add a value to the
|
||||
|
||||
Vendored
+2
-2
@@ -107,9 +107,9 @@
|
||||
},
|
||||
{
|
||||
"name": "VERSION_HISTORY",
|
||||
"default": false,
|
||||
"default": true,
|
||||
"lifecycle": "development",
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty."
|
||||
"description": "Enables the version history panel on Explore and Dashboard pages. History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on; with capture off the panel renders but stays empty, so the two ship with matching defaults and should be changed together."
|
||||
}
|
||||
],
|
||||
"testing": [
|
||||
|
||||
Vendored
+7
@@ -11383,6 +11383,13 @@
|
||||
},
|
||||
"User3": {
|
||||
"properties": {
|
||||
"attributes": {
|
||||
"additionalProperties": {
|
||||
"nullable": true
|
||||
},
|
||||
"nullable": true,
|
||||
"type": "object"
|
||||
},
|
||||
"first_name": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
+6
-6
@@ -8048,9 +8048,9 @@ domhandler@^5.0.2, domhandler@^5.0.3:
|
||||
domelementtype "^2.3.0"
|
||||
|
||||
dompurify@^3.3.3, dompurify@^3.4.12:
|
||||
version "3.4.12"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.12.tgz#6fa2265e9bbdce882c4ace4107626051b448ffa8"
|
||||
integrity sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==
|
||||
version "3.4.13"
|
||||
resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be"
|
||||
integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==
|
||||
optionalDependencies:
|
||||
"@types/trusted-types" "^2.0.7"
|
||||
|
||||
@@ -11737,9 +11737,9 @@ mz@^2.7.0:
|
||||
thenify-all "^1.0.0"
|
||||
|
||||
nanoid@^3.3.16:
|
||||
version "3.3.16"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c"
|
||||
integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==
|
||||
version "3.3.18"
|
||||
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913"
|
||||
integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==
|
||||
|
||||
natural-compare@^1.4.0:
|
||||
version "1.4.0"
|
||||
|
||||
+20
-15
@@ -68,19 +68,19 @@ dependencies = [
|
||||
# not just a connection-pool quirk. Needs dedicated investigation, not a
|
||||
# driver-compat-prep bump; revisit alongside the actual SQLAlchemy 2.0
|
||||
# core bump (discussion #40273, step 6).
|
||||
"flask-sqlalchemy>=2.5.1, <3.0",
|
||||
"flask-sqlalchemy>=2.5.1, <4.0",
|
||||
"flask-wtf>=1.3.0, <2.0",
|
||||
"geopy",
|
||||
"greenlet<=3.5.4, >=3.5.4",
|
||||
"gunicorn>=26.0.0, <27; sys_platform != 'win32'",
|
||||
"hashids>=1.3.1, <2",
|
||||
# holidays>=0.45 required for security fix
|
||||
"holidays>=0.99, <1",
|
||||
"holidays>=0.101, <1",
|
||||
"humanize",
|
||||
"isodate",
|
||||
"jsonpath-ng>=1.8.0, <2",
|
||||
"Mako>=1.2.2",
|
||||
"markdown>=3.10.2",
|
||||
"markdown>=3.10.3",
|
||||
# marshmallow 4 compatibility: see superset/marshmallow_compatibility.py for a
|
||||
# Flask-AppBuilder workaround. Tracking issue:
|
||||
# https://github.com/apache/superset/issues/33162
|
||||
@@ -88,7 +88,7 @@ dependencies = [
|
||||
"marshmallow-union>=0.1.15.post1",
|
||||
"msgpack>=1.2.0, <1.3",
|
||||
"nh3>=0.3.5, <0.4",
|
||||
"numpy>1.23.5, <2.3",
|
||||
"numpy>=1.23.5, <2.5",
|
||||
"packaging",
|
||||
# --------------------------
|
||||
# pandas and related (wanting pandas[performance] without numba as it's 100+MB and not needed)
|
||||
@@ -108,10 +108,10 @@ dependencies = [
|
||||
"pyarrow>=24.0.0, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
|
||||
"pyyaml>=6.0.3, <7.0.0",
|
||||
"PyJWT>=2.4.0, <3.0",
|
||||
"redis>=5.0.0, <9.0",
|
||||
"redis>=8.1.0, <9.0",
|
||||
"rison>=2.0.1, <3.0",
|
||||
"selenium>=4.46.0, <5.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.4, <2.0",
|
||||
"shillelagh[gsheetsapi]>=1.4.5, <2.0",
|
||||
"sshtunnel>=0.4.0, <0.5",
|
||||
"simplejson>=4.1.1",
|
||||
"slack_sdk>=3.43.0, <4",
|
||||
@@ -131,7 +131,7 @@ dependencies = [
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
athena = ["pyathena[pandas]>=3.35.2, <4"]
|
||||
athena = ["pyathena[pandas]>=3.35.4, <4"]
|
||||
# No SQLAlchemy 2.0 support anywhere in this dialect's ecosystem today: our
|
||||
# own preset-io/sqlalchemy-aurora-data-api fork is dead since 2021, and the
|
||||
# more active community fork (cloud-utils/sqlalchemy-aurora-data-api) has an
|
||||
@@ -143,7 +143,7 @@ bigquery = [
|
||||
# 1.17.1 is likely the final release: googleapis/python-bigquery-sqlalchemy
|
||||
# was archived 2026-05-16. Both 1.17.0 and 1.17.1 support SQLAlchemy 1.4/2.0.
|
||||
"sqlalchemy-bigquery>=1.17.1",
|
||||
"google-cloud-bigquery>=3.42.2",
|
||||
"google-cloud-bigquery>=3.42.3",
|
||||
]
|
||||
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
|
||||
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
|
||||
@@ -204,7 +204,7 @@ fastmcp = [
|
||||
firebird = ["sqlalchemy-firebird>=0.8.0, <2.0.0"]
|
||||
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
|
||||
gevent = ["gevent>=26.7.0"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.4, <2"]
|
||||
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
|
||||
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
|
||||
hive = [
|
||||
"pyhive[hive_pure_sasl]>=0.7.0",
|
||||
@@ -234,7 +234,7 @@ ocient = [
|
||||
oracle = ["oracledb>=4.0.2, <5"]
|
||||
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
|
||||
pinot = ["pinotdb>=5.0.0, <10.0.0"]
|
||||
playwright = ["playwright>=1.61.0, <2"]
|
||||
playwright = ["playwright>=1.62.0, <2"]
|
||||
postgres = ["psycopg2-binary==2.9.12"]
|
||||
presto = ["pyhive[presto]>=0.6.5"]
|
||||
trino = ["trino>=0.338.0"]
|
||||
@@ -248,8 +248,8 @@ redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
|
||||
# and 2.0 (version numbers don't track SQLAlchemy compat monotonically); pin
|
||||
# to the newest 1.4-only release for now. Bump to >=2.0.0 in lockstep with
|
||||
# Superset's own SQLAlchemy 2.0 core bump (discussion #40273), not before.
|
||||
risingwave = ["sqlalchemy-risingwave>=1.4.1, <2.0.0"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
|
||||
risingwave = ["sqlalchemy-risingwave>=1.4.1, <3.0.0"]
|
||||
shillelagh = ["shillelagh[all]>=1.4.5, <2"]
|
||||
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
|
||||
snowflake = ["snowflake-sqlalchemy>=1.11.0, <2"]
|
||||
sqlite = ["syntaqlite>=0.7.0,<0.8.0"]
|
||||
@@ -262,11 +262,11 @@ tdengine = [
|
||||
"taospy>=2.8.10",
|
||||
"taos-ws-py>=0.7.0"
|
||||
]
|
||||
teradata = ["teradatasql>=20.0.0.63"]
|
||||
teradata = ["teradatasql>=20.0.0.64"]
|
||||
thumbnails = [] # deprecated, will be removed in 7.0
|
||||
vertica = ["sqlalchemy-vertica-python>= 0.6.3, < 0.7"]
|
||||
netezza = ["nzalchemy>= 11.1.2, < 11.2"]
|
||||
starrocks = ["starrocks>=1.3.3, <2"]
|
||||
starrocks = ["starrocks>=1.3.4, <2"]
|
||||
doris = ["pydoris>=1.2.0, <2.0.0"]
|
||||
oceanbase = ["oceanbase_py>=0.0.1.2"]
|
||||
ydb = ["ydb-sqlalchemy>=0.1.22", "ydb-sqlglot-plugin>=0.2.8"]
|
||||
@@ -286,7 +286,7 @@ development = [
|
||||
"progress>=1.6.1,<2",
|
||||
"psutil",
|
||||
"pyfakefs",
|
||||
"pyinstrument>=5.1.2,<6",
|
||||
"pyinstrument>=5.1.3,<6",
|
||||
"pylint",
|
||||
"pytest<10.0.0", # cap below the next major until validated; the earlier <8 pin (current_app proxy timing) no longer reproduces
|
||||
"pytest-asyncio",
|
||||
@@ -535,6 +535,11 @@ authorized_licenses = [
|
||||
# Seems ok, might need legal review
|
||||
# https://github.com/urschrei/pypolyline/blob/master/LICENSE.md
|
||||
polyline = "2"
|
||||
# NumPy 2.x reports a combined SPDX license expression covering vendored
|
||||
# code (BSD-3-Clause AND 0BSD AND MIT AND Zlib AND CC0-1.0), all of which
|
||||
# are permissive OSI-approved licenses; the package itself remains
|
||||
# BSD-3-Clause. https://github.com/numpy/numpy/blob/main/LICENSE.txt
|
||||
numpy = "2"
|
||||
# --------------------------------------------------------------
|
||||
|
||||
# TODO REMOVE THESE DEPS FROM CODEBASE
|
||||
|
||||
@@ -178,7 +178,7 @@ h11==0.16.0
|
||||
# via wsproto
|
||||
hashids==1.3.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
holidays==0.100
|
||||
holidays==0.102
|
||||
# via apache-superset (pyproject.toml)
|
||||
humanize==4.12.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
@@ -219,7 +219,7 @@ mako==1.3.12
|
||||
# -r requirements/base.in
|
||||
# apache-superset (pyproject.toml)
|
||||
# alembic
|
||||
markdown==3.10.2
|
||||
markdown==3.10.3
|
||||
# via apache-superset (pyproject.toml)
|
||||
markdown-it-py==3.0.0
|
||||
# via rich
|
||||
@@ -251,7 +251,7 @@ nh3==0.3.6
|
||||
# via apache-superset (pyproject.toml)
|
||||
numexpr==2.10.2
|
||||
# via -r requirements/base.in
|
||||
numpy==1.26.4
|
||||
numpy==2.4.6
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# bottleneck
|
||||
@@ -357,7 +357,7 @@ pyyaml==6.0.3
|
||||
# via
|
||||
# apache-superset (pyproject.toml)
|
||||
# apispec
|
||||
redis==8.0.1
|
||||
redis==8.1.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
referencing==0.36.2
|
||||
# via
|
||||
@@ -384,7 +384,7 @@ selenium==4.46.0
|
||||
# via apache-superset (pyproject.toml)
|
||||
setuptools==80.9.0
|
||||
# via -r requirements/base.in
|
||||
shillelagh==1.4.4
|
||||
shillelagh==1.4.5
|
||||
# via apache-superset (pyproject.toml)
|
||||
simplejson==4.1.1
|
||||
# via apache-superset (pyproject.toml)
|
||||
|
||||
@@ -363,7 +363,7 @@ google-auth-oauthlib==1.2.1
|
||||
# via
|
||||
# pandas-gbq
|
||||
# pydata-google-auth
|
||||
google-cloud-bigquery==3.42.2
|
||||
google-cloud-bigquery==3.43.0
|
||||
# via
|
||||
# apache-superset
|
||||
# pandas-gbq
|
||||
@@ -408,7 +408,7 @@ hashids==1.3.1
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
holidays==0.100
|
||||
holidays==0.102
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -521,7 +521,7 @@ mako==1.3.12
|
||||
# -c requirements/base-constraint.txt
|
||||
# alembic
|
||||
# apache-superset
|
||||
markdown==3.10.2
|
||||
markdown==3.10.3
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -581,7 +581,7 @@ nh3==0.3.6
|
||||
# apache-superset
|
||||
nodeenv==1.8.0
|
||||
# via pre-commit
|
||||
numpy==1.26.4
|
||||
numpy==2.4.6
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -776,7 +776,7 @@ pygments==2.20.0
|
||||
# rich
|
||||
pyhive==0.7.0
|
||||
# via apache-superset
|
||||
pyinstrument==5.1.2
|
||||
pyinstrument==5.1.3
|
||||
# via apache-superset
|
||||
pyjwt==2.13.0
|
||||
# via
|
||||
@@ -874,7 +874,7 @@ pyyaml==6.0.3
|
||||
# fastmcp-slim
|
||||
# jsonschema-path
|
||||
# pre-commit
|
||||
redis==8.0.1
|
||||
redis==8.1.0
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
@@ -948,7 +948,7 @@ setuptools==80.9.0
|
||||
# pydata-google-auth
|
||||
# zope-event
|
||||
# zope-interface
|
||||
shillelagh==1.4.4
|
||||
shillelagh==1.4.5
|
||||
# via
|
||||
# -c requirements/base-constraint.txt
|
||||
# apache-superset
|
||||
|
||||
Generated
+523
-1722
File diff suppressed because it is too large
Load Diff
@@ -65,7 +65,6 @@
|
||||
"ensure-oxc": "echo 'OXC linter is ready' && npx oxlint --version",
|
||||
"lint-stats": "node ./scripts/oxlint-metrics-uploader.js",
|
||||
"plugins:build": "node ./scripts/build.js",
|
||||
"plugins:build-assets": "node ./scripts/copyAssets.js",
|
||||
"plugins:create-conventional-version": "npm run prune && lerna version --conventional-commits --create-release github --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
"plugins:create-minor-version": "npm run prune && lerna version minor --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
"plugins:create-patch-version": "npm run prune && lerna version patch --no-private --yes --tag-version-prefix=\"plugins-and-packages-v\"",
|
||||
@@ -167,8 +166,6 @@
|
||||
"dom-to-image-more": "^3.10.2",
|
||||
"dom-to-pdf": "^0.3.2",
|
||||
"echarts": "^6.1.0",
|
||||
"fast-glob": "^3.3.2",
|
||||
"fs-extra": "^11.4.0",
|
||||
"fuse.js": "^7.5.0",
|
||||
"geolib": "^3.3.14",
|
||||
"geostyler": "^18.6.0",
|
||||
@@ -176,7 +173,7 @@
|
||||
"geostyler-openlayers-parser": "^5.7.1",
|
||||
"geostyler-style": "11.0.2",
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"google-auth-library": "^10.9.1",
|
||||
"google-auth-library": "^11.0.0",
|
||||
"immer": "^11.1.15",
|
||||
"interweave": "^13.1.1",
|
||||
"jquery": "^4.0.0",
|
||||
@@ -186,7 +183,7 @@
|
||||
"lodash": "^4.18.1",
|
||||
"lodash-es": "^4.18.1",
|
||||
"mapbox-gl": "^3.27.0",
|
||||
"markdown-to-jsx": "^9.9.0",
|
||||
"markdown-to-jsx": "^9.10.1",
|
||||
"match-sorter": "^8.3.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mousetrap": "^1.6.5",
|
||||
@@ -203,7 +200,7 @@
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-intersection-observer": "^10.1.0",
|
||||
"react-intersection-observer": "^11.0.0",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
@@ -216,7 +213,7 @@
|
||||
"react-table": "^7.8.0",
|
||||
"react-transition-group": "^4.4.5",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
"react-window": "^1.8.10",
|
||||
"react-window": "^2.3.0",
|
||||
"redux": "^4.2.1",
|
||||
"redux-localstorage": "^0.4.1",
|
||||
"redux-thunk": "^2.1.0",
|
||||
@@ -255,7 +252,7 @@
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.18",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
"@storybook/addon-docs": "10.5.5",
|
||||
"@storybook/addon-links": "10.5.5",
|
||||
@@ -284,7 +281,6 @@
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/redux-localstorage": "^1.0.8",
|
||||
"@types/redux-mock-store": "^1.0.6",
|
||||
"@types/rison": "0.1.0",
|
||||
@@ -296,7 +292,7 @@
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
"babel-plugin-jsx-remove-data-test-id": "^3.0.0",
|
||||
"baseline-browser-mapping": "^2.11.7",
|
||||
"baseline-browser-mapping": "^2.11.8",
|
||||
"cheerio": "1.2.0",
|
||||
"concurrently": "^10.0.4",
|
||||
"copy-webpack-plugin": "^14.0.0",
|
||||
@@ -308,7 +304,7 @@
|
||||
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
|
||||
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest-dom": "^5.6.0",
|
||||
"eslint-plugin-jest-dom": "^5.7.0",
|
||||
"eslint-plugin-lodash": "^8.0.0",
|
||||
"eslint-plugin-no-only-tests": "^3.4.0",
|
||||
"eslint-plugin-react-prefer-function-component": "^5.0.0",
|
||||
@@ -327,7 +323,7 @@
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"js-yaml-loader": "^1.2.2",
|
||||
"jsdom": "^30.0.1",
|
||||
"lerna": "^9.0.4",
|
||||
"lerna": "^10.0.0",
|
||||
"lightningcss": "^1.33.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"minimizer-webpack-plugin": "^5.6.1",
|
||||
|
||||
@@ -20,8 +20,7 @@
|
||||
import { dirname, join } from 'path';
|
||||
// @ts-ignore -- yeoman-test type resolution differs between local and Docker environments
|
||||
import helpers from 'yeoman-test';
|
||||
// @ts-ignore -- fs-extra/esm has no type declarations
|
||||
import { copySync } from 'fs-extra/esm';
|
||||
import { cpSync } from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
import pluginChartModule from '../generators/plugin-chart';
|
||||
|
||||
@@ -31,9 +30,10 @@ test('generator-superset:plugin-chart:creates files', async () => {
|
||||
.onTargetDirectory((dir: string) => {
|
||||
// `dir` is the path to the new temporary directory
|
||||
const generatorDirname = dirname(fileURLToPath(import.meta.url));
|
||||
copySync(
|
||||
cpSync(
|
||||
join(generatorDirname, '../generators/plugin-chart/templates'),
|
||||
join(dir, 'unknown/templates'),
|
||||
{ recursive: true },
|
||||
);
|
||||
})
|
||||
.withPrompts({
|
||||
|
||||
@@ -109,7 +109,6 @@
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
|
||||
@@ -36,6 +36,7 @@ import type {
|
||||
QueryResponse,
|
||||
TimeFormatter,
|
||||
} from '@superset-ui/core';
|
||||
import { type RGBColor } from '@superset-ui/core/components';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { sharedControls, sharedControlComponents } from './shared-controls';
|
||||
|
||||
@@ -494,7 +495,7 @@ export type ConditionalFormattingConfig = {
|
||||
targetValueLeft?: number;
|
||||
targetValueRight?: number;
|
||||
column?: string;
|
||||
colorScheme?: string;
|
||||
colorScheme?: RGBColor | string;
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
useGradient?: boolean;
|
||||
|
||||
+41
-5
@@ -19,7 +19,7 @@
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { isString, isBoolean } from 'lodash-es';
|
||||
import { isBlank } from '@apache-superset/core/utils';
|
||||
import { addAlpha, DataRecord } from '@superset-ui/core';
|
||||
import { addAlpha, DataRecord, rgbaToHex } from '@superset-ui/core';
|
||||
import tinycolor from 'tinycolor2';
|
||||
import {
|
||||
ColorFormatters,
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
ConditionalFormattingConfig,
|
||||
MultipleValueComparators,
|
||||
ResolvedColorFormatterResult,
|
||||
ColorSchemeEnum,
|
||||
} from '../types';
|
||||
|
||||
export const round = (num: number, precision = 0) =>
|
||||
@@ -71,6 +72,9 @@ export const getOpacity = (
|
||||
);
|
||||
};
|
||||
|
||||
const isSpecialColor = (value: unknown): value is ColorSchemeEnum =>
|
||||
Object.values(ColorSchemeEnum).includes(value as ColorSchemeEnum);
|
||||
|
||||
export const getColorFunction = (
|
||||
{
|
||||
operator,
|
||||
@@ -270,19 +274,51 @@ export const getColorFunction = (
|
||||
if (compareResult === false) return undefined;
|
||||
const { cutoffValue, extremeValue } = compareResult;
|
||||
|
||||
// If useGradient is explicitly false, return solid color
|
||||
if (useGradient === false) {
|
||||
if (typeof colorScheme === 'string') {
|
||||
if (isSpecialColor(colorScheme)) {
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
if (
|
||||
useGradient === false ||
|
||||
(useGradient === undefined && colorScheme.length === 9)
|
||||
) {
|
||||
if (alpha === false) {
|
||||
return colorScheme.length === 9
|
||||
? colorScheme.slice(0, 7)
|
||||
: colorScheme;
|
||||
}
|
||||
return colorScheme;
|
||||
}
|
||||
|
||||
const cleanHex =
|
||||
colorScheme.length === 9 ? colorScheme.slice(0, 7) : colorScheme;
|
||||
|
||||
if (alpha === undefined || alpha) {
|
||||
return addAlpha(
|
||||
cleanHex,
|
||||
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
|
||||
);
|
||||
}
|
||||
return colorScheme;
|
||||
}
|
||||
// If useGradient is explicitly false, return solid color
|
||||
if (useGradient === false || useGradient === undefined) {
|
||||
if (alpha === false) {
|
||||
return rgbaToHex({ ...colorScheme, a: 1 });
|
||||
}
|
||||
return rgbaToHex(colorScheme);
|
||||
}
|
||||
|
||||
const baseHexColor = rgbaToHex({ ...colorScheme, a: 1 });
|
||||
// Otherwise apply gradient (default behavior for backward compatibility)
|
||||
if (alpha === undefined || alpha) {
|
||||
return addAlpha(
|
||||
colorScheme,
|
||||
baseHexColor,
|
||||
getOpacity(value, cutoffValue, extremeValue, minOpacity, maxOpacity),
|
||||
);
|
||||
}
|
||||
return colorScheme;
|
||||
return baseHexColor;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
+164
@@ -952,3 +952,167 @@ test('correct column boolean config', () => {
|
||||
expect(colorFormatters[3].getColorFromValue(true)).toEqual('#FF0000FF');
|
||||
expect(colorFormatters[3].getColorFromValue(false)).toEqual('#FF0000FF');
|
||||
});
|
||||
|
||||
test('should return hex color when colorScheme is an RGB object', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 128, b: 0, a: 1 },
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('#ff8000');
|
||||
expect(colorFunction('Carlos')).toEqual('#ff8000');
|
||||
expect(colorFunction('Brian')).toEqual('#ff8000');
|
||||
});
|
||||
|
||||
test('should return token name as-is when colorScheme is a string token', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.None,
|
||||
colorScheme: 'Green',
|
||||
column: 'name',
|
||||
},
|
||||
strValues,
|
||||
);
|
||||
expect(colorFunction('Diana')).toEqual('Green');
|
||||
expect(colorFunction('Carlos')).toEqual('Green');
|
||||
expect(colorFunction('Brian')).toEqual('Green');
|
||||
});
|
||||
|
||||
test('should return solid hex color when useGradient is false or true', () => {
|
||||
const columnConfig = [
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 0, g: 47, b: 255, a: 1 },
|
||||
column: 'count',
|
||||
useGradient: false,
|
||||
},
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 255, g: 166, b: 0, a: 1 },
|
||||
column: 'count',
|
||||
useGradient: true,
|
||||
},
|
||||
];
|
||||
const colorFormatters = getColorFormatters(columnConfig, mockData);
|
||||
expect(colorFormatters.length).toEqual(2);
|
||||
|
||||
// First formatter with useGradient: false should return solid color
|
||||
expect(colorFormatters[0].column).toEqual('count');
|
||||
expect(colorFormatters[0].getColorFromValue(100)).toEqual('#002fff');
|
||||
|
||||
// Second formatter with useGradient: true should return gradient color
|
||||
expect(colorFormatters[1].column).toEqual('count');
|
||||
expect(colorFormatters[1].getColorFromValue(100)).toEqual('#ffa600FF');
|
||||
});
|
||||
|
||||
test('should return hex color without alpha for GreaterThan operator with RGB colorScheme', () => {
|
||||
const config = {
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 1 },
|
||||
useGradient: true,
|
||||
};
|
||||
|
||||
const columnValues = [10, 50, 100];
|
||||
|
||||
const alpha = false;
|
||||
const colorFunction = getColorFunction(config, columnValues, alpha);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#ff0000');
|
||||
});
|
||||
|
||||
test('should preserve alpha from colorScheme when useGradient is false', () => {
|
||||
const config = {
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
|
||||
useGradient: false,
|
||||
};
|
||||
|
||||
const colorFunction = getColorFunction(config, [10, 20, 30]);
|
||||
const result = colorFunction(20);
|
||||
|
||||
expect(result).not.toBe('#ff0000');
|
||||
expect(result).not.toBe('rgb(255, 0, 0)');
|
||||
});
|
||||
|
||||
test('should force opaque color when useGradient is false but alpha is explicitly false', () => {
|
||||
const config = {
|
||||
operator: Comparator.None,
|
||||
colorScheme: { r: 255, g: 0, b: 0, a: 0.5 },
|
||||
useGradient: false,
|
||||
};
|
||||
|
||||
const colorFunction = getColorFunction(config, [10, 20, 30], false);
|
||||
const result = colorFunction(20);
|
||||
|
||||
expect(result).toBe('#ff0000');
|
||||
});
|
||||
|
||||
test('should return colorScheme as-is when alpha is false and length is 7', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF0000',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
});
|
||||
|
||||
test('should preserve alpha when alpha is undefined and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF000080');
|
||||
});
|
||||
|
||||
test('should preserve alpha when alpha is true and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF000080');
|
||||
});
|
||||
|
||||
test('should strip alpha channel when alpha is false and colorScheme has 9 chars', () => {
|
||||
const colorFunction = getColorFunction(
|
||||
{
|
||||
operator: Comparator.GreaterThan,
|
||||
targetValue: 50,
|
||||
colorScheme: '#FF000080',
|
||||
useGradient: false,
|
||||
column: 'count',
|
||||
},
|
||||
countValues,
|
||||
false,
|
||||
);
|
||||
|
||||
expect(colorFunction(100)).toEqual('#FF0000');
|
||||
expect(colorFunction(100)).toHaveLength(7);
|
||||
});
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
"d3-time": "^3.1.0",
|
||||
"d3-time-format": "^4.1.0",
|
||||
"dayjs": "^1.11.21",
|
||||
"dompurify": "^3.4.12",
|
||||
"dompurify": "^3.4.13",
|
||||
"fetch-retry": "^6.0.0",
|
||||
"handlebars": "^4.7.9",
|
||||
"jed": "^1.1.1",
|
||||
@@ -123,7 +123,6 @@
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"antd": "^6.0.0",
|
||||
"nanoid": "*",
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import tinycolor from 'tinycolor2';
|
||||
import { type RGBColor } from '@superset-ui/core/components';
|
||||
|
||||
const rgbRegex = /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/;
|
||||
export function getContrastingColor(color: string, thresholds = 186) {
|
||||
@@ -120,3 +121,45 @@ export function rgbToHex(red: number, green: number, blue: number) {
|
||||
|
||||
return `#${r}${g}${b}`;
|
||||
}
|
||||
|
||||
export function rgbaToHex(rgb: RGBColor): string {
|
||||
const { r, g, b, a = 1 } = rgb;
|
||||
const clampChannel = (value: number) =>
|
||||
Math.min(255, Math.max(0, Math.round(value)));
|
||||
const clampAlpha = (value: number) => Math.min(1, Math.max(0, value));
|
||||
const toHex = (value: number) => {
|
||||
const hex = value.toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
const hexColor = `#${toHex(clampChannel(r))}${toHex(clampChannel(g))}${toHex(clampChannel(b))}`;
|
||||
const clampedAlpha = clampAlpha(a);
|
||||
if (clampedAlpha !== 1) {
|
||||
return `${hexColor}${toHex(Math.round(clampedAlpha * 255))}`;
|
||||
}
|
||||
return hexColor;
|
||||
}
|
||||
|
||||
export const forceHexAlpha = (color: string | RGBColor): string => {
|
||||
if (typeof color === 'object' && color !== null) {
|
||||
return rgbaToHex({ ...color, a: 0.6 });
|
||||
}
|
||||
|
||||
let hex = color.startsWith('#') ? color : `#${color}`;
|
||||
|
||||
// Expand shorthand hex (#rgb, #rgba) to full length before appending or
|
||||
// replacing the alpha channel, otherwise the result is not a valid 6- or
|
||||
// 8-digit CSS hex color.
|
||||
if (hex.length === 4 || hex.length === 5) {
|
||||
hex = `#${hex
|
||||
.slice(1)
|
||||
.split('')
|
||||
.map(char => char + char)
|
||||
.join('')}`;
|
||||
}
|
||||
|
||||
if (hex.length === 9) {
|
||||
return `${hex.slice(0, -2)}99`;
|
||||
}
|
||||
|
||||
return `${hex}99`;
|
||||
};
|
||||
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, waitFor } from '@superset-ui/core/spec';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { Table } from './index';
|
||||
|
||||
// These tests exercise VirtualTable's react-window v2 `Grid` wiring
|
||||
// (`cellComponent`/`cellProps`/`gridRef`), which previously had no direct
|
||||
// coverage - `Table.test.tsx` only exercises the non-virtualized code path.
|
||||
|
||||
interface BasicData {
|
||||
columnName: string;
|
||||
columnType: string;
|
||||
}
|
||||
|
||||
const testData: BasicData[] = [
|
||||
{ columnName: 'Number', columnType: 'Numerical' },
|
||||
{ columnName: 'String', columnType: 'Physical' },
|
||||
{ columnName: 'Date', columnType: 'Virtual' },
|
||||
];
|
||||
|
||||
const testColumns: ColumnsType<BasicData> = [
|
||||
{
|
||||
title: 'Column Name',
|
||||
dataIndex: 'columnName',
|
||||
key: 'columnName',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: 'Column Type',
|
||||
dataIndex: 'columnType',
|
||||
key: 'columnType',
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
|
||||
test('virtualized table renders headers and row content through the react-window Grid', async () => {
|
||||
render(
|
||||
<Table
|
||||
columns={testColumns}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
testColumns.forEach(column =>
|
||||
expect(
|
||||
screen
|
||||
.getAllByText(column.title as string)
|
||||
.find(el => el.closest('th')),
|
||||
).toBeInTheDocument(),
|
||||
),
|
||||
);
|
||||
|
||||
testData.forEach(row => {
|
||||
expect(screen.getByText(row.columnName)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('virtualized table cells keep the DOM hooks other code (cypress, downloadAsImage) relies on', async () => {
|
||||
const { container } = render(
|
||||
<Table
|
||||
columns={testColumns}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector('.virtual-grid')).toBeInTheDocument();
|
||||
expect(
|
||||
container.querySelectorAll('.virtual-table-cell').length,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
test('cell render functions receive their row data via cellProps rather than a stale closure', async () => {
|
||||
const columnsWithRender: ColumnsType<BasicData> = [
|
||||
{
|
||||
title: 'Column Name',
|
||||
dataIndex: 'columnName',
|
||||
key: 'columnName',
|
||||
width: 150,
|
||||
render: (value: string) => `rendered:${value}`,
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<Table
|
||||
columns={columnsWithRender}
|
||||
data={testData}
|
||||
virtualize
|
||||
height={200}
|
||||
usePagination={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('rendered:Number')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -24,10 +24,14 @@ import {
|
||||
} from 'antd/es/table';
|
||||
import classNames from 'classnames';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import { useEffect, useRef, useState, useCallback, CSSProperties } from 'react';
|
||||
import { VariableSizeGrid as Grid } from 'react-window';
|
||||
import { useRef, useState, useCallback, type UIEvent } from 'react';
|
||||
import {
|
||||
Grid,
|
||||
type CellComponentProps,
|
||||
type GridImperativeAPI,
|
||||
} from 'react-window';
|
||||
import { safeHtmlSpan } from '@superset-ui/core';
|
||||
import { useTheme, styled } from '@apache-superset/core/theme';
|
||||
import { useTheme, styled, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
import { TableSize, ETableAction } from './index';
|
||||
|
||||
@@ -71,6 +75,59 @@ const StyledTable = styled(AntTable)(
|
||||
const SMALL = 39;
|
||||
const MIDDLE = 47;
|
||||
|
||||
interface VirtualGridCellProps {
|
||||
mergedColumns: AntTableProps<any>['columns'];
|
||||
rawData: readonly object[];
|
||||
cellSize: number;
|
||||
allowHTML: boolean;
|
||||
theme: SupersetTheme;
|
||||
}
|
||||
|
||||
// Rendered via `cellComponent`, so it must be a stable reference (module scope)
|
||||
// rather than defined inline on every render of the enclosing table -
|
||||
// otherwise react-window would treat it as a new component type each render
|
||||
// and remount every cell. All the data it needs is threaded through
|
||||
// `cellProps` instead of being closed over.
|
||||
const VirtualGridCell = ({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
mergedColumns,
|
||||
rawData,
|
||||
cellSize,
|
||||
allowHTML,
|
||||
theme,
|
||||
}: CellComponentProps<VirtualGridCellProps>) => {
|
||||
const data: any = rawData?.[rowIndex];
|
||||
// Set default content
|
||||
let content = data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
|
||||
// Check if the column has a render function
|
||||
const render = mergedColumns?.[columnIndex]?.render;
|
||||
if (typeof render === 'function') {
|
||||
// Use render function to generate formatted content using column's render function
|
||||
content = render(content, data, rowIndex);
|
||||
}
|
||||
|
||||
if (allowHTML && typeof content === 'string') {
|
||||
content = safeHtmlSpan(content);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledCell
|
||||
className={classNames('virtual-table-cell', {
|
||||
'virtual-table-cell-last':
|
||||
columnIndex === (mergedColumns?.length ?? 0) - 1,
|
||||
})}
|
||||
style={style}
|
||||
title={typeof content === 'string' ? content : undefined}
|
||||
theme={theme}
|
||||
height={cellSize}
|
||||
>
|
||||
{content}
|
||||
</StyledCell>
|
||||
);
|
||||
};
|
||||
|
||||
const VirtualTable = <RecordType extends object>(
|
||||
props: VirtualTableProps<RecordType>,
|
||||
) => {
|
||||
@@ -126,19 +183,15 @@ const VirtualTable = <RecordType extends object>(
|
||||
(lastColumn.width as number) + Math.floor(tableWidth - totalWidth);
|
||||
}
|
||||
|
||||
const gridRef = useRef<any>();
|
||||
const gridRef = useRef<GridImperativeAPI>(null);
|
||||
const [connectObject] = useState<any>(() => {
|
||||
const obj = {};
|
||||
Object.defineProperty(obj, 'scrollLeft', {
|
||||
get: () => {
|
||||
if (gridRef.current) {
|
||||
return gridRef.current?.state?.scrollLeft;
|
||||
}
|
||||
return 0;
|
||||
},
|
||||
get: () => gridRef.current?.element?.scrollLeft ?? 0,
|
||||
set: (scrollLeft: number) => {
|
||||
if (gridRef.current) {
|
||||
gridRef.current.scrollTo({ scrollLeft });
|
||||
const element = gridRef.current?.element;
|
||||
if (element) {
|
||||
element.scrollLeft = scrollLeft;
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -146,14 +199,11 @@ const VirtualTable = <RecordType extends object>(
|
||||
return obj;
|
||||
});
|
||||
|
||||
const resetVirtualGrid = () => {
|
||||
gridRef.current?.resetAfterIndices({
|
||||
columnIndex: 0,
|
||||
shouldForceUpdate: true,
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => resetVirtualGrid, [tableWidth, columns, size]);
|
||||
// No manual cache-reset is needed here (react-window v2 has no
|
||||
// `resetAfterIndices`-style API): `columnWidth` below is a fresh inline
|
||||
// closure over `mergedColumns` on every render, so react-window's internal
|
||||
// size cache - which is invalidated whenever the `columnWidth`/`rowHeight`
|
||||
// function reference changes - recomputes automatically.
|
||||
|
||||
/*
|
||||
* antd Table has a runtime error when it tries to fire the onChange event triggered from a pageChange
|
||||
@@ -166,7 +216,10 @@ const VirtualTable = <RecordType extends object>(
|
||||
* We intentionally leave horizontal scroll where it was so user can focus on
|
||||
* specific range of columns as they page through data
|
||||
*/
|
||||
gridRef.current?.scrollTo?.({ scrollTop: 0 });
|
||||
const element = gridRef.current?.element;
|
||||
if (element) {
|
||||
element.scrollTop = 0;
|
||||
}
|
||||
|
||||
onChange?.(
|
||||
{
|
||||
@@ -192,61 +245,31 @@ const VirtualTable = <RecordType extends object>(
|
||||
const cellSize = size === TableSize.Middle ? MIDDLE : SMALL;
|
||||
return (
|
||||
<Grid
|
||||
ref={gridRef}
|
||||
gridRef={gridRef}
|
||||
className="virtual-grid"
|
||||
columnCount={mergedColumns.length}
|
||||
columnWidth={(index: number) => {
|
||||
const { width = DEFAULT_COL_WIDTH } = mergedColumns[index];
|
||||
return width as number;
|
||||
}}
|
||||
height={height || (scroll!.y as number)}
|
||||
rowCount={rawData.length}
|
||||
rowHeight={() => cellSize}
|
||||
width={tableWidth}
|
||||
onScroll={({ scrollLeft }: { scrollLeft: number }) => {
|
||||
onScroll({ scrollLeft });
|
||||
style={{
|
||||
height: height || (scroll!.y as number),
|
||||
width: tableWidth,
|
||||
}}
|
||||
>
|
||||
{({
|
||||
columnIndex,
|
||||
rowIndex,
|
||||
style,
|
||||
}: {
|
||||
columnIndex: number;
|
||||
rowIndex: number;
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const data: any = rawData?.[rowIndex];
|
||||
// Set default content
|
||||
let content =
|
||||
data?.[(mergedColumns as any)?.[columnIndex]?.dataIndex];
|
||||
// Check if the column has a render function
|
||||
const render = mergedColumns[columnIndex]?.render;
|
||||
if (typeof render === 'function') {
|
||||
// Use render function to generate formatted content using column's render function
|
||||
content = render(content, data, rowIndex);
|
||||
}
|
||||
|
||||
if (allowHTML && typeof content === 'string') {
|
||||
content = safeHtmlSpan(content);
|
||||
}
|
||||
|
||||
return (
|
||||
<StyledCell
|
||||
className={classNames('virtual-table-cell', {
|
||||
'virtual-table-cell-last':
|
||||
columnIndex === mergedColumns.length - 1,
|
||||
})}
|
||||
style={style}
|
||||
title={typeof content === 'string' ? content : undefined}
|
||||
theme={theme}
|
||||
height={cellSize}
|
||||
>
|
||||
{content}
|
||||
</StyledCell>
|
||||
);
|
||||
cellComponent={VirtualGridCell}
|
||||
cellProps={{
|
||||
mergedColumns,
|
||||
rawData,
|
||||
cellSize,
|
||||
allowHTML,
|
||||
theme,
|
||||
}}
|
||||
</Grid>
|
||||
onScroll={(event: UIEvent<HTMLDivElement>) => {
|
||||
onScroll({ scrollLeft: event.currentTarget.scrollLeft });
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ import {
|
||||
addAlpha,
|
||||
hexToRgb,
|
||||
rgbToHex,
|
||||
rgbaToHex,
|
||||
forceHexAlpha,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
describe('color utils', () => {
|
||||
@@ -106,4 +108,51 @@ describe('color utils', () => {
|
||||
expect(rgbToHex(0, 0, 0)).toBe('#000000');
|
||||
});
|
||||
});
|
||||
describe('rgbaToHex', () => {
|
||||
test('omits the alpha channel for opaque colors', () => {
|
||||
expect(rgbaToHex({ r: 255, g: 0, b: 0 })).toBe('#ff0000');
|
||||
expect(rgbaToHex({ r: 255, g: 0, b: 0, a: 1 })).toBe('#ff0000');
|
||||
});
|
||||
test('appends the alpha channel for translucent colors', () => {
|
||||
expect(rgbaToHex({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960033');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 0.5 })).toBe('#00000080');
|
||||
});
|
||||
test('fully transparent colors keep an explicit 00 alpha', () => {
|
||||
expect(rgbaToHex({ r: 255, g: 255, b: 255, a: 0 })).toBe('#ffffff00');
|
||||
});
|
||||
test('zero-pads single-digit channels', () => {
|
||||
expect(rgbaToHex({ r: 1, g: 2, b: 3 })).toBe('#010203');
|
||||
});
|
||||
test('rounds fractional channel values', () => {
|
||||
expect(rgbaToHex({ r: 254.6, g: 0.4, b: 0 })).toBe('#ff0000');
|
||||
});
|
||||
test('clamps out-of-range channel and alpha values', () => {
|
||||
expect(rgbaToHex({ r: 300, g: -10, b: 0 })).toBe('#ff0000');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: 1.5 })).toBe('#000000');
|
||||
expect(rgbaToHex({ r: 0, g: 0, b: 0, a: -0.5 })).toBe('#00000000');
|
||||
});
|
||||
});
|
||||
describe('forceHexAlpha', () => {
|
||||
test('appends 60% alpha to a 6-digit hex string', () => {
|
||||
expect(forceHexAlpha('#ff0000')).toBe('#ff000099');
|
||||
});
|
||||
test('adds the # prefix when missing', () => {
|
||||
expect(forceHexAlpha('ff0000')).toBe('#ff000099');
|
||||
});
|
||||
test('replaces the existing alpha on an 8-digit hex string', () => {
|
||||
expect(forceHexAlpha('#ff000033')).toBe('#ff000099');
|
||||
});
|
||||
test('converts an RGBColor object using 60% alpha', () => {
|
||||
expect(forceHexAlpha({ r: 255, g: 0, b: 0 })).toBe('#ff000099');
|
||||
});
|
||||
test('overrides the alpha of a translucent RGBColor object', () => {
|
||||
expect(forceHexAlpha({ r: 0, g: 150, b: 0, a: 0.2 })).toBe('#00960099');
|
||||
});
|
||||
test('expands a shorthand 3-digit hex string before adding alpha', () => {
|
||||
expect(forceHexAlpha('#fff')).toBe('#ffffff99');
|
||||
});
|
||||
test('expands a shorthand 4-digit hex string before replacing alpha', () => {
|
||||
expect(forceHexAlpha('#ff03')).toBe('#ffff0099');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -707,12 +707,8 @@ const config: ControlPanelConfig = {
|
||||
const extraColorChoices = hasTimeComparison
|
||||
? [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: t('Green for increase, red for decrease'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: t('Red for increase, green for decrease'),
|
||||
label: t('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -154,12 +154,8 @@ test('extraColorChoices included when time comparison is enabled', () => {
|
||||
|
||||
expect(result.extraColorChoices).toEqual([
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: expect.stringContaining('Green for increase'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: expect.stringContaining('Red for increase'),
|
||||
label: expect.stringContaining('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]);
|
||||
expect(result.columnOptions).not.toEqual(
|
||||
|
||||
@@ -296,7 +296,13 @@ function Echart(
|
||||
}
|
||||
: {};
|
||||
|
||||
// ECharts' built-in ARIA descriptions are off by default so behavior
|
||||
// doesn't change for existing deployments; a theme or chart's options
|
||||
// can opt in (or further customize aria handling) by overriding this.
|
||||
const ariaDefault = { aria: { enabled: false } };
|
||||
|
||||
const themedEchartOptions = mergeEchartsThemeOverrides(
|
||||
ariaDefault,
|
||||
baseTheme,
|
||||
echartOptions,
|
||||
globalOverrides,
|
||||
|
||||
@@ -30,7 +30,11 @@ import {
|
||||
TimeFormats,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { getColorFormatters } from '@superset-ui/chart-controls';
|
||||
import {
|
||||
ColorSchemeEnum,
|
||||
ConditionalFormattingConfig,
|
||||
getColorFormatters,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { DateFormatter, PivotTableQueryFormData, QueryData } from '../types';
|
||||
import buildGroupbyCombinations, {
|
||||
additiveReducerFor,
|
||||
@@ -206,8 +210,17 @@ export default function transformProps(chartProps: ChartProps<QueryFormData>) {
|
||||
},
|
||||
{},
|
||||
);
|
||||
// The "Green"/"Red" trend-color tokens are resolved by the Table chart's
|
||||
// own comparison-aware formatter, which this renderer does not implement.
|
||||
// Filter them out so a stale config (e.g. carried over from switching viz
|
||||
// types) doesn't leak the raw token name through as a literal CSS color.
|
||||
const pivotConditionalFormatting = conditionalFormatting?.filter(
|
||||
(config: ConditionalFormattingConfig) =>
|
||||
config.colorScheme !== ColorSchemeEnum.Green &&
|
||||
config.colorScheme !== ColorSchemeEnum.Red,
|
||||
);
|
||||
const metricColorFormatters = getColorFormatters(
|
||||
conditionalFormatting,
|
||||
pivotConditionalFormatting,
|
||||
mainQuery.data,
|
||||
theme,
|
||||
);
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
BinaryQueryObjectFilterClause,
|
||||
extractTextFromHTML,
|
||||
TimeGranularity,
|
||||
forceHexAlpha,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
styled,
|
||||
@@ -1094,7 +1095,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
formatter.objectFormatting === ObjectFormattingEnum.CELL_BAR
|
||||
) {
|
||||
if (generalShowCellBars)
|
||||
backgroundColorCellBar = formatterResult.slice(0, -2);
|
||||
backgroundColorCellBar = forceHexAlpha(formatterResult);
|
||||
} else {
|
||||
backgroundColor = formatterResult;
|
||||
valueRangeFlag = false;
|
||||
@@ -1182,7 +1183,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
alignPositiveNegative,
|
||||
})}%`};
|
||||
background-color: ${
|
||||
(backgroundColorCellBar && `${backgroundColorCellBar}99`) ||
|
||||
backgroundColorCellBar ||
|
||||
cellBackground({
|
||||
value: value as number,
|
||||
colorPositiveNegative,
|
||||
|
||||
@@ -757,12 +757,8 @@ const config: ControlPanelConfig = {
|
||||
const extraColorChoices = hasTimeComparison
|
||||
? [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: t('Green for increase, red for decrease'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: t('Red for increase, green for decrease'),
|
||||
label: t('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -774,6 +770,7 @@ const config: ControlPanelConfig = {
|
||||
(item: ConditionalFormattingConfig, index, array) => {
|
||||
if (
|
||||
item.colorScheme &&
|
||||
typeof item.colorScheme === 'string' &&
|
||||
!['Green', 'Red'].includes(item.colorScheme)
|
||||
) {
|
||||
if (item.columnFormatting === undefined) {
|
||||
|
||||
@@ -752,8 +752,15 @@ const transformProps = (
|
||||
const basicColorFormatters =
|
||||
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
|
||||
const columnColorFormatters =
|
||||
getColorFormatters(conditionalFormatting, passedData, theme) ??
|
||||
defaultColorFormatters;
|
||||
getColorFormatters(
|
||||
(conditionalFormatting || []).filter(
|
||||
(config: ConditionalFormattingConfig) =>
|
||||
config.colorScheme !== ColorSchemeEnum.Green &&
|
||||
config.colorScheme !== ColorSchemeEnum.Red,
|
||||
),
|
||||
passedData,
|
||||
theme,
|
||||
) ?? defaultColorFormatters;
|
||||
|
||||
const basicColorColumnFormatters = getBasicColorFormatterForColumn(
|
||||
baseQuery?.data,
|
||||
|
||||
@@ -156,12 +156,8 @@ test('extraColorChoices included when time comparison is enabled', () => {
|
||||
|
||||
expect(result.extraColorChoices).toEqual([
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: expect.stringContaining('Green for increase'),
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: expect.stringContaining('Red for increase'),
|
||||
label: expect.stringContaining('Trend colors'),
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
]);
|
||||
expect(result.columnOptions).not.toEqual(
|
||||
|
||||
@@ -350,6 +350,51 @@ test('getLayer coerces free-form string radius values to numbers', () => {
|
||||
expect(props.pointRadiusScale).toBe(0.25);
|
||||
});
|
||||
|
||||
// Regression for https://github.com/apache/superset/issues/34748: pins the
|
||||
// consumer/render boundary (recurseGeoJson feeding GeoJsonLayer's `data`),
|
||||
// complementing the transformProps.test.ts coverage of the query->feature step.
|
||||
test('getLayer forwards every feature from transformProps output to GeoJsonLayer', () => {
|
||||
const multiRowPayload = {
|
||||
data: {
|
||||
type: 'FeatureCollection',
|
||||
features: [
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.92, 47.38] },
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.93, 47.39] },
|
||||
properties: {},
|
||||
},
|
||||
{
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.94, 47.4] },
|
||||
properties: {},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const layer = getLayer({
|
||||
...baseLayerArgs,
|
||||
formData: baseFormData,
|
||||
payload: multiRowPayload,
|
||||
});
|
||||
|
||||
expect(layer.props.data).toHaveLength(3);
|
||||
expect(
|
||||
(layer.props.data as Array<{ geometry: { coordinates: number[] } }>).map(
|
||||
f => f.geometry.coordinates,
|
||||
),
|
||||
).toEqual([
|
||||
[2.92, 47.38],
|
||||
[2.93, 47.39],
|
||||
[2.94, 47.4],
|
||||
]);
|
||||
});
|
||||
|
||||
type ControlConfig = {
|
||||
default?: unknown;
|
||||
validators?: unknown[];
|
||||
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 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 { ChartProps, DatasourceType } from '@superset-ui/core';
|
||||
import transformProps from './transformProps';
|
||||
|
||||
// Regression for https://github.com/apache/superset/issues/34748: reporters
|
||||
// on 5.0.0/6.0.0 found that only a single row's GeoJSON Feature ever
|
||||
// rendered on the map, no matter how many rows the query returned (setting
|
||||
// the row limit to 1 made no visible difference).
|
||||
test('transformProps builds a feature for every row, not just the first', () => {
|
||||
const mockChartProps: Partial<ChartProps> = {
|
||||
rawFormData: {
|
||||
geojson: 'geometry',
|
||||
viewport: {},
|
||||
},
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{
|
||||
geometry: JSON.stringify({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.92, 47.38] },
|
||||
properties: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
geometry: JSON.stringify({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.93, 47.39] },
|
||||
properties: {},
|
||||
}),
|
||||
},
|
||||
{
|
||||
geometry: JSON.stringify({
|
||||
type: 'Feature',
|
||||
geometry: { type: 'Point', coordinates: [2.94, 47.4] },
|
||||
properties: {},
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
datasource: {
|
||||
type: DatasourceType.Table,
|
||||
id: 1,
|
||||
name: 'test_datasource',
|
||||
columns: [],
|
||||
metrics: [],
|
||||
},
|
||||
height: 400,
|
||||
width: 600,
|
||||
hooks: {},
|
||||
filterState: {},
|
||||
emitCrossFilters: false,
|
||||
};
|
||||
|
||||
const result = transformProps(mockChartProps as ChartProps);
|
||||
const features = result.payload.data.features as Array<{
|
||||
geometry: { coordinates: number[] };
|
||||
}>;
|
||||
|
||||
expect(features).toHaveLength(3);
|
||||
expect(features.map(f => f.geometry.coordinates)).toEqual([
|
||||
[2.92, 47.38],
|
||||
[2.93, 47.39],
|
||||
[2.94, 47.4],
|
||||
]);
|
||||
});
|
||||
@@ -26,7 +26,7 @@
|
||||
process.env.PATH = `./node_modules/.bin:${process.env.PATH}`;
|
||||
|
||||
const { spawnSync } = require('child_process');
|
||||
const fastGlob = require('fast-glob');
|
||||
const { globSync } = require('fs');
|
||||
const yargs = require('yargs');
|
||||
const { hideBin } = require('yargs/helpers');
|
||||
|
||||
@@ -60,12 +60,11 @@ function getPackages(packagePattern, tsOnly = false) {
|
||||
// Find packages in both @superset-ui and @apache-superset scopes
|
||||
const supersetUiPackages = [
|
||||
...new Set(
|
||||
fastGlob
|
||||
.sync([
|
||||
`./node_modules/@superset-ui/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
])
|
||||
globSync([
|
||||
`./node_modules/@superset-ui/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
])
|
||||
.map(x => x.split('/')[3])
|
||||
.filter(x => !META_PACKAGES.has(x)),
|
||||
),
|
||||
@@ -73,13 +72,11 @@ function getPackages(packagePattern, tsOnly = false) {
|
||||
|
||||
const apachePackages = [
|
||||
...new Set(
|
||||
fastGlob
|
||||
.sync([
|
||||
`./node_modules/@apache-superset/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
])
|
||||
.map(x => x.split('/')[3]),
|
||||
globSync([
|
||||
`./node_modules/@apache-superset/${pattern}/src/**/*.${
|
||||
tsOnly ? '{ts,tsx}' : '{ts,tsx,js,jsx}'
|
||||
}`,
|
||||
]).map(x => x.split('/')[3]),
|
||||
),
|
||||
];
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/* eslint-disable no-console */
|
||||
const { sync } = require('fast-glob');
|
||||
const { copy } = require('fs-extra');
|
||||
|
||||
const pkgGlob = process.argv[2] || '*';
|
||||
|
||||
const packages = sync([`{packages,plugins}/${pkgGlob}`], {
|
||||
onlyDirectories: true,
|
||||
});
|
||||
|
||||
console.log('Copying asset files from package {src} to {lib,esm}...');
|
||||
packages.forEach(pkg => {
|
||||
const assets = sync([`${pkg}/src/**/*.{png,gif,jpg,css,geojson}`]);
|
||||
assets.forEach(filePath => {
|
||||
['lib', 'esm']
|
||||
.map(dir => filePath.replace(`${pkg}/src`, `${pkg}/${dir}`))
|
||||
.forEach(newFilePath => {
|
||||
copy(filePath, newFilePath, err => {
|
||||
if (err) {
|
||||
console.error(err);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
if (assets.length > 0) {
|
||||
console.log(
|
||||
` Copied ${assets.length
|
||||
.toString()
|
||||
.padStart(2)} asset files for ${pkg.replace(
|
||||
'packages/superset-ui-',
|
||||
'',
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
CSSProperties,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -44,7 +43,7 @@ import {
|
||||
Icons,
|
||||
} from '@superset-ui/core/components';
|
||||
import { debounce } from 'lodash-es';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { List, type RowComponentProps } from 'react-window';
|
||||
import { InputRef } from 'antd';
|
||||
import { MenuItemTooltip } from '../DisabledMenuItemTooltip';
|
||||
import { VirtualizedMenuItem } from '../MenuItemWithTruncation';
|
||||
@@ -53,6 +52,34 @@ import { Dataset } from '../types';
|
||||
const SUBMENU_HEIGHT = 200;
|
||||
const SHOW_COLUMNS_SEARCH_THRESHOLD = 10;
|
||||
|
||||
interface DrillByColumnRowProps {
|
||||
columns: Column[];
|
||||
onSelectColumn: (event: React.MouseEvent, column: Column) => void;
|
||||
}
|
||||
|
||||
// Rendered via `rowComponent`, so it must be a stable reference (module
|
||||
// scope) rather than defined inline on every render of the submenu -
|
||||
// otherwise react-window would treat it as a new component type each
|
||||
// render and remount every row. All the data it needs is threaded
|
||||
// through `rowProps` instead of being closed over.
|
||||
function DrillByColumnRow({
|
||||
index,
|
||||
style,
|
||||
columns,
|
||||
onSelectColumn,
|
||||
}: RowComponentProps<DrillByColumnRowProps>) {
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => onSelectColumn(e, column)}
|
||||
style={style}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export interface DrillBySubmenuProps {
|
||||
drillByConfig?: ContextMenuFilters['drillBy'];
|
||||
formData: BaseFormData & { [key: string]: any };
|
||||
@@ -190,26 +217,9 @@ export const DrillBySubmenu = ({
|
||||
|
||||
const isDisabled = !handlesDimensionContextMenu || !hasDrillBy;
|
||||
|
||||
const Row = ({
|
||||
index,
|
||||
data,
|
||||
style,
|
||||
}: {
|
||||
index: number;
|
||||
data: { columns: Column[] };
|
||||
style: CSSProperties;
|
||||
}) => {
|
||||
const { columns } = data;
|
||||
const column = columns[index];
|
||||
return (
|
||||
<VirtualizedMenuItem
|
||||
tooltipText={column.verbose_name || column.column_name}
|
||||
onClick={e => handleSelection(e, column)}
|
||||
style={style}
|
||||
>
|
||||
{column.verbose_name || column.column_name}
|
||||
</VirtualizedMenuItem>
|
||||
);
|
||||
const listRowProps: DrillByColumnRowProps = {
|
||||
columns: filteredColumns,
|
||||
onSelectColumn: handleSelection,
|
||||
};
|
||||
|
||||
const popoverContent = (
|
||||
@@ -260,15 +270,13 @@ export const DrillBySubmenu = ({
|
||||
</div>
|
||||
) : filteredColumns.length ? (
|
||||
<List
|
||||
width="100%"
|
||||
height={SUBMENU_HEIGHT}
|
||||
itemSize={35}
|
||||
itemCount={filteredColumns.length}
|
||||
itemData={{ columns: filteredColumns }}
|
||||
style={{ width: '100%', height: SUBMENU_HEIGHT }}
|
||||
rowHeight={35}
|
||||
rowCount={filteredColumns.length}
|
||||
rowProps={listRowProps}
|
||||
rowComponent={DrillByColumnRow}
|
||||
overscanCount={20}
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
css={css`
|
||||
|
||||
@@ -45,28 +45,26 @@ jest.mock(
|
||||
children({ height: 500, width: 400 }),
|
||||
);
|
||||
|
||||
// Mock react-window VariableSizeList to render all items for testing
|
||||
// Mock react-window List to render all items for testing
|
||||
jest.mock('react-window', () => ({
|
||||
VariableSizeList: ({
|
||||
children: Row,
|
||||
itemCount,
|
||||
itemData,
|
||||
List: ({
|
||||
rowComponent: Row,
|
||||
rowCount,
|
||||
rowProps,
|
||||
}: {
|
||||
children: React.ComponentType<{
|
||||
index: number;
|
||||
style: React.CSSProperties;
|
||||
data: unknown;
|
||||
}>;
|
||||
itemCount: number;
|
||||
itemData: unknown;
|
||||
rowComponent: React.ComponentType<
|
||||
{ index: number; style: React.CSSProperties } & Record<string, unknown>
|
||||
>;
|
||||
rowCount: number;
|
||||
rowProps: Record<string, unknown>;
|
||||
}) => (
|
||||
<div data-testid="virtualized-list">
|
||||
{Array.from({ length: itemCount }, (_, index) => (
|
||||
{Array.from({ length: rowCount }, (_, index) => (
|
||||
<Row
|
||||
key={index}
|
||||
index={index}
|
||||
style={{ height: 'auto', position: 'relative' }}
|
||||
data={itemData}
|
||||
{...rowProps}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { CSSProperties, memo } from 'react';
|
||||
import type { ListChildComponentProps } from 'react-window';
|
||||
import type { RowComponentProps } from 'react-window';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import type { UniqueIdentifier } from '@dnd-kit/core';
|
||||
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
|
||||
@@ -135,32 +135,34 @@ const TreeItemWrapper = memo(function TreeItemWrapper({
|
||||
);
|
||||
});
|
||||
|
||||
function VirtualizedTreeItemComponent({
|
||||
// Not wrapped in `memo()` here: react-window v2 already wraps `rowComponent`
|
||||
// in its own `memo()` internally (comparing `ariaAttributes`/`style`/the
|
||||
// rest of the row props), so an outer `memo()` would be redundant - and
|
||||
// React 19's `memo()` typings widen the wrapped component's return type to
|
||||
// `ReactNode`, which react-window v2's `rowComponent` type (expecting
|
||||
// `ReactElement | null`) rejects.
|
||||
export function VirtualizedTreeItem({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
}: ListChildComponentProps<VirtualizedTreeItemData>) {
|
||||
const {
|
||||
flattenedItems,
|
||||
collapsedIds,
|
||||
selectedItemIds,
|
||||
editingFolderId,
|
||||
folderChildCounts,
|
||||
itemSeparatorInfo,
|
||||
visibleItemIds,
|
||||
searchTerm,
|
||||
metricsMap,
|
||||
columnsMap,
|
||||
activeId,
|
||||
draggedFolderChildIds,
|
||||
forbiddenDropFolderIds,
|
||||
currentDropTargetId,
|
||||
onToggleCollapse,
|
||||
onSelect,
|
||||
onStartEdit,
|
||||
onFinishEdit,
|
||||
} = data;
|
||||
|
||||
flattenedItems,
|
||||
collapsedIds,
|
||||
selectedItemIds,
|
||||
editingFolderId,
|
||||
folderChildCounts,
|
||||
itemSeparatorInfo,
|
||||
visibleItemIds,
|
||||
searchTerm,
|
||||
metricsMap,
|
||||
columnsMap,
|
||||
activeId,
|
||||
draggedFolderChildIds,
|
||||
forbiddenDropFolderIds,
|
||||
currentDropTargetId,
|
||||
onToggleCollapse,
|
||||
onSelect,
|
||||
onStartEdit,
|
||||
onFinishEdit,
|
||||
}: RowComponentProps<VirtualizedTreeItemData>) {
|
||||
const item = flattenedItems[index];
|
||||
|
||||
if (!item) {
|
||||
@@ -224,5 +226,3 @@ function VirtualizedTreeItemComponent({
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const VirtualizedTreeItem = memo(VirtualizedTreeItemComponent);
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import { useCallback, useMemo, useRef } from 'react';
|
||||
import { List, type ListImperativeAPI } from 'react-window';
|
||||
import type { UniqueIdentifier } from '@dnd-kit/core';
|
||||
import type { Metric, ColumnMeta } from '@superset-ui/chart-controls';
|
||||
import { FoldersEditorItemType } from '../types';
|
||||
@@ -82,7 +82,7 @@ export function VirtualizedTreeList({
|
||||
onStartEdit,
|
||||
onFinishEdit,
|
||||
}: VirtualizedTreeListProps) {
|
||||
const listRef = useRef<List>(null);
|
||||
const listRef = useRef<ListImperativeAPI>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Custom auto-scroll during drag (replaces dnd-kit's auto-scroll which conflicts with virtualization)
|
||||
@@ -93,20 +93,15 @@ export function VirtualizedTreeList({
|
||||
listHeight: height,
|
||||
});
|
||||
|
||||
// Reset list cache when items structure changes, but not during drag
|
||||
// Resetting during drag causes jumping/flickering
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}
|
||||
}, [
|
||||
flattenedItems,
|
||||
collapsedIds,
|
||||
folderChildCounts,
|
||||
itemSeparatorInfo,
|
||||
visibleItemIds,
|
||||
isDragging,
|
||||
]);
|
||||
// No manual cache-reset is needed here (react-window v2 has no
|
||||
// `resetAfterIndex`-style API): `getItemSize` below is a `useCallback`
|
||||
// whose dependency list matches everything that can change row heights,
|
||||
// so react-window's internal size cache - which is invalidated whenever
|
||||
// the `rowHeight` function reference changes - recomputes automatically.
|
||||
// Because `flattenedItems` is held stable during drag (see
|
||||
// useDragHandlers), `getItemSize`'s reference - and therefore the cache -
|
||||
// naturally stays stable during drag too, preserving the old "don't
|
||||
// reset while dragging" behavior without needing an explicit gate.
|
||||
|
||||
// Calculate item size for react-window
|
||||
const getItemSize = useCallback(
|
||||
@@ -169,7 +164,7 @@ export function VirtualizedTreeList({
|
||||
);
|
||||
|
||||
// Prepare item data for the row renderer
|
||||
const itemData: VirtualizedTreeItemData = useMemo(
|
||||
const rowProps: VirtualizedTreeItemData = useMemo(
|
||||
() => ({
|
||||
flattenedItems,
|
||||
collapsedIds,
|
||||
@@ -218,16 +213,14 @@ export function VirtualizedTreeList({
|
||||
return (
|
||||
<div ref={containerRef} style={{ width, height, position: 'relative' }}>
|
||||
<List
|
||||
ref={listRef}
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={flattenedItems.length}
|
||||
itemSize={getItemSize}
|
||||
itemData={itemData}
|
||||
listRef={listRef}
|
||||
style={{ width, height }}
|
||||
rowCount={flattenedItems.length}
|
||||
rowHeight={getItemSize}
|
||||
rowProps={rowProps}
|
||||
rowComponent={VirtualizedTreeItem}
|
||||
overscanCount={overscanCount}
|
||||
>
|
||||
{VirtualizedTreeItem}
|
||||
</List>
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import { useEffect, useRef } from 'react';
|
||||
import type { VariableSizeList as List } from 'react-window';
|
||||
import type { ListImperativeAPI } from 'react-window';
|
||||
|
||||
// Distance from edge where auto-scroll activates (in pixels)
|
||||
const SCROLL_THRESHOLD = 80;
|
||||
@@ -28,7 +28,7 @@ const BASE_SCROLL_SPEED = 8;
|
||||
const MAX_SPEED_MULTIPLIER = 3;
|
||||
|
||||
interface UseAutoScrollOptions {
|
||||
listRef: React.RefObject<List>;
|
||||
listRef: React.RefObject<ListImperativeAPI | null>;
|
||||
containerRef: React.RefObject<HTMLDivElement>;
|
||||
isDragging: boolean;
|
||||
listHeight: number;
|
||||
@@ -110,7 +110,7 @@ export function useAutoScroll({
|
||||
// Animation frame callback - uses time-based scrolling for consistent speed
|
||||
const scrollFrame = (currentTime: number) => {
|
||||
const list = listRef.current;
|
||||
const outerElement = (list as any)?._outerRef;
|
||||
const outerElement = list?.element;
|
||||
|
||||
if (!list || !outerElement || !state.direction) {
|
||||
// Restore pointer events when scrolling stops
|
||||
@@ -149,7 +149,7 @@ export function useAutoScroll({
|
||||
|
||||
if (Math.abs(newScroll - currentScroll) > 0.5) {
|
||||
// Use direct DOM manipulation for smoother scrolling
|
||||
// react-window's scrollTo triggers re-renders which can cause stutter
|
||||
// react-window's scrollToRow triggers re-renders which can cause stutter
|
||||
outerElement.scrollTop = newScroll;
|
||||
}
|
||||
|
||||
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* 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 { ErrorLevel, ErrorSource, ErrorTypeEnum } from '@superset-ui/core';
|
||||
import { render, screen } from 'spec/helpers/testing-library';
|
||||
import { DatasourceSecurityAccessErrorMessage } from './DatasourceSecurityAccessErrorMessage';
|
||||
|
||||
jest.mock(
|
||||
'@superset-ui/core/components/Icons/AsyncIcon',
|
||||
() =>
|
||||
({ fileName }: { fileName: string }) => (
|
||||
<img alt={fileName.replace('_', '-')} />
|
||||
),
|
||||
);
|
||||
|
||||
const baseProps = {
|
||||
error: {
|
||||
error_type: ErrorTypeEnum.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
extra: {
|
||||
datasource: 12,
|
||||
datasource_name: 'Quarterly Sales',
|
||||
owners: ['Jane Doe', 'Bob Smith'],
|
||||
link: 'https://access.example.com/request?dataset=12',
|
||||
issue_codes: [{ code: 1017, message: 'Permission issue' }],
|
||||
},
|
||||
level: 'error' as ErrorLevel,
|
||||
message:
|
||||
'This endpoint requires the datasource 12, database or ' +
|
||||
'`all_datasource_access` permission',
|
||||
},
|
||||
source: 'dashboard' as ErrorSource,
|
||||
subtitle: '',
|
||||
};
|
||||
|
||||
test('shows a friendly title and names the dataset', () => {
|
||||
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
|
||||
expect(
|
||||
screen.getByText("You don't have access to this chart's data"),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText(/Quarterly Sales/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces the chart owners to contact', () => {
|
||||
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
|
||||
expect(
|
||||
screen.getByText(/reach out to the chart owners: Jane Doe, Bob Smith/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a Request access link to the configured URL', () => {
|
||||
render(<DatasourceSecurityAccessErrorMessage {...baseProps} />);
|
||||
const link = screen.getByRole('link', { name: 'Request access' });
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://access.example.com/request?dataset=12',
|
||||
);
|
||||
});
|
||||
|
||||
test('falls back to administrator guidance when no owners are known', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
error: {
|
||||
...baseProps.error,
|
||||
extra: { datasource_name: 'Quarterly Sales' },
|
||||
},
|
||||
};
|
||||
render(<DatasourceSecurityAccessErrorMessage {...props} />);
|
||||
expect(
|
||||
screen.getByText(/contact your Superset administrator/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: 'Request access' })).toBeNull();
|
||||
});
|
||||
|
||||
test('explains table access for TABLE_SECURITY_ACCESS_ERROR', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
error: {
|
||||
...baseProps.error,
|
||||
error_type: ErrorTypeEnum.TABLE_SECURITY_ACCESS_ERROR,
|
||||
extra: { tables: ['public.sales', 'public.users'] },
|
||||
},
|
||||
};
|
||||
render(<DatasourceSecurityAccessErrorMessage {...props} />);
|
||||
expect(screen.getByText(/public.sales, public.users/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders plainly when the error carries no access payload', () => {
|
||||
// DATASOURCE_SECURITY_ACCESS_ERROR is reused for virtual-dataset SQL
|
||||
// validation ("Only SELECT statements are allowed"); without an access
|
||||
// payload the component must not show request-access guidance.
|
||||
const props = {
|
||||
...baseProps,
|
||||
error: {
|
||||
...baseProps.error,
|
||||
extra: null,
|
||||
message: 'Only `SELECT` statements are allowed',
|
||||
},
|
||||
};
|
||||
render(<DatasourceSecurityAccessErrorMessage {...props} />);
|
||||
expect(
|
||||
screen.getByText(/Only `SELECT` statements are allowed/),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByText(/Request access/)).toBeNull();
|
||||
expect(screen.queryByText(/access to this chart's data/)).toBeNull();
|
||||
});
|
||||
|
||||
test('uses query wording for the sqllab source', () => {
|
||||
const props = {
|
||||
...baseProps,
|
||||
source: 'sqllab' as ErrorSource,
|
||||
};
|
||||
render(<DatasourceSecurityAccessErrorMessage {...props} />);
|
||||
expect(
|
||||
screen.getByText(/This query uses the "Quarterly Sales"/),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText("You don't have access to this data"),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
+159
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* 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 { ReactNode } from 'react';
|
||||
import { t, tn } from '@apache-superset/core/translation';
|
||||
import { Typography } from '@superset-ui/core/components';
|
||||
|
||||
import type { ErrorMessageComponentProps } from './types';
|
||||
import { IssueCode } from './IssueCode';
|
||||
import { ErrorAlert } from './ErrorAlert';
|
||||
|
||||
interface DatasourceSecurityAccessExtra {
|
||||
owners?: string[];
|
||||
link?: string;
|
||||
datasource?: number | string;
|
||||
datasource_name?: string;
|
||||
tables?: string[];
|
||||
issue_codes?: {
|
||||
code: number;
|
||||
message: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when a viewer opens a chart but lacks permission to its underlying data
|
||||
* (DATASOURCE_SECURITY_ACCESS_ERROR / TABLE_SECURITY_ACCESS_ERROR). Surfaces a
|
||||
* plain-language explanation, who to contact, and a "Request access" link when
|
||||
* the deployment configures PERMISSION_INSTRUCTIONS_LINK.
|
||||
*/
|
||||
export function DatasourceSecurityAccessErrorMessage({
|
||||
error,
|
||||
source,
|
||||
closable,
|
||||
compact,
|
||||
}: ErrorMessageComponentProps<DatasourceSecurityAccessExtra | null>) {
|
||||
const { extra, level, message } = error;
|
||||
const isVisualization = ['dashboard', 'explore'].includes(source || '');
|
||||
|
||||
// DATASOURCE_SECURITY_ACCESS_ERROR is also raised for non-access failures
|
||||
// (e.g. virtual-dataset SQL validation: "Only SELECT statements are
|
||||
// allowed"). Those errors carry no access payload — render them plainly
|
||||
// rather than misleading the user with request-access guidance.
|
||||
const isAccessDenial = !!extra?.datasource_name || !!extra?.tables?.length;
|
||||
if (!isAccessDenial) {
|
||||
return (
|
||||
<ErrorAlert
|
||||
errorType={t('Unexpected error')}
|
||||
message={message}
|
||||
type={level}
|
||||
closable={closable}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let explanation: string;
|
||||
if (extra?.datasource_name) {
|
||||
explanation = isVisualization
|
||||
? t(
|
||||
'This chart uses the "%s" dataset, which you do not have ' +
|
||||
'permission to view.',
|
||||
extra.datasource_name,
|
||||
)
|
||||
: t(
|
||||
'This query uses the "%s" dataset, which you do not have ' +
|
||||
'permission to view.',
|
||||
extra.datasource_name,
|
||||
);
|
||||
} else {
|
||||
explanation = isVisualization
|
||||
? t(
|
||||
'You do not have access to the data behind this chart ' +
|
||||
'(tables: %s).',
|
||||
extra?.tables?.join(', '),
|
||||
)
|
||||
: t(
|
||||
'You do not have access to the following tables: %s.', // sqllab
|
||||
extra?.tables?.join(', '),
|
||||
);
|
||||
}
|
||||
|
||||
const owners = extra?.owners;
|
||||
const ownerLine =
|
||||
isVisualization && owners && owners.length > 0
|
||||
? tn(
|
||||
'To request access, reach out to the chart owner: %s.',
|
||||
'To request access, reach out to the chart owners: %s.',
|
||||
owners.length,
|
||||
owners.join(', '),
|
||||
)
|
||||
: t('To request access, contact your Superset administrator.');
|
||||
|
||||
// Actionable guidance stays visible (not collapsed): who to contact + link.
|
||||
const description: ReactNode = (
|
||||
<>
|
||||
<div>{ownerLine}</div>
|
||||
{extra?.link && (
|
||||
<Typography.Link
|
||||
href={extra.link}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('Request access')}
|
||||
</Typography.Link>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
// Technical detail is collapsible: issue codes and the raw backend message.
|
||||
const hasIssueCodes = !!extra?.issue_codes && extra.issue_codes.length > 0;
|
||||
const descriptionDetails: ReactNode =
|
||||
hasIssueCodes || message ? (
|
||||
<>
|
||||
{hasIssueCodes && (
|
||||
<p>
|
||||
{t('This may be triggered by:')}
|
||||
<br />
|
||||
{extra?.issue_codes?.flatMap((issueCode, idx, arr) => [
|
||||
<IssueCode {...issueCode} key={issueCode.code} />,
|
||||
idx < arr.length - 1 ? <br key={`br-${issueCode.code}`} /> : null,
|
||||
])}
|
||||
</p>
|
||||
)}
|
||||
{message && <pre>{message}</pre>}
|
||||
</>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<ErrorAlert
|
||||
errorType={
|
||||
isVisualization
|
||||
? t("You don't have access to this chart's data")
|
||||
: t("You don't have access to this data")
|
||||
}
|
||||
message={explanation}
|
||||
description={description}
|
||||
descriptionDetails={descriptionDetails}
|
||||
descriptionPre={false}
|
||||
type={level}
|
||||
closable={closable}
|
||||
compact={compact}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
export { BasicErrorAlert } from './BasicErrorAlert';
|
||||
export { DatabaseErrorMessage } from './DatabaseErrorMessage';
|
||||
export { DatasetNotFoundErrorMessage } from './DatasetNotFoundErrorMessage';
|
||||
export { DatasourceSecurityAccessErrorMessage } from './DatasourceSecurityAccessErrorMessage';
|
||||
export { ErrorAlert } from './ErrorAlert';
|
||||
export { ErrorMessageWithStackTrace } from './ErrorMessageWithStackTrace';
|
||||
export { getErrorMessageComponentRegistry } from './getErrorMessageComponentRegistry';
|
||||
|
||||
@@ -154,6 +154,7 @@ export const DEFAULT_COMMON_BOOTSTRAP_DATA: CommonBootstrapData = {
|
||||
},
|
||||
extra_categorical_color_schemes: [],
|
||||
extra_sequential_color_schemes: [],
|
||||
extra_theme_tokens: [],
|
||||
theme: {
|
||||
default: {},
|
||||
dark: {},
|
||||
|
||||
@@ -25,7 +25,11 @@ import {
|
||||
import { DatasourceType } from '@superset-ui/core';
|
||||
import { sliceEntitiesForDashboard as mockSliceEntities } from 'spec/fixtures/mockSliceEntities';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import SliceAdder, { SliceAdderProps, sortByComparator } from './SliceAdder';
|
||||
import SliceAdder, {
|
||||
SliceAdderProps,
|
||||
sortByComparator,
|
||||
SliceListRow,
|
||||
} from './SliceAdder';
|
||||
|
||||
// Mock the Select component to avoid debounce issues
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
@@ -236,4 +240,54 @@ describe('SliceAdder', () => {
|
||||
// Verify the internal state was updated by checking if new charts are available
|
||||
expect(screen.getByRole('checkbox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Covers the react-window v2 `rowComponent`/`rowProps` wiring: SliceAdder
|
||||
// hoists its row renderer to module scope (see SliceAdder.tsx) so
|
||||
// react-window doesn't remount every row on each render. This verifies
|
||||
// that hoisted row renders correctly from the flattened rowProps react-window
|
||||
// passes it (index/style plus the custom row props), rather than through
|
||||
// closures over component state.
|
||||
test('SliceListRow renders a chart card for the slice at the given index', () => {
|
||||
const filteredSlices = Object.values(mockSliceEntities.slices);
|
||||
render(
|
||||
<SliceListRow
|
||||
index={0}
|
||||
style={{}}
|
||||
ariaAttributes={{
|
||||
role: 'listitem',
|
||||
'aria-posinset': 1,
|
||||
'aria-setsize': filteredSlices.length,
|
||||
}}
|
||||
filteredSlices={filteredSlices}
|
||||
selectedSliceIdsSet={new Set()}
|
||||
editMode={false}
|
||||
/>,
|
||||
{ useDnd: true, store: mockStore },
|
||||
);
|
||||
expect(screen.getByTestId('chart-card')).toBeInTheDocument();
|
||||
expect(screen.getByText(filteredSlices[0].slice_name)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('SliceListRow renders the slice at the row index passed via rowProps, not index 0', () => {
|
||||
const filteredSlices = Object.values(mockSliceEntities.slices);
|
||||
render(
|
||||
<SliceListRow
|
||||
index={1}
|
||||
style={{}}
|
||||
ariaAttributes={{
|
||||
role: 'listitem',
|
||||
'aria-posinset': 2,
|
||||
'aria-setsize': filteredSlices.length,
|
||||
}}
|
||||
filteredSlices={filteredSlices}
|
||||
selectedSliceIdsSet={new Set()}
|
||||
editMode={false}
|
||||
/>,
|
||||
{ useDnd: true, store: mockStore },
|
||||
);
|
||||
expect(screen.getByText(filteredSlices[1].slice_name)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(filteredSlices[0].slice_name),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
/* eslint-env browser */
|
||||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { List, type RowComponentProps } from 'react-window';
|
||||
// @ts-expect-error
|
||||
import { createFilter } from 'react-search-input';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -167,6 +167,71 @@ function getFilteredSortedSlices(
|
||||
.sort(sortByComparator(sortBy));
|
||||
}
|
||||
|
||||
export interface SliceListRowProps {
|
||||
filteredSlices: Slice[];
|
||||
selectedSliceIdsSet: Set<number>;
|
||||
editMode: boolean;
|
||||
}
|
||||
|
||||
// Rendered via `rowComponent`, so it must be a stable reference (module
|
||||
// scope) rather than defined inline on every render of SliceAdder -
|
||||
// otherwise react-window would treat it as a new component type each
|
||||
// render and remount every row (losing in-flight drag state). All the
|
||||
// data it needs is threaded through `rowProps` instead of being closed
|
||||
// over.
|
||||
export function SliceListRow({
|
||||
index,
|
||||
style,
|
||||
filteredSlices,
|
||||
selectedSliceIdsSet,
|
||||
editMode,
|
||||
}: RowComponentProps<SliceListRowProps>) {
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
}
|
||||
|
||||
function SliceAdder({
|
||||
fetchSlices,
|
||||
updateSlices,
|
||||
@@ -321,53 +386,8 @@ function SliceAdder({
|
||||
[fetchSlices, searchTerm, sortBy],
|
||||
);
|
||||
|
||||
const rowRenderer = useCallback(
|
||||
({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const cellData = filteredSlices[index];
|
||||
|
||||
const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
|
||||
const type = CHART_TYPE;
|
||||
const id = NEW_CHART_ID;
|
||||
|
||||
const meta = {
|
||||
chartId: cellData.slice_id,
|
||||
sliceName: cellData.slice_name,
|
||||
};
|
||||
return (
|
||||
<DragDroppable
|
||||
key={cellData.slice_id}
|
||||
component={{ type, id, meta }}
|
||||
parentComponent={{
|
||||
id: NEW_COMPONENTS_SOURCE_ID,
|
||||
type: NEW_COMPONENT_SOURCE_TYPE,
|
||||
}}
|
||||
index={index}
|
||||
depth={0}
|
||||
disableDragDrop={isSelected}
|
||||
editMode={editMode}
|
||||
// we must use a custom drag preview within the List because
|
||||
// it does not seem to work within a fixed-position container
|
||||
useEmptyDragPreview
|
||||
// List library expect style props here
|
||||
// actual style should be applied to nested AddSliceCard component
|
||||
style={{}}
|
||||
>
|
||||
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
|
||||
<AddSliceCard
|
||||
innerRef={dragSourceRef}
|
||||
style={style}
|
||||
sliceName={cellData.slice_name}
|
||||
lastModified={cellData.changed_on_humanized}
|
||||
visType={cellData.viz_type}
|
||||
datasourceUrl={cellData.datasource_url}
|
||||
datasourceName={cellData.datasource_name}
|
||||
thumbnailUrl={cellData.thumbnail_url}
|
||||
isSelected={isSelected}
|
||||
/>
|
||||
)}
|
||||
</DragDroppable>
|
||||
);
|
||||
},
|
||||
const listRowProps = useMemo<SliceListRowProps>(
|
||||
() => ({ filteredSlices, selectedSliceIdsSet, editMode }),
|
||||
[filteredSlices, selectedSliceIdsSet, editMode],
|
||||
);
|
||||
|
||||
@@ -448,14 +468,13 @@ function SliceAdder({
|
||||
<AutoSizer>
|
||||
{({ height, width }: { height: number; width: number }) => (
|
||||
<List
|
||||
width={width}
|
||||
height={height}
|
||||
itemCount={filteredSlices.length}
|
||||
itemSize={DEFAULT_CELL_HEIGHT}
|
||||
itemKey={index => filteredSlices[index].slice_id}
|
||||
>
|
||||
{rowRenderer}
|
||||
</List>
|
||||
style={{ width, height }}
|
||||
rowCount={filteredSlices.length}
|
||||
rowHeight={DEFAULT_CELL_HEIGHT}
|
||||
rowProps={listRowProps}
|
||||
rowComponent={SliceListRow}
|
||||
rowKey={(index, data) => data.filteredSlices[index].slice_id}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</ChartList>
|
||||
|
||||
@@ -16,8 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { VariableSizeList as List } from 'react-window';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { List } from 'react-window';
|
||||
import { FlattenedItem, Folder } from './types';
|
||||
import DatasourcePanelItem from './DatasourcePanelItem';
|
||||
|
||||
@@ -98,7 +98,6 @@ export const DatasourceItems = ({
|
||||
height,
|
||||
folders,
|
||||
}: DatasourceItemsProps) => {
|
||||
const listRef = useRef<List>(null);
|
||||
const [collapsedFolderIds, setCollapsedFolderIds] = useState<Set<string>>(
|
||||
new Set(
|
||||
folders.filter(folder => folder.isCollapsed).map(folder => folder.id),
|
||||
@@ -122,17 +121,15 @@ export const DatasourceItems = ({
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// reset the list cache when flattenedItems length changes to recalculate the heights
|
||||
listRef.current?.resetAfterIndex(0);
|
||||
}, [flattenedItems]);
|
||||
|
||||
const getItemSize = useCallback(
|
||||
// No manual cache-reset is needed: `rowHeight` is a useCallback tied to
|
||||
// `flattenedItems`, so react-window's internal size cache recomputes
|
||||
// automatically whenever its reference changes.
|
||||
const rowHeight = useCallback(
|
||||
(index: number) => flattenedItems[index].height,
|
||||
[flattenedItems],
|
||||
);
|
||||
|
||||
const itemData = useMemo(
|
||||
const rowProps = useMemo(
|
||||
() => ({
|
||||
flattenedItems,
|
||||
folderMap,
|
||||
@@ -151,15 +148,12 @@ export const DatasourceItems = ({
|
||||
|
||||
return (
|
||||
<List
|
||||
ref={listRef}
|
||||
width={width - BORDER_WIDTH}
|
||||
height={height}
|
||||
itemSize={getItemSize}
|
||||
itemCount={flattenedItems.length}
|
||||
itemData={itemData}
|
||||
style={{ width: width - BORDER_WIDTH, height }}
|
||||
rowHeight={rowHeight}
|
||||
rowCount={flattenedItems.length}
|
||||
rowProps={rowProps}
|
||||
rowComponent={DatasourcePanelItem}
|
||||
overscanCount={5}
|
||||
>
|
||||
{DatasourcePanelItem}
|
||||
</List>
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
+15
-4
@@ -22,12 +22,12 @@ import {
|
||||
} from 'src/explore/components/DatasourcePanel/fixtures';
|
||||
import { screen, userEvent, render } from 'spec/helpers/testing-library';
|
||||
import DatasourcePanelItem, {
|
||||
DatasourcePanelItemProps,
|
||||
DatasourcePanelItemRowProps,
|
||||
} from './DatasourcePanelItem';
|
||||
import { FoldersEditorItemType } from 'src/components/Datasource/types';
|
||||
import { MetricItem, ColumnItem } from './types';
|
||||
|
||||
const mockData: DatasourcePanelItemProps['data'] = {
|
||||
const mockData: DatasourcePanelItemRowProps = {
|
||||
flattenedItems: [
|
||||
{ type: 'header', depth: 0, folderId: '1', height: 50 },
|
||||
...metrics.map((m, idx) => ({
|
||||
@@ -82,11 +82,22 @@ const mockData: DatasourcePanelItemProps['data'] = {
|
||||
collapsedFolderIds: new Set(),
|
||||
};
|
||||
|
||||
const setup = (data: DatasourcePanelItemProps['data'] = mockData) =>
|
||||
const setup = (data: DatasourcePanelItemRowProps = mockData) =>
|
||||
render(
|
||||
<>
|
||||
{data.flattenedItems.map((_, index) => (
|
||||
<DatasourcePanelItem index={index} data={data} style={{}} />
|
||||
<DatasourcePanelItem
|
||||
// eslint-disable-next-line react/no-array-index-key -- test fixture has no stable id
|
||||
key={index}
|
||||
index={index}
|
||||
style={{}}
|
||||
ariaAttributes={{
|
||||
role: 'listitem',
|
||||
'aria-posinset': index + 1,
|
||||
'aria-setsize': data.flattenedItems.length,
|
||||
}}
|
||||
{...data}
|
||||
/>
|
||||
))}
|
||||
</>,
|
||||
{ useDnd: true, useRedux: true, initialState: { explore: {} } },
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { CSSProperties, ReactNode, useCallback } from 'react';
|
||||
import { ReactNode, useCallback } from 'react';
|
||||
import type { RowComponentProps } from 'react-window';
|
||||
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { useCSSTextTruncation } from '@superset-ui/core';
|
||||
@@ -112,30 +113,26 @@ const Divider = styled.div`
|
||||
`}
|
||||
`;
|
||||
|
||||
export interface DatasourcePanelItemProps {
|
||||
index: number;
|
||||
style: CSSProperties;
|
||||
data: {
|
||||
flattenedItems: FlattenedItem[];
|
||||
folderMap: Map<string, Folder>;
|
||||
width: number;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
collapsedFolderIds: Set<string>;
|
||||
};
|
||||
export interface DatasourcePanelItemRowProps {
|
||||
flattenedItems: FlattenedItem[];
|
||||
folderMap: Map<string, Folder>;
|
||||
width: number;
|
||||
onToggleCollapse: (folderId: string) => void;
|
||||
collapsedFolderIds: Set<string>;
|
||||
}
|
||||
|
||||
export type DatasourcePanelItemProps =
|
||||
RowComponentProps<DatasourcePanelItemRowProps>;
|
||||
|
||||
const DatasourcePanelItem = ({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
flattenedItems,
|
||||
folderMap,
|
||||
width,
|
||||
onToggleCollapse,
|
||||
collapsedFolderIds,
|
||||
}: DatasourcePanelItemProps) => {
|
||||
const {
|
||||
flattenedItems,
|
||||
folderMap,
|
||||
width,
|
||||
onToggleCollapse,
|
||||
collapsedFolderIds,
|
||||
} = data;
|
||||
const item = flattenedItems[index];
|
||||
const theme = useTheme();
|
||||
const [labelRef, labelIsTruncated] = useCSSTextTruncation<HTMLSpanElement>({
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from 'spec/helpers/testing-library';
|
||||
import {
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import {
|
||||
CategoricalScheme,
|
||||
getCategoricalSchemeRegistry,
|
||||
@@ -28,67 +33,208 @@ const defaultProps = {
|
||||
onChange: jest.fn(),
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||
describe('ColorPickerControl', () => {
|
||||
beforeAll(() => {
|
||||
getCategoricalSchemeRegistry()
|
||||
.registerValue(
|
||||
'test',
|
||||
new CategoricalScheme({
|
||||
id: 'test',
|
||||
colors: ['#ff0000', '#00ff00', '#0000ff'],
|
||||
}),
|
||||
)
|
||||
.setDefaultKey('test');
|
||||
beforeAll(() => {
|
||||
getCategoricalSchemeRegistry()
|
||||
.registerValue(
|
||||
'test',
|
||||
new CategoricalScheme({
|
||||
id: 'test',
|
||||
colors: ['#ff0000', '#00ff00', '#0000ff'],
|
||||
}),
|
||||
)
|
||||
.setDefaultKey('test');
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders a ColorPicker component', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
|
||||
// AntD ColorPicker renders a trigger element with class
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('displays the correct color value', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
|
||||
// The color should be displayed as hex #007A87 (uppercase in AntD)
|
||||
expect(screen.getByText('#007A87')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onChange with RGB values when color changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ColorPickerControl {...defaultProps} onChange={onChange} />);
|
||||
|
||||
// Open the color picker
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
|
||||
if (colorPickerTrigger) {
|
||||
await userEvent.click(colorPickerTrigger);
|
||||
}
|
||||
|
||||
// Note: Testing actual color selection in AntD ColorPicker would require more complex mocking
|
||||
// as it uses complex internal components. The main functionality is covered by the component itself.
|
||||
});
|
||||
|
||||
test('includes preset colors from the categorical scheme', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
|
||||
// The component should have access to the preset colors from the registry
|
||||
// This is tested by ensuring the component renders without errors with the presets
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onChange with string key "Green" when resolveThemeTokens is true', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ColorPickerControl
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
resolveThemeTokens
|
||||
presets={[{ label: 'Special Colors', colors: ['Green', 'Red'] }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
await userEvent.click(colorPickerTrigger!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.ant-color-picker-presets-color'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
|
||||
const greenPreset = presets[0];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
expect(greenPreset).toBeInTheDocument();
|
||||
await userEvent.click(greenPreset);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('Green');
|
||||
});
|
||||
|
||||
test('calls onChange with RGB object when resolveThemeTokens is false', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
render(
|
||||
<ColorPickerControl
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
resolveThemeTokens={false}
|
||||
outputFormat="hex"
|
||||
presets={[{ label: 'Special Colors', colors: ['Green', 'Red'] }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
await userEvent.click(colorPickerTrigger!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.ant-color-picker-presets-color'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
|
||||
const greenPreset = presets[0];
|
||||
|
||||
expect(greenPreset).toBeInTheDocument();
|
||||
await userEvent.click(greenPreset);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#00960033');
|
||||
});
|
||||
|
||||
test('renders a ColorPicker component', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
test('resolves colorSuccess theme token correctly when matching color is selected', async () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
// AntD ColorPicker renders a trigger element with class
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
jest
|
||||
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
|
||||
.mockReturnValue({
|
||||
colors: {
|
||||
colorSuccess: 'rgba(82, 196, 26, 1)',
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<ColorPickerControl
|
||||
{...defaultProps}
|
||||
onChange={onChange}
|
||||
resolveThemeTokens
|
||||
presets={[{ label: 'Theme Tokens', colors: ['colorSuccess'] }]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
await userEvent.click(colorPickerTrigger!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.ant-color-picker-presets-items'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const successPreset = document.querySelector(
|
||||
'.ant-color-picker-presets-color [style*="82, 196, 26"]',
|
||||
) as HTMLElement | null;
|
||||
|
||||
test('displays the correct color value', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
expect(successPreset).toBeInTheDocument();
|
||||
|
||||
// The color should be displayed as hex #007A87 (uppercase in AntD)
|
||||
expect(screen.getByText('#007A87')).toBeInTheDocument();
|
||||
});
|
||||
await userEvent.click(successPreset!);
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('colorSuccess');
|
||||
});
|
||||
|
||||
test('calls onChange with RGB values when color changes', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ColorPickerControl {...defaultProps} onChange={onChange} />);
|
||||
test('handles theme with nested colors object', () => {
|
||||
jest
|
||||
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
|
||||
.mockReturnValue({
|
||||
colors: { primary: '#007bff' },
|
||||
});
|
||||
|
||||
// Open the color picker
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
const { container } = render(<ColorPickerControl {...defaultProps} />);
|
||||
expect(
|
||||
container.querySelector('.ant-color-picker-trigger'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
if (colorPickerTrigger) {
|
||||
await userEvent.click(colorPickerTrigger);
|
||||
}
|
||||
test('handles theme without colors field', () => {
|
||||
jest
|
||||
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
|
||||
.mockReturnValue({
|
||||
primary: '#007bff',
|
||||
});
|
||||
|
||||
// Note: Testing actual color selection in AntD ColorPicker would require more complex mocking
|
||||
// as it uses complex internal components. The main functionality is covered by the component itself.
|
||||
});
|
||||
const { container } = render(<ColorPickerControl {...defaultProps} />);
|
||||
expect(
|
||||
container.querySelector('.ant-color-picker-trigger'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('includes preset colors from the categorical scheme', () => {
|
||||
render(<ColorPickerControl {...defaultProps} />);
|
||||
test('handles undefined theme gracefully', () => {
|
||||
jest
|
||||
.spyOn(require('@apache-superset/core/theme'), 'useTheme')
|
||||
.mockReturnValue(undefined);
|
||||
|
||||
// The component should have access to the preset colors from the registry
|
||||
// This is tested by ensuring the component renders without errors with the presets
|
||||
const colorPickerTrigger = document.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
});
|
||||
expect(() => render(<ColorPickerControl {...defaultProps} />)).not.toThrow();
|
||||
});
|
||||
|
||||
@@ -16,70 +16,215 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { getCategoricalSchemeRegistry } from '@superset-ui/core';
|
||||
import { useMemo } from 'react';
|
||||
import { getCategoricalSchemeRegistry, rgbaToHex } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
ColorPicker,
|
||||
type RGBColor,
|
||||
type ColorValue,
|
||||
} from '@superset-ui/core/components';
|
||||
import ControlHeader from '../ControlHeader';
|
||||
import { useTheme, type SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
const SPECIAL_COLORS = {
|
||||
Red: { r: 150, g: 0, b: 0, a: 0.2 },
|
||||
Green: { r: 0, g: 150, b: 0, a: 0.2 },
|
||||
} as const;
|
||||
|
||||
type SpecialColorKey = keyof typeof SPECIAL_COLORS;
|
||||
export type ColorPickerValue = RGBColor | SpecialColorKey | string;
|
||||
export type ColorOutputFormat = 'hex' | 'rgb';
|
||||
|
||||
export interface ColorPickerControlProps {
|
||||
onChange?: (color: RGBColor) => void;
|
||||
value?: RGBColor;
|
||||
onChange?: (color: ColorPickerValue) => void;
|
||||
value?: ColorPickerValue;
|
||||
name?: string;
|
||||
label?: string;
|
||||
description?: string;
|
||||
renderTrigger?: boolean;
|
||||
hovered?: boolean;
|
||||
warning?: string;
|
||||
presets?: { label: string; colors: string[] }[];
|
||||
ariaLabel?: string;
|
||||
resolveThemeTokens?: boolean;
|
||||
outputFormat?: ColorOutputFormat;
|
||||
}
|
||||
|
||||
function rgbToHex(rgb: RGBColor): string {
|
||||
const { r, g, b, a = 1 } = rgb;
|
||||
const toHex = (value: number) => {
|
||||
const hex = Math.round(value).toString(16);
|
||||
return hex.length === 1 ? `0${hex}` : hex;
|
||||
};
|
||||
const normalizeColorToHex = (color: string): string => {
|
||||
if (!color) return '';
|
||||
|
||||
const hexColor = `#${toHex(r)}${toHex(g)}${toHex(b)}`;
|
||||
|
||||
if (a !== undefined && a !== 1) {
|
||||
return `${hexColor}${toHex(Math.round(a * 255))}`;
|
||||
if (color.startsWith('#')) {
|
||||
return color.toLowerCase();
|
||||
}
|
||||
|
||||
return hexColor;
|
||||
const div = document.createElement('div');
|
||||
div.style.color = color;
|
||||
const normalized = div.style.color || '';
|
||||
|
||||
const match = /^rgba?\((\d+),\s+(\d+),\s+(\d+)(?:,\s*([\d.]+))?\)$/.exec(
|
||||
normalized,
|
||||
);
|
||||
if (match) {
|
||||
return rgbaToHex({
|
||||
r: parseInt(match[1], 10),
|
||||
g: parseInt(match[2], 10),
|
||||
b: parseInt(match[3], 10),
|
||||
a: match[4] !== undefined ? parseFloat(match[4]) : 1,
|
||||
}).toLowerCase();
|
||||
}
|
||||
|
||||
return color.toLowerCase();
|
||||
};
|
||||
|
||||
const getReverseThemeColorMap = (
|
||||
themeColors: Record<string, string>,
|
||||
): Map<string, string> => {
|
||||
const reverseMap = new Map<string, string>();
|
||||
if (!themeColors) return reverseMap;
|
||||
|
||||
Object.entries(themeColors).forEach(([name, value]) => {
|
||||
if (typeof value === 'string') {
|
||||
const hex = normalizeColorToHex(value);
|
||||
if (!reverseMap.has(hex)) {
|
||||
reverseMap.set(hex, name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return reverseMap;
|
||||
};
|
||||
|
||||
function toDisplayHex(
|
||||
value: ColorPickerValue | undefined,
|
||||
themeColors: Record<string, string>,
|
||||
): string | undefined {
|
||||
if (!value) return undefined;
|
||||
|
||||
if (typeof value === 'string') {
|
||||
if (value in SPECIAL_COLORS) {
|
||||
return rgbaToHex(SPECIAL_COLORS[value as SpecialColorKey]).toLowerCase();
|
||||
}
|
||||
if (
|
||||
themeColors &&
|
||||
Object.prototype.hasOwnProperty.call(themeColors, value)
|
||||
) {
|
||||
return themeColors[value as string].toLowerCase();
|
||||
}
|
||||
return value.toLowerCase();
|
||||
}
|
||||
|
||||
return rgbaToHex(value).toLowerCase();
|
||||
}
|
||||
|
||||
const extractThemeColors = (
|
||||
theme: SupersetTheme | undefined | null,
|
||||
): Record<string, string> => {
|
||||
if (!theme || typeof theme !== 'object') {
|
||||
return {};
|
||||
}
|
||||
|
||||
if (
|
||||
'colors' in theme &&
|
||||
typeof theme.colors === 'object' &&
|
||||
theme.colors !== null
|
||||
) {
|
||||
return theme.colors as Record<string, string>;
|
||||
}
|
||||
|
||||
return theme as unknown as Record<string, string>;
|
||||
};
|
||||
|
||||
export default function ColorPickerControl({
|
||||
onChange,
|
||||
value,
|
||||
presets: customPresets,
|
||||
ariaLabel,
|
||||
resolveThemeTokens = false,
|
||||
outputFormat = 'rgb',
|
||||
...headerProps
|
||||
}: ColorPickerControlProps) {
|
||||
const categoricalScheme = getCategoricalSchemeRegistry().get();
|
||||
const presetColors = categoricalScheme?.colors.slice(0, 9) || [];
|
||||
const defaultPresets = categoricalScheme?.colors.slice(0, 9) || [];
|
||||
const theme = useTheme();
|
||||
|
||||
const themeColors = useMemo<Record<string, string>>(
|
||||
() => extractThemeColors(theme),
|
||||
[theme],
|
||||
);
|
||||
|
||||
const reverseMap = useMemo(
|
||||
() => getReverseThemeColorMap(themeColors),
|
||||
[themeColors],
|
||||
);
|
||||
|
||||
const presets = useMemo(() => {
|
||||
if (customPresets) {
|
||||
return customPresets.map(item => ({
|
||||
label: item.label,
|
||||
colors: item.colors.map(color => {
|
||||
if (color in SPECIAL_COLORS) {
|
||||
return rgbaToHex(
|
||||
SPECIAL_COLORS[color as SpecialColorKey],
|
||||
).toLowerCase();
|
||||
}
|
||||
if (
|
||||
themeColors &&
|
||||
Object.prototype.hasOwnProperty.call(themeColors, color as string)
|
||||
) {
|
||||
return themeColors[color as string].toLowerCase();
|
||||
}
|
||||
return String(color).toLowerCase();
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('Theme colors'),
|
||||
colors: defaultPresets.map(c => String(c).toLowerCase()),
|
||||
},
|
||||
];
|
||||
}, [customPresets, themeColors, defaultPresets]);
|
||||
|
||||
const handleChange = (color: ColorValue) => {
|
||||
if (onChange) {
|
||||
const rgb = color.toRgb();
|
||||
onChange({
|
||||
r: rgb.r,
|
||||
g: rgb.g,
|
||||
b: rgb.b,
|
||||
a: rgb.a,
|
||||
});
|
||||
if (!onChange) return;
|
||||
|
||||
const rgb = color.toRgb();
|
||||
const hex = rgbaToHex(rgb).toLowerCase();
|
||||
|
||||
const specialEntry = resolveThemeTokens
|
||||
? Object.entries(SPECIAL_COLORS).find(
|
||||
([, rgba]) => rgbaToHex(rgba).toLowerCase() === hex,
|
||||
)
|
||||
: undefined;
|
||||
|
||||
if (specialEntry) {
|
||||
onChange(specialEntry[0] as SpecialColorKey);
|
||||
return;
|
||||
}
|
||||
|
||||
if (resolveThemeTokens && reverseMap.has(hex)) {
|
||||
const tokenName = reverseMap.get(hex);
|
||||
if (tokenName) {
|
||||
onChange(tokenName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (outputFormat === 'rgb') onChange(rgb);
|
||||
else onChange(hex);
|
||||
};
|
||||
|
||||
const hexValue = value ? rgbToHex(value) : undefined;
|
||||
const hexValue = toDisplayHex(value, themeColors);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<ControlHeader {...headerProps} />
|
||||
<ColorPicker
|
||||
aria-label={ariaLabel}
|
||||
value={hexValue}
|
||||
onChangeComplete={handleChange}
|
||||
presets={[{ label: 'Theme colors', colors: presetColors }]}
|
||||
presets={presets}
|
||||
showText
|
||||
/>
|
||||
</div>
|
||||
|
||||
+80
-14
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
fireEvent,
|
||||
waitFor,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { Comparator, ColorSchemeEnum } from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
@@ -51,12 +52,8 @@ const mixColumns = [
|
||||
|
||||
const extraColorChoices = [
|
||||
{
|
||||
value: ColorSchemeEnum.Green,
|
||||
label: 'Green for increase, red for decrease',
|
||||
},
|
||||
{
|
||||
value: ColorSchemeEnum.Red,
|
||||
label: 'Red for increase, green for decrease',
|
||||
label: 'Colors',
|
||||
colors: [ColorSchemeEnum.Green, ColorSchemeEnum.Red],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -117,7 +114,7 @@ test('renders the correct input fields based on the selected operator', async ()
|
||||
});
|
||||
|
||||
test('renders None for operator when Green for increase is selected', async () => {
|
||||
render(
|
||||
const { container } = render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
@@ -125,15 +122,44 @@ test('renders None for operator when Green for increase is selected', async () =
|
||||
/>,
|
||||
);
|
||||
|
||||
// Select the 'Green for increase' color scheme
|
||||
fireEvent.change(screen.getAllByLabelText(/color scheme/i)[0], {
|
||||
target: { value: ColorSchemeEnum.Green },
|
||||
const colorPickerTrigger = container.querySelector(
|
||||
'.ant-color-picker-trigger',
|
||||
);
|
||||
expect(colorPickerTrigger).toBeInTheDocument();
|
||||
await userEvent.click(colorPickerTrigger!);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
document.querySelector('.ant-color-picker-presets-items'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(await screen.findByTitle(/green for increase/i));
|
||||
const presets = document.querySelectorAll('.ant-color-picker-presets-color');
|
||||
const greenPreset = Array.from(presets).find(preset => {
|
||||
const inner = preset.querySelector('.ant-color-picker-color-block-inner');
|
||||
return (
|
||||
inner && inner.getAttribute('style')?.includes('rgba(0, 150, 0, 0.2)')
|
||||
);
|
||||
});
|
||||
|
||||
// Assert that the operator is set to 'None'
|
||||
expect(screen.getByText(/none/i)).toBeInTheDocument();
|
||||
expect(greenPreset).toBeDefined();
|
||||
expect(greenPreset).toBeInTheDocument();
|
||||
const safeGreenPreset = greenPreset as HTMLElement;
|
||||
|
||||
const innerColorBlock = safeGreenPreset.querySelector(
|
||||
'.ant-color-picker-color-block-inner',
|
||||
);
|
||||
expect(innerColorBlock).toHaveStyle({ background: 'rgba(0, 150, 0, 0.2)' });
|
||||
|
||||
expect(safeGreenPreset).toBeInTheDocument();
|
||||
await userEvent.click(safeGreenPreset);
|
||||
|
||||
const operatorInput = screen.getByLabelText('Operator');
|
||||
expect(operatorInput).toBeInTheDocument();
|
||||
|
||||
const operatorSelect = operatorInput.closest('.ant-select-content');
|
||||
expect(operatorSelect).toBeInTheDocument();
|
||||
expect(operatorSelect).toHaveTextContent(/none/i);
|
||||
});
|
||||
|
||||
test('displays the correct input fields based on the selected string type operator', async () => {
|
||||
@@ -295,7 +321,7 @@ test('should hide formatting fields when allColumns is empty', async () => {
|
||||
test('should hide formatting fields when color scheme is Green', async () => {
|
||||
render(
|
||||
<FormattingPopoverContent
|
||||
config={{ colorScheme: extraColorChoices[0].value }}
|
||||
config={{ colorScheme: extraColorChoices[0].colors[0] }}
|
||||
columns={mixColumns}
|
||||
allColumns={mixColumns}
|
||||
onChange={mockOnChange}
|
||||
@@ -307,3 +333,43 @@ test('should hide formatting fields when color scheme is Green', async () => {
|
||||
expect(screen.queryByText('Formatting object')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('should not display tooltip when extraColorChoices is not provided', async () => {
|
||||
const { container } = render(
|
||||
<FormattingPopoverContent onChange={mockOnChange} columns={columns} />,
|
||||
);
|
||||
|
||||
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
|
||||
expect(tooltipIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should display tooltip icon when extraColorChoices is provided', () => {
|
||||
const { container } = render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
extraColorChoices={extraColorChoices}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
|
||||
expect(tooltipIcon).toBeInTheDocument();
|
||||
|
||||
const questionIcon = tooltipIcon?.querySelector(
|
||||
'[aria-label="question-circle"]',
|
||||
);
|
||||
expect(questionIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should not display tooltip icon when extraColorChoices is empty', () => {
|
||||
const { container } = render(
|
||||
<FormattingPopoverContent
|
||||
onChange={mockOnChange}
|
||||
columns={columns}
|
||||
extraColorChoices={[]}
|
||||
/>,
|
||||
);
|
||||
|
||||
const tooltipIcon = container.querySelector('.ant-form-item-tooltip');
|
||||
expect(tooltipIcon).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
+20
-7
@@ -44,8 +44,9 @@ import {
|
||||
stringOperatorOptions,
|
||||
booleanOperatorOptions,
|
||||
formattingOptions,
|
||||
colorSchemeOptions,
|
||||
colorScheme,
|
||||
} from './constants';
|
||||
import ColorPickerControl from '../ColorPickerControl';
|
||||
|
||||
const FullWidthInputNumber = styled(InputNumber)`
|
||||
width: 100%;
|
||||
@@ -236,11 +237,11 @@ export const FormattingPopoverContent = ({
|
||||
config?: ConditionalFormattingConfig;
|
||||
onChange: (config: ConditionalFormattingConfig) => void;
|
||||
columns: { label: string; value: string; dataType: GenericDataType }[];
|
||||
extraColorChoices?: { label: string; value: string }[];
|
||||
extraColorChoices?: { label: string; colors: string[] }[];
|
||||
allColumns?: ColumnOption[];
|
||||
}) => {
|
||||
const [form] = Form.useForm();
|
||||
const colorScheme = colorSchemeOptions();
|
||||
const colors = colorScheme();
|
||||
const [showOperatorFields, setShowOperatorFields] = useState(
|
||||
config === undefined ||
|
||||
(config?.colorScheme !== ColorSchemeEnum.Green &&
|
||||
@@ -319,6 +320,7 @@ export const FormattingPopoverContent = ({
|
||||
() => allColumns.filter(col => col.dataType === GenericDataType.Numeric),
|
||||
[allColumns],
|
||||
);
|
||||
const defaultColorToken = colors[0]?.colors?.[0];
|
||||
|
||||
const visibleUseGradient = useMemo(
|
||||
() =>
|
||||
@@ -365,6 +367,14 @@ export const FormattingPopoverContent = ({
|
||||
}
|
||||
}, [column, columns, previousColumnType]);
|
||||
|
||||
const trendColorsTooltip = (
|
||||
<div>
|
||||
<div>{t('Trend colors are added (for time-based comparison):')}</div>
|
||||
<div>{t('green — increase / red — decrease')}</div>
|
||||
<div>{t('red — increase / green — decrease')}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
@@ -395,12 +405,15 @@ export const FormattingPopoverContent = ({
|
||||
name="colorScheme"
|
||||
label={t('Color scheme')}
|
||||
rules={rulesRequired}
|
||||
initialValue={colorScheme[0].value}
|
||||
initialValue={defaultColorToken}
|
||||
tooltip={extraColorChoices.length > 0 ? trendColorsTooltip : ''}
|
||||
>
|
||||
<Select
|
||||
onChange={event => handleChange(event)}
|
||||
<ColorPickerControl
|
||||
ariaLabel={t('Color scheme')}
|
||||
options={[...colorScheme, ...extraColorChoices]}
|
||||
onChange={event => handleChange(event)}
|
||||
presets={[...colors, ...extraColorChoices]}
|
||||
resolveThemeTokens
|
||||
outputFormat="hex"
|
||||
/>
|
||||
</FormItem>
|
||||
</Col>
|
||||
|
||||
+12
-4
@@ -65,8 +65,16 @@ export const formattingOptions = [
|
||||
];
|
||||
|
||||
// Use theme token names instead of hex values to support theme switching
|
||||
export const colorSchemeOptions = () => [
|
||||
{ value: 'colorSuccess', label: t('success') },
|
||||
{ value: 'colorWarning', label: t('alert') },
|
||||
{ value: 'colorError', label: t('error') },
|
||||
export const colorScheme = () => [
|
||||
{
|
||||
label: t('Colors'),
|
||||
colors: [
|
||||
'colorSuccess',
|
||||
'colorWarning',
|
||||
'colorError',
|
||||
'colorSuccessBg',
|
||||
'colorWarningBg',
|
||||
'colorErrorBg',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
+4
-3
@@ -25,6 +25,7 @@ import {
|
||||
ObjectFormattingEnum,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { type RGBColor } from '@superset-ui/core/components';
|
||||
|
||||
export type ConditionalFormattingConfig = {
|
||||
operator?: Comparator;
|
||||
@@ -32,7 +33,7 @@ export type ConditionalFormattingConfig = {
|
||||
targetValueLeft?: number;
|
||||
targetValueRight?: number;
|
||||
column?: string;
|
||||
colorScheme?: string;
|
||||
colorScheme?: RGBColor | string;
|
||||
toAllRow?: boolean;
|
||||
toTextColor?: boolean;
|
||||
useGradient?: boolean;
|
||||
@@ -48,7 +49,7 @@ export type ConditionalFormattingControlProps = ControlComponentProps<
|
||||
verboseMap: Record<string, string>;
|
||||
label: string;
|
||||
description: string;
|
||||
extraColorChoices?: { label: string; value: string }[];
|
||||
extraColorChoices?: { label: string; colors: string[] }[];
|
||||
allColumns?: ColumnOption[];
|
||||
};
|
||||
|
||||
@@ -58,7 +59,7 @@ export type FormattingPopoverProps = PopoverProps & {
|
||||
config?: ConditionalFormattingConfig;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
extraColorChoices?: { label: string; value: string }[];
|
||||
extraColorChoices?: { label: string; colors: string[] }[];
|
||||
allColumns?: ColumnOption[];
|
||||
};
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ const generateMockPayload = (dashboard = true) => {
|
||||
force_screenshot: true,
|
||||
grace_period: 14400,
|
||||
id: 1,
|
||||
include_cta: true,
|
||||
last_eval_dttm: null,
|
||||
last_state: 'Not triggered',
|
||||
last_value: null,
|
||||
@@ -308,6 +309,9 @@ afterEach(() => {
|
||||
'put-dashboard-payload',
|
||||
'put-report-1',
|
||||
'put-no-recipients',
|
||||
'put-include-cta',
|
||||
'get-report-cta-false',
|
||||
'get-report-cta-absent',
|
||||
'tabs-99',
|
||||
]) {
|
||||
try {
|
||||
@@ -336,6 +340,7 @@ const validAlert: AlertObject = {
|
||||
dashboard_id: 0,
|
||||
chart_id: 1,
|
||||
force_screenshot: false,
|
||||
include_cta: true,
|
||||
last_state: 'Not triggered',
|
||||
name: 'Test Alert',
|
||||
editors: [mockEditorSubject],
|
||||
@@ -729,6 +734,90 @@ test('removes ignore cache checkbox when chart is selected', async () => {
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders include link checkbox checked by default in create mode', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(true)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
const checkbox = await screen.findByRole('checkbox', {
|
||||
name: /include a link back to superset/i,
|
||||
});
|
||||
expect(checkbox).toBeChecked();
|
||||
});
|
||||
|
||||
test('keeps include link checkbox when chart is selected', async () => {
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, true)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
const contentTypeSelector = screen.getByRole('combobox', {
|
||||
name: /select content type/i,
|
||||
});
|
||||
await comboboxSelect(
|
||||
contentTypeSelector,
|
||||
'Chart',
|
||||
() => screen.getAllByText(/select chart/i)[0],
|
||||
);
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /include a link back to superset/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hydrates include link checkbox from a resource with include_cta false', async () => {
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/report/8',
|
||||
{ result: { ...generateMockPayload(true), id: 8, include_cta: false } },
|
||||
{ name: 'get-report-cta-false' },
|
||||
);
|
||||
|
||||
render(
|
||||
<AlertReportModal
|
||||
{...generateMockedProps(false, true, true)}
|
||||
alert={{ ...validAlert, id: 8 }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /include a link back to superset/i,
|
||||
}),
|
||||
).not.toBeChecked();
|
||||
|
||||
fetchMock.removeRoute('get-report-cta-false');
|
||||
});
|
||||
|
||||
test('treats a resource without include_cta as checked', async () => {
|
||||
const { include_cta: _include_cta, ...payloadWithoutCta } =
|
||||
generateMockPayload(true);
|
||||
fetchMock.get(
|
||||
'glob:*/api/v1/report/9',
|
||||
{ result: { ...payloadWithoutCta, id: 9 } },
|
||||
{ name: 'get-report-cta-absent' },
|
||||
);
|
||||
|
||||
render(
|
||||
<AlertReportModal
|
||||
{...generateMockedProps(false, true, true)}
|
||||
alert={{ ...validAlert, id: 9 }}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
await screen.findByText(/test dashboard/i);
|
||||
expect(
|
||||
screen.getByRole('checkbox', {
|
||||
name: /include a link back to superset/i,
|
||||
}),
|
||||
).toBeChecked();
|
||||
|
||||
fetchMock.removeRoute('get-report-cta-absent');
|
||||
});
|
||||
|
||||
test('open chart button opens explore with slice_id', async () => {
|
||||
// Render with an existing alert that has a chart selected
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
|
||||
@@ -1550,6 +1639,58 @@ test('submit includes conditionNotNull without threshold in alert payload', asyn
|
||||
fetchMock.removeRoute('put-condition');
|
||||
}, 45000);
|
||||
|
||||
test('submit includes include_cta false after unchecking the checkbox', async () => {
|
||||
// Mock payload returns id:1, so updateResource PUTs to /api/v1/report/1
|
||||
fetchMock.put(
|
||||
'glob:*/api/v1/report/1',
|
||||
{ id: 1, result: {} },
|
||||
{ name: 'put-include-cta' },
|
||||
);
|
||||
|
||||
render(<AlertReportModal {...generateMockedProps(false, true, false)} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Wait for resource to load and all validation to pass
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(
|
||||
screen.queryAllByRole('img', { name: /check-circle/i }),
|
||||
).toHaveLength(5);
|
||||
},
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
// Open the contents panel and uncheck the include link checkbox
|
||||
userEvent.click(screen.getByTestId('contents-panel'));
|
||||
const checkbox = await screen.findByRole('checkbox', {
|
||||
name: /include a link back to superset/i,
|
||||
});
|
||||
expect(checkbox).toBeChecked();
|
||||
userEvent.click(checkbox);
|
||||
await waitFor(() => {
|
||||
expect(checkbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
// Wait for Save to be enabled and click
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
|
||||
});
|
||||
userEvent.click(screen.getByRole('button', { name: /save/i }));
|
||||
|
||||
// Verify the PUT payload
|
||||
await waitFor(() => {
|
||||
const calls = fetchMock.callHistory.calls('put-include-cta');
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const calls = fetchMock.callHistory.calls('put-include-cta');
|
||||
const body = JSON.parse(calls[calls.length - 1].options.body as string);
|
||||
expect(body.include_cta).toBe(false);
|
||||
|
||||
fetchMock.removeRoute('put-include-cta');
|
||||
}, 45000);
|
||||
|
||||
test('edit mode submit uses PUT and excludes read-only fields', async () => {
|
||||
// Mock payload returns id:1, so updateResource PUTs to /api/v1/report/1
|
||||
fetchMock.put(
|
||||
|
||||
@@ -721,6 +721,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
validator_config_json: {},
|
||||
validator_type: '',
|
||||
force_screenshot: false,
|
||||
include_cta: true,
|
||||
grace_period: undefined,
|
||||
retry_on_failure: false,
|
||||
retry_max_attempts: 3,
|
||||
@@ -963,6 +964,7 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
...currentAlert,
|
||||
type: isReport ? 'Report' : 'Alert',
|
||||
force_screenshot: shouldEnableForceScreenshot || forceScreenshot,
|
||||
include_cta: currentAlert?.include_cta ?? true,
|
||||
validator_type: conditionNotNull ? 'not null' : 'operator',
|
||||
validator_config_json: conditionNotNull
|
||||
? {}
|
||||
@@ -2611,6 +2613,22 @@ const AlertReportModal: FunctionComponent<AlertReportModalProps> = ({
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
<div className="inline-container">
|
||||
<Checkbox
|
||||
data-test="include-cta"
|
||||
checked={currentAlert?.include_cta !== false}
|
||||
onChange={(e: CheckboxChangeEvent) =>
|
||||
updateAlertState('include_cta', e.target.checked)
|
||||
}
|
||||
>
|
||||
{t('Include a link back to Superset')}
|
||||
</Checkbox>
|
||||
<InfoTooltip
|
||||
tooltip={t(
|
||||
'When unchecked, the "Explore in Superset" link is omitted from the delivered notifications.',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -135,6 +135,7 @@ export type AlertObject = {
|
||||
force_screenshot: boolean;
|
||||
grace_period?: number;
|
||||
id: number;
|
||||
include_cta?: boolean;
|
||||
last_eval_dttm?: number;
|
||||
last_state?:
|
||||
| 'Success'
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
MarshmallowErrorMessage,
|
||||
ParameterErrorMessage,
|
||||
DatasetNotFoundErrorMessage,
|
||||
DatasourceSecurityAccessErrorMessage,
|
||||
InvalidSQLErrorMessage,
|
||||
OAuth2RedirectMessage,
|
||||
FrontendNetworkErrorMessage,
|
||||
@@ -94,6 +95,14 @@ export default function setupErrorMessages() {
|
||||
ErrorTypeEnum.QUERY_SECURITY_ACCESS_ERROR,
|
||||
DatabaseErrorMessage,
|
||||
);
|
||||
errorMessageComponentRegistry.registerValue(
|
||||
ErrorTypeEnum.DATASOURCE_SECURITY_ACCESS_ERROR,
|
||||
DatasourceSecurityAccessErrorMessage,
|
||||
);
|
||||
errorMessageComponentRegistry.registerValue(
|
||||
ErrorTypeEnum.TABLE_SECURITY_ACCESS_ERROR,
|
||||
DatasourceSecurityAccessErrorMessage,
|
||||
);
|
||||
errorMessageComponentRegistry.registerValue(
|
||||
ErrorTypeEnum.CONNECTION_INVALID_HOSTNAME_ERROR,
|
||||
DatabaseErrorMessage,
|
||||
|
||||
@@ -22,6 +22,15 @@ import {
|
||||
getAllValidTokenNames,
|
||||
} from './antdTokenNames';
|
||||
|
||||
// Simulate a deployment that registered a custom token via EXTRA_THEME_TOKENS
|
||||
// (shipped in the common bootstrap payload).
|
||||
jest.mock('src/utils/getBootstrapData', () => ({
|
||||
__esModule: true,
|
||||
default: () => ({
|
||||
common: { extra_theme_tokens: ['customDeploymentToken'] },
|
||||
}),
|
||||
}));
|
||||
|
||||
test('isValidTokenName recognizes standard Ant Design tokens', () => {
|
||||
expect(isValidTokenName('colorPrimary')).toBe(true);
|
||||
expect(isValidTokenName('fontSize')).toBe(true);
|
||||
@@ -141,6 +150,20 @@ test('dashboard tile tokens are recognized as valid Superset custom tokens', ()
|
||||
});
|
||||
});
|
||||
|
||||
test('isValidTokenName recognizes deployment-registered EXTRA_THEME_TOKENS', () => {
|
||||
expect(isValidTokenName('customDeploymentToken')).toBe(true);
|
||||
});
|
||||
|
||||
test('isSupersetCustomToken treats EXTRA_THEME_TOKENS as custom, not Ant Design', () => {
|
||||
expect(isSupersetCustomToken('customDeploymentToken')).toBe(true);
|
||||
});
|
||||
|
||||
test('getAllValidTokenNames lists EXTRA_THEME_TOKENS under supersetTokens', () => {
|
||||
const result = getAllValidTokenNames();
|
||||
expect(result.supersetTokens).toContain('customDeploymentToken');
|
||||
expect(result.antdTokens).not.toContain('customDeploymentToken');
|
||||
});
|
||||
|
||||
test('label variant tokens are recognized as valid Superset custom tokens', () => {
|
||||
const labelTokens = [
|
||||
// Published/Draft
|
||||
|
||||
@@ -17,6 +17,25 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { theme } from 'antd';
|
||||
import getBootstrapData from 'src/utils/getBootstrapData';
|
||||
|
||||
/**
|
||||
* Custom token names registered by the deployment via the EXTRA_THEME_TOKENS
|
||||
* config (shipped in the common bootstrap payload). Lets deployments and plugins
|
||||
* validate their own theme tokens without forking this list.
|
||||
*
|
||||
* Snapshotted once so the cached valid-token set (isValidTokenName) and the live
|
||||
* reads (isSupersetCustomToken, getAllValidTokenNames) all derive from the same
|
||||
* list, rather than depending on getBootstrapData's memoization to stay in sync.
|
||||
*/
|
||||
let extraThemeTokensCache: readonly string[] | undefined;
|
||||
function getExtraThemeTokens(): readonly string[] {
|
||||
if (extraThemeTokensCache === undefined) {
|
||||
extraThemeTokensCache =
|
||||
getBootstrapData()?.common?.extra_theme_tokens ?? [];
|
||||
}
|
||||
return extraThemeTokensCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* Superset-specific custom tokens that extend Ant Design's token system.
|
||||
@@ -125,10 +144,11 @@ function getValidTokenNames(): Set<string> {
|
||||
const antdTokens = theme.getDesignToken();
|
||||
const antdTokenNames = Object.keys(antdTokens);
|
||||
|
||||
// Combine with Superset custom tokens
|
||||
// Combine with Superset custom tokens + deployment-registered extras
|
||||
validTokenNamesCache = new Set([
|
||||
...antdTokenNames,
|
||||
...SUPERSET_CUSTOM_TOKENS,
|
||||
...getExtraThemeTokens(),
|
||||
]);
|
||||
}
|
||||
return validTokenNamesCache;
|
||||
@@ -149,7 +169,10 @@ export function isValidTokenName(tokenName: string): boolean {
|
||||
* @returns true if it's a Superset-specific token
|
||||
*/
|
||||
export function isSupersetCustomToken(tokenName: string): boolean {
|
||||
return SUPERSET_CUSTOM_TOKENS.has(tokenName);
|
||||
return (
|
||||
SUPERSET_CUSTOM_TOKENS.has(tokenName) ||
|
||||
getExtraThemeTokens().includes(tokenName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,7 +188,9 @@ export function getAllValidTokenNames(): {
|
||||
const antdTokens = Array.from(allTokens).filter(
|
||||
t => !isSupersetCustomToken(t),
|
||||
);
|
||||
const supersetTokens: string[] = Array.from(SUPERSET_CUSTOM_TOKENS);
|
||||
const supersetTokens: string[] = Array.from(
|
||||
new Set([...SUPERSET_CUSTOM_TOKENS, ...getExtraThemeTokens()]),
|
||||
);
|
||||
|
||||
return {
|
||||
antdTokens,
|
||||
|
||||
@@ -168,6 +168,7 @@ export interface CommonBootstrapData {
|
||||
language_pack: LanguagePack;
|
||||
extra_categorical_color_schemes: ColorSchemeConfig[];
|
||||
extra_sequential_color_schemes: SequentialSchemeConfig[];
|
||||
extra_theme_tokens: string[];
|
||||
theme: BootstrapThemeDataConfig;
|
||||
menu_data: MenuData;
|
||||
d3_format: Partial<FormatLocaleDefinition>;
|
||||
|
||||
@@ -43,6 +43,7 @@ from superset.constants import SKIP_VISIBILITY_FILTER_CLASSES
|
||||
from superset.daos.dataset import DatasetDAO
|
||||
from superset.exceptions import SupersetSecurityException
|
||||
from superset.models.core import Database
|
||||
from superset.models.helpers import ChildMultipleResultsFound
|
||||
from superset.sql.parse import Table
|
||||
from superset.utils import json
|
||||
from superset.utils.core import get_user
|
||||
@@ -442,6 +443,22 @@ def import_dataset( # noqa: C901
|
||||
# import recursively to include columns and metrics
|
||||
try:
|
||||
dataset = SqlaTable.import_from_dict(config, recursive=True, sync=sync)
|
||||
except ChildMultipleResultsFound as ex:
|
||||
# An ambiguous *child* (metric/column) lookup. Unlike the dataset-level
|
||||
# case handled below, this is raised after the dataset's own fields were
|
||||
# updated and after earlier siblings were already imported, so there is
|
||||
# no unmodified row to fall back to — silently returning one would
|
||||
# report success over a half-applied import (parent scalars written,
|
||||
# children not synced, the ``sync`` deletion never run). Raise instead
|
||||
# so the command's transaction wrapper rolls the whole thing back.
|
||||
raise ImportFailedError(
|
||||
f"Dataset {config['table_name']!r} (uuid {config['uuid']}) "
|
||||
"could not be imported because one of its metrics or columns "
|
||||
"matches two different existing ones — one by name and another by "
|
||||
"UUID. The import was aborted so it is not applied partially. "
|
||||
"Rename or delete the conflicting metric/column in the target "
|
||||
"instance, or remove the UUID from the uploaded file, and retry."
|
||||
) from ex
|
||||
except MultipleResultsFound as ex:
|
||||
# Finding multiple results when importing a dataset only happens because initially # noqa: E501
|
||||
# datasets were imported without schemas (eg, `examples.NULL.users`), and later
|
||||
|
||||
@@ -14,16 +14,18 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""Write-ahead purge audit record.
|
||||
"""Write-ahead purge audit records and retained purge outcomes.
|
||||
|
||||
Every purge — time-based or force — writes an immutable record that
|
||||
**survives** the entity it names, on a **dedicated session** outside the
|
||||
Every purge evaluation — scheduled or force — writes a provisional record on
|
||||
a **dedicated session** outside the
|
||||
purge transaction so it neither entangles with the ``DBEventLogger``
|
||||
(which shares ``db.session`` and commits mid-request) nor vanishes if the
|
||||
purge rolls back. The record is written ``pending`` *before* the purge and
|
||||
flipped to ``confirmed`` *after* it commits, so a crash leaves at most a
|
||||
``pending`` row, never a missing one. ``pending`` rows are reconciled on the
|
||||
next run (the purge is convergent).
|
||||
next run. Completed records are immutable. Consecutive scheduled evaluations
|
||||
that remain blocked may discard only their current redundant provisional row;
|
||||
force-purge and other meaningful outcomes are retained independently.
|
||||
|
||||
The dedicated ``purge_audit_log`` table is content-free (no name or PII; only
|
||||
action, actor, UTC time, entity type, UUID, and affected referrers) and is never
|
||||
@@ -39,11 +41,13 @@ removed by the purge cascade.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, cast
|
||||
from typing import Any, cast, Literal, TypeAlias
|
||||
from uuid import UUID
|
||||
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from superset import db
|
||||
@@ -73,6 +77,17 @@ TRIGGER_FORCE = "force"
|
||||
|
||||
ACTOR_SYSTEM = "system"
|
||||
|
||||
RetentionBlockedDisposition: TypeAlias = Literal["retained", "suppressed", "fallback"]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _AuditRecoverySnapshot:
|
||||
id: UUID
|
||||
actor: str
|
||||
entity_type: str
|
||||
entity_uuid: str | None
|
||||
created_on: datetime
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
"""Naive UTC now for the audit columns.
|
||||
@@ -182,6 +197,163 @@ def block(record_id: UUID | None) -> None:
|
||||
finalize(record_id, STATUS_BLOCKED)
|
||||
|
||||
|
||||
def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
|
||||
"""Capture the content-free fields needed for fail-safe recovery."""
|
||||
return _AuditRecoverySnapshot(
|
||||
id=cast(UUID, record.id),
|
||||
actor=str(record.actor),
|
||||
entity_type=str(record.entity_type),
|
||||
entity_uuid=record.entity_uuid,
|
||||
created_on=cast(datetime, record.created_on),
|
||||
)
|
||||
|
||||
|
||||
def _retention_predecessor(
|
||||
session: Session, current: PurgeAuditLog
|
||||
) -> PurgeAuditLog | None:
|
||||
"""Return the latest row that could unambiguously precede ``current``."""
|
||||
predecessor: PurgeAuditLog | None = session.execute(
|
||||
sa.select(PurgeAuditLog)
|
||||
.where(PurgeAuditLog.entity_uuid == current.entity_uuid)
|
||||
.where(PurgeAuditLog.entity_type == current.entity_type)
|
||||
.where(PurgeAuditLog.trigger == TRIGGER_RETENTION)
|
||||
.where(PurgeAuditLog.created_on <= current.created_on)
|
||||
.where(PurgeAuditLog.id != current.id)
|
||||
.order_by(PurgeAuditLog.created_on.desc())
|
||||
.limit(1)
|
||||
).scalar_one_or_none()
|
||||
if predecessor is None:
|
||||
return predecessor
|
||||
tied_mixed_status_exists: bool = session.execute(
|
||||
sa.select(
|
||||
sa.exists().where(
|
||||
PurgeAuditLog.entity_uuid == current.entity_uuid,
|
||||
PurgeAuditLog.entity_type == current.entity_type,
|
||||
PurgeAuditLog.trigger == TRIGGER_RETENTION,
|
||||
PurgeAuditLog.created_on == predecessor.created_on,
|
||||
PurgeAuditLog.id != current.id,
|
||||
PurgeAuditLog.status != predecessor.status,
|
||||
)
|
||||
)
|
||||
).scalar_one()
|
||||
if tied_mixed_status_exists:
|
||||
return None
|
||||
return predecessor
|
||||
|
||||
|
||||
def _suppress_redundant_block(
|
||||
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
|
||||
) -> bool:
|
||||
"""Delete only a pending row with a strictly older blocked predecessor."""
|
||||
if (
|
||||
predecessor is None
|
||||
or predecessor.created_on >= current.created_on
|
||||
or predecessor.status != STATUS_BLOCKED
|
||||
):
|
||||
return False
|
||||
deleted_rows: int | None = session.execute(
|
||||
sa.delete(PurgeAuditLog.__table__).where(
|
||||
PurgeAuditLog.__table__.c.id == current.id,
|
||||
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
|
||||
)
|
||||
).rowcount
|
||||
if deleted_rows not in (0, 1):
|
||||
raise SQLAlchemyError(
|
||||
f"indeterminate purge audit suppression rowcount: {deleted_rows}"
|
||||
)
|
||||
return deleted_rows == 1
|
||||
|
||||
|
||||
def _retain_blocked(session: Session, record_id: UUID) -> None:
|
||||
"""Conditionally retain the current provisional row as blocked."""
|
||||
session.execute(
|
||||
sa.update(PurgeAuditLog.__table__)
|
||||
.where(
|
||||
PurgeAuditLog.__table__.c.id == record_id,
|
||||
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
|
||||
)
|
||||
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
|
||||
)
|
||||
|
||||
|
||||
def _recover_retention_blocked(
|
||||
record_id: UUID, snapshot: _AuditRecoverySnapshot | None
|
||||
) -> RetentionBlockedDisposition:
|
||||
"""Retain blocked evidence on a fresh session after persistence uncertainty."""
|
||||
recovery_session: Session = _dedicated_session()
|
||||
try:
|
||||
current: PurgeAuditLog | None = recovery_session.get(PurgeAuditLog, record_id)
|
||||
if current is not None:
|
||||
if current.status == STATUS_PENDING:
|
||||
_retain_blocked(recovery_session, record_id)
|
||||
recovery_session.commit()
|
||||
return "fallback"
|
||||
if snapshot is None:
|
||||
return "fallback"
|
||||
recovery_session.add(
|
||||
PurgeAuditLog(
|
||||
id=snapshot.id,
|
||||
status=STATUS_BLOCKED,
|
||||
trigger=TRIGGER_RETENTION,
|
||||
actor=snapshot.actor,
|
||||
entity_type=snapshot.entity_type,
|
||||
entity_uuid=snapshot.entity_uuid,
|
||||
removed_dashboard_slices=0,
|
||||
created_on=snapshot.created_on,
|
||||
)
|
||||
)
|
||||
recovery_session.commit()
|
||||
except SQLAlchemyError:
|
||||
recovery_session.rollback()
|
||||
logger.warning(
|
||||
"deletion_retention: failed to recover blocked audit row %s "
|
||||
"entity_type=%s entity_uuid=%s",
|
||||
record_id,
|
||||
snapshot.entity_type if snapshot else None,
|
||||
snapshot.entity_uuid if snapshot else None,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
recovery_session.close()
|
||||
return "fallback"
|
||||
|
||||
|
||||
def finalize_retention_blocked(
|
||||
record_id: UUID | None,
|
||||
) -> RetentionBlockedDisposition:
|
||||
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
|
||||
if record_id is None:
|
||||
return "fallback"
|
||||
session: Session = _dedicated_session()
|
||||
snapshot: _AuditRecoverySnapshot | None = None
|
||||
try:
|
||||
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
|
||||
if current is None:
|
||||
return "fallback"
|
||||
snapshot = _capture_recovery_snapshot(current)
|
||||
if current.status != STATUS_PENDING or current.trigger != TRIGGER_RETENTION:
|
||||
return "retained"
|
||||
predecessor: PurgeAuditLog | None = None
|
||||
if current.entity_uuid is not None:
|
||||
predecessor = _retention_predecessor(session, current)
|
||||
suppressed: bool = _suppress_redundant_block(session, current, predecessor)
|
||||
if not suppressed:
|
||||
_retain_blocked(session, record_id)
|
||||
session.commit()
|
||||
return "suppressed" if suppressed else "retained"
|
||||
except SQLAlchemyError:
|
||||
session.rollback()
|
||||
logger.warning(
|
||||
"deletion_retention: persistence uncertainty finalizing blocked "
|
||||
"audit row %s",
|
||||
record_id,
|
||||
exc_info=True,
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
return _recover_retention_blocked(record_id, snapshot)
|
||||
|
||||
|
||||
def _entity_exists(session: Session, record: PurgeAuditLog) -> bool | None:
|
||||
"""Return whether the audit target exists, or None if it cannot resolve."""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
|
||||
@@ -1243,6 +1243,8 @@ class BaseReportState:
|
||||
error_text = None
|
||||
header_data = self._get_log_data()
|
||||
url = self._get_url(user_friendly=True)
|
||||
# NULL (rows predating the include_cta column) is treated as True
|
||||
include_cta = self._report_schedule.include_cta is not False
|
||||
|
||||
if (
|
||||
feature_flag_manager.is_feature_enabled("ALERTS_ATTACH_REPORTS")
|
||||
@@ -1278,6 +1280,7 @@ class BaseReportState:
|
||||
text=error_text,
|
||||
header_data=header_data,
|
||||
url=url,
|
||||
include_cta=include_cta,
|
||||
)
|
||||
|
||||
if (
|
||||
@@ -1310,6 +1313,7 @@ class BaseReportState:
|
||||
xlsx=xlsx_data,
|
||||
embedded_data=embedded_data,
|
||||
header_data=header_data,
|
||||
include_cta=include_cta,
|
||||
)
|
||||
|
||||
def _send(
|
||||
@@ -1414,7 +1418,12 @@ class BaseReportState:
|
||||
self._execution_id,
|
||||
)
|
||||
notification_content = NotificationContent(
|
||||
name=sanitize_title(name), text=message, header_data=header_data, url=url
|
||||
name=sanitize_title(name),
|
||||
text=message,
|
||||
header_data=header_data,
|
||||
url=url,
|
||||
# NULL (rows predating the include_cta column) is treated as True
|
||||
include_cta=self._report_schedule.include_cta is not False,
|
||||
)
|
||||
|
||||
# filter recipients to recipients who are also editors
|
||||
|
||||
+32
-15
@@ -551,6 +551,7 @@ LANGUAGES = {
|
||||
"fi": {"flag": "fi", "name": "Finnish"},
|
||||
"th": {"flag": "th", "name": "Thai"},
|
||||
"tr": {"flag": "tr", "name": "Turkish"},
|
||||
"ta": {"flag": "in", "name": "Tamil"},
|
||||
}
|
||||
# Turning off i18n by default as translation in most languages are
|
||||
# incomplete and not well maintained.
|
||||
@@ -723,9 +724,10 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
|
||||
"TAGGING_SYSTEM": False,
|
||||
# Enables the version history panel on Explore and Dashboard pages.
|
||||
# History only accrues while ``ENABLE_VERSIONING_CAPTURE`` is also on;
|
||||
# with capture off the panel renders but stays empty.
|
||||
# with capture off the panel renders but stays empty, so the two ship
|
||||
# with matching defaults and should be changed together.
|
||||
# @lifecycle: development
|
||||
"VERSION_HISTORY": False,
|
||||
"VERSION_HISTORY": True,
|
||||
# =================================================================
|
||||
# IN TESTING
|
||||
# =================================================================
|
||||
@@ -1062,6 +1064,12 @@ COMMON_BOOTSTRAP_OVERRIDES_FUNC: Callable[ # noqa: E731
|
||||
# This is merely a default
|
||||
EXTRA_CATEGORICAL_COLOR_SCHEMES: list[dict[str, Any]] = []
|
||||
|
||||
# EXTRA_THEME_TOKENS lets a deployment register additional custom theme token
|
||||
# names so they validate cleanly in the theme editor instead of being flagged as
|
||||
# unknown Ant Design tokens. Deployments and plugins that consume their own
|
||||
# tokens via useTheme add them here. This is merely a default.
|
||||
EXTRA_THEME_TOKENS: list[str] = []
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Theme System Configuration
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -1668,19 +1676,19 @@ DATETIME_FORMAT_DETECTION_SAMPLE_SIZE = 1000
|
||||
# The limit for the Superset Meta DB when the feature flag ENABLE_SUPERSET_META_DB is on
|
||||
SUPERSET_META_DB_LIMIT: int | None = 1000
|
||||
|
||||
# Master switch for entity-version-history capture. Ships defaulted ``False``
|
||||
# so the versioning infrastructure (schema + Continuum wiring) lands inert:
|
||||
# no save writes shadow rows or a ``version_transaction``/``version_changes``
|
||||
# record, while the /versions/ endpoints stay available read-only (returning
|
||||
# empty). Set to ``True`` in ``superset_config.py`` (or via the env var of the
|
||||
# same name) to enable the before-flush listeners that drive capture.
|
||||
# Capture is activated by flipping this default to on once validated in
|
||||
# production. It is an operational escape hatch — for use when a
|
||||
# versioning-induced regression needs a 30-second recovery instead of
|
||||
# revert-and-redeploy — not a feature flag, and remains as the permanent
|
||||
# kill-switch.
|
||||
# Master switch for entity-version-history capture. Capture is enabled by
|
||||
# default, so saves write shadow rows and a ``version_transaction`` /
|
||||
# ``version_changes`` record. Set this to a falsy value in
|
||||
# ``superset_config.py`` (or via the environment variable of the same name) to
|
||||
# disable the before-flush listeners while keeping the /versions/ endpoints
|
||||
# available read-only.
|
||||
# Capture ships on. It is an operational escape hatch — set the environment
|
||||
# variable to a falsy value when a versioning-induced regression needs a
|
||||
# 30-second recovery instead of revert-and-redeploy — not a feature flag,
|
||||
# and it remains permanently as the kill-switch rather than being removed
|
||||
# with the rollout toggles.
|
||||
ENABLE_VERSIONING_CAPTURE: bool = utils.parse_boolean_string(
|
||||
os.environ.get("ENABLE_VERSIONING_CAPTURE", "false")
|
||||
os.environ.get("ENABLE_VERSIONING_CAPTURE", "true")
|
||||
)
|
||||
|
||||
# Retention window (days) for entity version history. Version rows
|
||||
@@ -2094,7 +2102,16 @@ TROUBLESHOOTING_LINK = ""
|
||||
WTF_CSRF_TIME_LIMIT = int(timedelta(weeks=1).total_seconds())
|
||||
|
||||
# This link should lead to a page with instructions on how to gain access to a
|
||||
# Datasource. It will be placed at the bottom of permissions errors.
|
||||
# Datasource. It is surfaced as a "Request Access" link on data-permission
|
||||
# errors (e.g. when a viewer opens a chart whose dataset they cannot access).
|
||||
# The URL may include any of these placeholders, which are substituted with
|
||||
# URL-encoded values so the link can deep-link into an access-request system:
|
||||
# {datasource_id} - id of the denied dataset (datasource errors)
|
||||
# {datasource_name} - name of the denied dataset (datasource errors)
|
||||
# {table_names} - comma-separated denied table names (table/SQL errors)
|
||||
# {username} - the requesting user's username
|
||||
# A URL with no placeholders is used as-is. Example:
|
||||
# "https://access.example.com/request?dataset={datasource_id}&user={username}"
|
||||
PERMISSION_INSTRUCTIONS_LINK = ""
|
||||
|
||||
# Integrate external Blueprints to the app by passing them to your
|
||||
|
||||
@@ -70,21 +70,18 @@ from sqlalchemy.types import JSON
|
||||
from superset_core.common.models import Dataset as CoreDataset
|
||||
|
||||
from superset import db, is_feature_enabled, security_manager
|
||||
from superset.commands.dataset.exceptions import DatasetNotFoundError
|
||||
from superset.common.db_query_status import QueryStatus
|
||||
from superset.connectors.sqla.utils import (
|
||||
get_columns_description,
|
||||
get_physical_table_metadata,
|
||||
get_virtual_table_metadata,
|
||||
)
|
||||
from superset.daos.exceptions import DatasourceNotFound
|
||||
from superset.db_engine_specs.base import BaseEngineSpec, TimestampExpression
|
||||
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
|
||||
from superset.exceptions import (
|
||||
ColumnNotFoundException,
|
||||
DatasetInvalidPermissionEvaluationException,
|
||||
QueryObjectValidationError,
|
||||
SupersetGenericDBErrorException,
|
||||
SupersetParseError,
|
||||
SupersetSecurityException,
|
||||
SupersetSyntaxErrorException,
|
||||
@@ -500,6 +497,107 @@ class BaseDatasource(
|
||||
"supports_drill_to_detail": self.supports_drill_to_detail,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _extract_query_columns(query: dict[str, Any]) -> set[str] | None:
|
||||
"""Extract metadata dependencies from one serialized query object."""
|
||||
column_names: set[str] = set()
|
||||
# This only collects metadata dependencies, so retaining both fields is
|
||||
# safer than reproducing conflicting schema/factory precedence rules.
|
||||
for query_field in ("columns", "groupby"):
|
||||
columns = query.get(query_field)
|
||||
if not isinstance(columns, list):
|
||||
continue
|
||||
try:
|
||||
column_names.update(
|
||||
utils.get_column_name(column_) for column_ in columns
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
# QueryContextFactory may replace a temporal x-axis with granularity.
|
||||
# Keeping both names is conservative and avoids dropping its metadata.
|
||||
for query_field in ("granularity", "granularity_sqla"):
|
||||
if isinstance(granularity := query.get(query_field), str):
|
||||
column_names.add(granularity)
|
||||
|
||||
return column_names
|
||||
|
||||
@staticmethod
|
||||
def _extract_form_data_columns(
|
||||
form_data: Any,
|
||||
currency_code_column: str | None,
|
||||
has_query_columns: bool,
|
||||
) -> set[str]:
|
||||
"""Extract columns that QueryContextFactory adds using form data."""
|
||||
if not isinstance(form_data, dict):
|
||||
return set()
|
||||
|
||||
column_names: set[str] = set()
|
||||
tooltip_contents = form_data.get("tooltip_contents")
|
||||
if isinstance(tooltip_contents, list):
|
||||
for item in tooltip_contents:
|
||||
if isinstance(item, str):
|
||||
column_names.add(item)
|
||||
elif (
|
||||
isinstance(item, dict)
|
||||
and item.get("item_type") == "column"
|
||||
and isinstance(column_name := item.get("column_name"), str)
|
||||
):
|
||||
column_names.add(column_name)
|
||||
|
||||
currency_format = form_data.get("currency_format")
|
||||
if (
|
||||
(has_query_columns or column_names)
|
||||
and currency_code_column
|
||||
and form_data.get("viz_type") == "pivot_table_v2"
|
||||
and isinstance(currency_format, dict)
|
||||
and currency_format.get("symbol") == "AUTO"
|
||||
):
|
||||
column_names.add(currency_code_column)
|
||||
|
||||
return column_names
|
||||
|
||||
@classmethod
|
||||
def _extract_query_context_columns(
|
||||
cls,
|
||||
query_context: str | None,
|
||||
currency_code_column: str | None = None,
|
||||
) -> set[str] | None:
|
||||
"""Extract column names from a serialized query context."""
|
||||
if not query_context:
|
||||
return None
|
||||
|
||||
try:
|
||||
payload = json.loads(query_context)
|
||||
except json.JSONDecodeError:
|
||||
logger.exception("Malformed JSON in slice's query context")
|
||||
return None
|
||||
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
queries = payload.get("queries")
|
||||
if not isinstance(queries, list):
|
||||
return None
|
||||
|
||||
column_names: set[str] = set()
|
||||
for query in queries:
|
||||
if not isinstance(query, dict):
|
||||
continue
|
||||
if (query_columns := cls._extract_query_columns(query)) is None:
|
||||
return None
|
||||
column_names.update(query_columns)
|
||||
|
||||
column_names.update(
|
||||
cls._extract_form_data_columns(
|
||||
payload.get("form_data"),
|
||||
currency_code_column,
|
||||
bool(column_names),
|
||||
)
|
||||
)
|
||||
|
||||
return column_names or None
|
||||
|
||||
def data_for_slices( # pylint: disable=too-many-locals # noqa: C901
|
||||
self, slices: list[Slice]
|
||||
) -> dict[str, Any]:
|
||||
@@ -538,28 +636,12 @@ class BaseDatasource(
|
||||
if "column" in filter_config
|
||||
)
|
||||
|
||||
# for legacy dashboard imports which have the wrong query_context in them
|
||||
try:
|
||||
query_context = slc.get_query_context()
|
||||
except (DatasetNotFoundError, DatasourceNotFound):
|
||||
logger.warning(
|
||||
"Failed to load query_context for chart '%s' (id=%s): "
|
||||
"referenced datasource not found",
|
||||
slc.slice_name,
|
||||
slc.id,
|
||||
)
|
||||
query_context = None
|
||||
|
||||
# legacy charts don't have query_context charts
|
||||
if query_context:
|
||||
column_names.update(
|
||||
[
|
||||
utils.get_column_name(column_)
|
||||
for query in query_context.queries
|
||||
for column_ in query.columns
|
||||
]
|
||||
or []
|
||||
)
|
||||
query_context_column_names = self._extract_query_context_columns(
|
||||
slc.query_context,
|
||||
currency_code_column=getattr(self, "currency_code_column", None),
|
||||
)
|
||||
if query_context_column_names is not None:
|
||||
column_names.update(query_context_column_names)
|
||||
else:
|
||||
_columns = [
|
||||
(
|
||||
@@ -1858,42 +1940,43 @@ class SqlaTable(
|
||||
|
||||
sqla_column = literal_column(expression)
|
||||
if has_timegrain or force_type_check:
|
||||
try:
|
||||
# probe adhoc column type
|
||||
# Most databases populate cursor.description from query-plan
|
||||
# metadata, so WHERE FALSE (zero rows, no table scan) is
|
||||
# preferred — it avoids hitting row-read limits enforced by
|
||||
# engines like ClickHouse (max_rows_to_read).
|
||||
# A small number of drivers (Druid, Pinot) instead build
|
||||
# cursor.description by inspecting the first returned row;
|
||||
# for those we fall back to LIMIT 1.
|
||||
tbl, _unused_cte = self.get_from_clause(template_processor)
|
||||
if self.db_engine_spec.type_probe_needs_row:
|
||||
qry = sa.select(sqla_column).limit(1).select_from(tbl)
|
||||
else:
|
||||
qry = sa.select(sqla_column).where(sa.false()).select_from(tbl)
|
||||
sql = self.database.compile_sqla_query(
|
||||
qry,
|
||||
catalog=self.catalog,
|
||||
schema=self.schema,
|
||||
)
|
||||
col_desc = get_columns_description(
|
||||
self.database,
|
||||
self.catalog,
|
||||
self.schema or None,
|
||||
sql,
|
||||
)
|
||||
if not col_desc:
|
||||
raise SupersetGenericDBErrorException("Column not found")
|
||||
is_dttm = col_desc[0]["is_dttm"] # type: ignore
|
||||
# ResultSet already resolves the generic type from the
|
||||
# driver's cursor.description; reuse it so callers can
|
||||
# coerce filter values correctly (e.g. numeric IN-lists
|
||||
# stay unquoted for numeric adhoc expressions like
|
||||
# CAST(... AS BIGINT)).
|
||||
generic_type = col_desc[0].get("type_generic")
|
||||
except SupersetGenericDBErrorException as ex:
|
||||
raise ColumnNotFoundException(message=str(ex)) from ex
|
||||
# probe adhoc column type
|
||||
# Most databases populate cursor.description from query-plan
|
||||
# metadata, so WHERE FALSE (zero rows, no table scan) is
|
||||
# preferred — it avoids hitting row-read limits enforced by
|
||||
# engines like ClickHouse (max_rows_to_read).
|
||||
# A small number of drivers (Druid, Pinot) instead build
|
||||
# cursor.description by inspecting the first returned row;
|
||||
# for those we fall back to LIMIT 1.
|
||||
tbl, _unused_cte = self.get_from_clause(template_processor)
|
||||
if self.db_engine_spec.type_probe_needs_row:
|
||||
qry = sa.select(sqla_column).limit(1).select_from(tbl)
|
||||
else:
|
||||
qry = sa.select(sqla_column).where(sa.false()).select_from(tbl)
|
||||
sql = self.database.compile_sqla_query(
|
||||
qry,
|
||||
catalog=self.catalog,
|
||||
schema=self.schema,
|
||||
)
|
||||
# A real DB/connectivity failure during the probe surfaces as a
|
||||
# SupersetGenericDBErrorException from get_columns_description and
|
||||
# is allowed to propagate unchanged; only a genuine empty result
|
||||
# (the column truly isn't there) is a ColumnNotFoundException.
|
||||
col_desc = get_columns_description(
|
||||
self.database,
|
||||
self.catalog,
|
||||
self.schema or None,
|
||||
sql,
|
||||
)
|
||||
if not col_desc:
|
||||
raise ColumnNotFoundException(message="Column not found")
|
||||
is_dttm = col_desc[0]["is_dttm"] # type: ignore
|
||||
# ResultSet already resolves the generic type from the
|
||||
# driver's cursor.description; reuse it so callers can
|
||||
# coerce filter values correctly (e.g. numeric IN-lists
|
||||
# stay unquoted for numeric adhoc expressions like
|
||||
# CAST(... AS BIGINT)).
|
||||
generic_type = col_desc[0].get("type_generic")
|
||||
|
||||
if is_dttm and has_timegrain:
|
||||
sqla_column = self.db_engine_spec.get_timestamp_expr(
|
||||
@@ -2199,6 +2282,11 @@ class SqlaTable(
|
||||
templatable_statements += [
|
||||
f.clause for f in security_manager.get_rls_filters(self)
|
||||
]
|
||||
if is_feature_enabled("EMBEDDED_SUPERSET"):
|
||||
# Guest-token RLS clauses are templated when the query is built, so
|
||||
# macros they contain (e.g. get_guest_user_attribute) must also
|
||||
# trigger extra cache key extraction.
|
||||
templatable_statements += security_manager.get_guest_rls_filters_str(self)
|
||||
for statement in templatable_statements:
|
||||
if ExtraCache.regex.search(statement):
|
||||
return True
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
# under the License.
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from dateutil.parser import isoparse
|
||||
from flask_babel import lazy_gettext as _
|
||||
@@ -284,6 +285,7 @@ class ImportV1ColumnSchema(Schema):
|
||||
description = fields.String(allow_none=True)
|
||||
python_date_format = fields.String(allow_none=True)
|
||||
datetime_format = fields.String(allow_none=True)
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
|
||||
|
||||
class ImportMetricCurrencySchema(Schema):
|
||||
@@ -326,6 +328,7 @@ class ImportV1MetricSchema(Schema):
|
||||
currency = CurrencyField(ImportMetricCurrencySchema, allow_none=True)
|
||||
extra = fields.Dict(allow_none=True)
|
||||
warning_text = fields.String(allow_none=True)
|
||||
uuid = fields.UUID(allow_none=True)
|
||||
|
||||
|
||||
class ImportV1DatasetSchema(Schema):
|
||||
@@ -348,6 +351,35 @@ class ImportV1DatasetSchema(Schema):
|
||||
|
||||
return data
|
||||
|
||||
@validates_schema
|
||||
def validate_unique_child_uuids(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""
|
||||
Reject a payload where two metrics (or two columns) share a UUID.
|
||||
|
||||
UUIDs are globally unique in the database, so such a payload cannot be
|
||||
imported faithfully: the importer matches children within their parent
|
||||
by name *or* UUID, so the second entry would match the first one and
|
||||
overwrite it in place, silently collapsing two metrics/columns into one.
|
||||
Only a hand-edited bundle can produce this — an export never does.
|
||||
"""
|
||||
for key, singular in (("metrics", "metric"), ("columns", "column")):
|
||||
seen: set[UUID] = set()
|
||||
duplicates: set[UUID] = set()
|
||||
for child in data.get(key) or []:
|
||||
child_uuid = child.get("uuid")
|
||||
if child_uuid is None:
|
||||
continue
|
||||
if child_uuid in seen:
|
||||
duplicates.add(child_uuid)
|
||||
seen.add(child_uuid)
|
||||
if duplicates:
|
||||
raise ValidationError(
|
||||
f"Duplicate UUIDs found in {key}: "
|
||||
f"{', '.join(sorted(str(dup) for dup in duplicates))}. "
|
||||
f"Each {singular} must have a unique `uuid`.",
|
||||
field_name=key,
|
||||
)
|
||||
|
||||
table_name = fields.String(required=True)
|
||||
main_dttm_col = fields.String(allow_none=True)
|
||||
currency_code_column = fields.String(allow_none=True)
|
||||
|
||||
+138
-17
@@ -61,6 +61,7 @@ if TYPE_CHECKING:
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
from superset.models.sql_lab import Query
|
||||
from superset.security.guest_token import GuestToken
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -88,6 +89,11 @@ ALLOWED_TYPES = (
|
||||
)
|
||||
COLLECTION_TYPES = ("list", "dict", "tuple", "set")
|
||||
|
||||
# Type alias for JSON-native types
|
||||
JsonValue = Union[
|
||||
str, int, float, bool, list["JsonValue"], dict[str, "JsonValue"], None
|
||||
]
|
||||
|
||||
|
||||
@lru_cache(maxsize=LRU_CACHE_MAX_SIZE)
|
||||
def context_addons() -> dict[str, Any]:
|
||||
@@ -112,6 +118,25 @@ class TimeFilter:
|
||||
time_range: str | None
|
||||
|
||||
|
||||
def _normalize_postgresql_backslash_escapes(dialect: Dialect) -> None:
|
||||
"""Correct a PostgreSQL dialect instance's ``_backslash_escapes`` default
|
||||
in place so backslashes round-trip unchanged when the dialect is used to
|
||||
render literals without a live connection.
|
||||
|
||||
A dialect built without a live connection (as ``Database.get_dialect()``
|
||||
does) defaults ``_backslash_escapes`` to ``True``, which would double
|
||||
every backslash even though every supported PostgreSQL version treats
|
||||
the backslash as a plain character by default
|
||||
(``standard_conforming_strings`` has been on since PostgreSQL 9.1). Left
|
||||
uncorrected, a value like ``C:\\Users`` would be rewritten to
|
||||
``C:\\\\Users`` and silently fail to match the original value. Other
|
||||
dialects (for example MySQL/MariaDB, which do treat the backslash as an
|
||||
escape character) are left untouched.
|
||||
"""
|
||||
if dialect.name == "postgresql":
|
||||
dialect._backslash_escapes = False
|
||||
|
||||
|
||||
class ExtraCache:
|
||||
"""
|
||||
Dummy class that exposes a method used to store additional values used in
|
||||
@@ -128,7 +153,8 @@ class ExtraCache:
|
||||
r"current_user_rls_rules\([^)]*\)|"
|
||||
r"current_user_roles\([^)]*\)|"
|
||||
r"cache_key_wrapper\([^)]*\)|"
|
||||
r"url_param\([^)]*\)"
|
||||
r"url_param\([^)]*\)|"
|
||||
r"get_guest_user_attribute\([^)]*\)"
|
||||
r")"
|
||||
r"[^{}]*?(\}\}|\%\})"
|
||||
)
|
||||
@@ -295,15 +321,88 @@ class ExtraCache:
|
||||
result = url_params.get(param, default)
|
||||
# Escape the value regardless of its source (request args or form
|
||||
# data); both are interpolated into the rendered SQL.
|
||||
if result and escape_result and self.dialect:
|
||||
# use the dialect specific quoting logic to escape string
|
||||
result = String().literal_processor(dialect=self.dialect)(value=result)[
|
||||
1:-1
|
||||
]
|
||||
if result and escape_result:
|
||||
# use the dialect-specific literal rendering to escape the string
|
||||
result = self._escape_value(result)
|
||||
if add_to_cache_keys:
|
||||
self.cache_key_wrapper(result)
|
||||
return result
|
||||
|
||||
def get_guest_user_attribute(
|
||||
self,
|
||||
attribute_name: str,
|
||||
default: JsonValue = None,
|
||||
add_to_cache_keys: bool = True,
|
||||
escape_result: bool = True,
|
||||
) -> JsonValue:
|
||||
"""
|
||||
Get a specific user attribute from guest user.
|
||||
|
||||
This function retrieves attributes from the guest user token and supports
|
||||
all JSON-native types (string, number, boolean, array, object, null).
|
||||
|
||||
Args:
|
||||
attribute_name: Name of the attribute to retrieve
|
||||
default: Default value if attribute not found (can be any JSON-native type)
|
||||
add_to_cache_keys: Whether the resolved value should be included in the
|
||||
cache key. The resolved value is keyed on every branch (including
|
||||
the default and null) so two principals whose tokens render
|
||||
different SQL never share a cache entry. Opting out is only safe
|
||||
when the value cannot affect the query results.
|
||||
escape_result: Escape string values (including strings nested inside
|
||||
lists and object values) through the database dialect's literal
|
||||
rendering so they are safe to interpolate into SQL, mirroring
|
||||
``url_param``. Enabled by default; non-string JSON types are
|
||||
returned unchanged. Set to False for the raw value, in which case
|
||||
the template author is responsible for validating the value. Pass
|
||||
``escape_result=False`` when piping a list-valued attribute
|
||||
through the ``where_in`` filter: ``where_in`` applies its own
|
||||
dialect-safe quoting, so leaving the default escaping on would
|
||||
escape each value twice.
|
||||
|
||||
Returns:
|
||||
The attribute value from the guest user token, or the default value.
|
||||
Can be any JSON-native type: string, number, boolean, array, object, or
|
||||
null.
|
||||
|
||||
Examples:
|
||||
{{ get_guest_user_attribute('department') }} # Returns: "Engineering"
|
||||
{{ get_guest_user_attribute('is_admin') }} # Returns: True
|
||||
{{ get_guest_user_attribute('permissions') }} # Returns: ["read", "write"]
|
||||
{{ get_guest_user_attribute('config') }} # Returns: {"theme": "dark"}
|
||||
{{ get_guest_user_attribute('missing', 'default') }} # Returns: "default"
|
||||
full_name IN {{ get_guest_user_attribute('names', escape_result=False)
|
||||
|where_in }}
|
||||
"""
|
||||
|
||||
result: JsonValue = default
|
||||
# The macro only applies to guest users (embedded). is_guest_user()
|
||||
# handles the feature-flag and request-context checks internally.
|
||||
if security_manager.is_guest_user():
|
||||
token: GuestToken = g.user.guest_token
|
||||
user_attributes: dict[str, JsonValue] = (
|
||||
token.get("user", {}).get("attributes") or {}
|
||||
)
|
||||
result = user_attributes.get(attribute_name, default)
|
||||
|
||||
if add_to_cache_keys:
|
||||
# Key the resolved value on every branch (attribute, default, or
|
||||
# null); a guest whose attribute is absent renders different SQL
|
||||
# than one whose attribute is set, so both must contribute to the
|
||||
# cache key. json.dumps gives a stable serialization for all
|
||||
# JSON-native types.
|
||||
cache_value = json.dumps(result, sort_keys=True)
|
||||
self.cache_key_wrapper(
|
||||
f"guest_user_attribute:{attribute_name}:{cache_value}"
|
||||
)
|
||||
# Guest attributes (and caller-supplied defaults) are interpolated into
|
||||
# the rendered SQL, so escape strings with the dialect's literal
|
||||
# rendering by default, mirroring url_param. Non-string JSON types pass
|
||||
# through.
|
||||
if escape_result:
|
||||
result = self._escape_value(result)
|
||||
return result
|
||||
|
||||
def filter_values(
|
||||
self, column: str, default: str | None = None, remove_filter: bool = False
|
||||
) -> list[Any]:
|
||||
@@ -348,22 +447,37 @@ class ExtraCache:
|
||||
def _escape_value(self, val: Any) -> Any:
|
||||
"""Return a dialect-quoted form of ``val`` suitable for direct SQL
|
||||
interpolation. When no dialect is configured the value is returned
|
||||
unchanged so callers see the raw value as before. Strings are
|
||||
passed through SQLAlchemy's ``String`` literal processor (with the
|
||||
surrounding quotes stripped, mirroring ``url_param``). Lists are
|
||||
processed element-wise; non-string members are left as-is.
|
||||
unchanged so callers see the raw value as before.
|
||||
|
||||
Strings are rendered through the dialect compiler's
|
||||
``render_literal_value`` (with the surrounding quotes stripped),
|
||||
which applies dialect-specific escaping beyond quote doubling; in
|
||||
particular, MySQL/MariaDB treat the backslash as an escape
|
||||
character, so backslashes are doubled there to prevent a trailing
|
||||
``\\'`` from re-opening the string literal. Dialects whose escaping
|
||||
mode cannot be introspected without a live connection err on the
|
||||
side of over-escaping, which can distort a backslash-containing
|
||||
value but can never widen the query.
|
||||
|
||||
PostgreSQL is special-cased via ``_normalize_postgresql_backslash_escapes``
|
||||
to restore parity with PostgreSQL's default configuration, while
|
||||
MySQL/MariaDB keep the stricter, backslash-doubling behavior above.
|
||||
|
||||
Lists are processed element-wise and dict values recursively, so
|
||||
strings nested inside JSON structures are also escaped; dict keys
|
||||
are left untouched since they are used for member lookups, not
|
||||
interpolation. Non-string leaf values are left as-is.
|
||||
"""
|
||||
if not self.dialect:
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return String().literal_processor(dialect=self.dialect)(value=val)[1:-1]
|
||||
compiler = self.dialect.statement_compiler(self.dialect, None)
|
||||
_normalize_postgresql_backslash_escapes(compiler.dialect)
|
||||
return compiler.render_literal_value(val, String())[1:-1]
|
||||
if isinstance(val, list):
|
||||
return [
|
||||
String().literal_processor(dialect=self.dialect)(value=v)[1:-1]
|
||||
if isinstance(v, str)
|
||||
else v
|
||||
for v in val
|
||||
]
|
||||
return [self._escape_value(v) for v in val]
|
||||
if isinstance(val, dict):
|
||||
return {k: self._escape_value(v) for k, v in val.items()}
|
||||
return val
|
||||
|
||||
def get_filters(self, column: str, remove_filter: bool = False) -> list[Filter]:
|
||||
@@ -672,6 +786,10 @@ def validate_template_context(
|
||||
|
||||
class WhereInMacro: # pylint: disable=too-few-public-methods
|
||||
def __init__(self, dialect: Dialect):
|
||||
# Without this, a PostgreSQL value like ``C:\Users`` would render as
|
||||
# ``C:\\Users`` and silently fail to match the original value; see
|
||||
# ``_normalize_postgresql_backslash_escapes`` for the full rationale.
|
||||
_normalize_postgresql_backslash_escapes(dialect)
|
||||
self.dialect = dialect
|
||||
|
||||
def __call__(
|
||||
@@ -917,6 +1035,9 @@ class JinjaTemplateProcessor(BaseTemplateProcessor):
|
||||
"get_filters": partial(safe_proxy, extra_cache.get_filters),
|
||||
"dataset": partial(safe_proxy, dataset_macro),
|
||||
"get_time_filter": partial(safe_proxy, extra_cache.get_time_filter),
|
||||
"get_guest_user_attribute": partial(
|
||||
safe_proxy, extra_cache.get_guest_user_attribute
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+54
@@ -0,0 +1,54 @@
|
||||
# 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 include_cta to report_schedule
|
||||
|
||||
Adds a nullable ``include_cta`` column to the ``report_schedule`` table. It controls
|
||||
whether the call-to-action link back to Superset (e.g. "Explore in Superset") is
|
||||
included in the notifications delivered for that schedule. The column defaults to
|
||||
true and NULL is treated as true, so existing schedules keep the link.
|
||||
|
||||
Revision ID: 2d6ad72e4af6
|
||||
Revises: 1a27941d5352
|
||||
Create Date: 2026-07-28 10:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
from superset.migrations.shared.utils import add_columns, drop_columns
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = "2d6ad72e4af6"
|
||||
down_revision = "b8d2f4a6c901"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add the nullable ``include_cta`` column to ``report_schedule``."""
|
||||
add_columns(
|
||||
"report_schedule",
|
||||
sa.Column(
|
||||
"include_cta",
|
||||
sa.Boolean(),
|
||||
nullable=True,
|
||||
server_default=sa.true(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Drop the ``include_cta`` column from ``report_schedule``."""
|
||||
drop_columns("report_schedule", "include_cta")
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# 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.
|
||||
"""Preserve MySQL audit ordering and index predecessor lookups.
|
||||
|
||||
Revision ID: b8d2f4a6c901
|
||||
Revises: d7cecc48bd55
|
||||
Create Date: 2026-08-06 18:00:00.000000
|
||||
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy.dialects.mysql.base import MySQLDialect
|
||||
|
||||
from superset.migrations.shared.utils import create_index, drop_index
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = "b8d2f4a6c901"
|
||||
down_revision: str = "d7cecc48bd55"
|
||||
|
||||
_INDEX_NAME: str = "ix_purge_audit_log_retention_predecessor"
|
||||
|
||||
|
||||
def _set_mysql_created_on_precision() -> None:
|
||||
"""Set fractional-second precision where plain DATETIME drops it."""
|
||||
if isinstance(op.get_bind().dialect, MySQLDialect):
|
||||
op.alter_column(
|
||||
"purge_audit_log",
|
||||
"created_on",
|
||||
existing_type=mysql.DATETIME(fsp=0),
|
||||
type_=mysql.DATETIME(fsp=6),
|
||||
existing_nullable=False,
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
_set_mysql_created_on_precision()
|
||||
create_index(
|
||||
"purge_audit_log",
|
||||
_INDEX_NAME,
|
||||
["entity_uuid", "entity_type", "trigger", "created_on"],
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# MySQL DATETIME(6) is backward-compatible with the prior application.
|
||||
# Keep the expanded precision so rollback never truncates audit ordering
|
||||
# evidence written after upgrade.
|
||||
drop_index("purge_audit_log", _INDEX_NAME)
|
||||
@@ -1519,7 +1519,7 @@ class Database(CoreDatabase, AuditMixinNullable, ImportExportMixin): # pylint:
|
||||
scope or in the endpoints.
|
||||
"""
|
||||
db.session.query(DatabaseUserOAuth2Tokens).filter(
|
||||
DatabaseUserOAuth2Tokens.id == self.id
|
||||
DatabaseUserOAuth2Tokens.database_id == self.id
|
||||
).delete()
|
||||
|
||||
def execute(
|
||||
|
||||
+163
-8
@@ -40,6 +40,7 @@ from typing import (
|
||||
TypedDict,
|
||||
Union,
|
||||
)
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import dateutil.parser
|
||||
import humanize
|
||||
@@ -369,6 +370,27 @@ class UUIDMixin: # pylint: disable=too-few-public-methods
|
||||
return str(self.uuid)[:8]
|
||||
|
||||
|
||||
class ChildMultipleResultsFound(MultipleResultsFound):
|
||||
"""
|
||||
An ambiguous lookup raised while importing a *child* object.
|
||||
|
||||
``ImportExportMixin.import_from_dict`` matches an incoming payload against
|
||||
existing rows with a disjunction (unique-constraint match OR uuid match), so
|
||||
a single payload entry can match two different DB rows — one by name and one
|
||||
by uuid. When that happens for a top-level object it is raised before any
|
||||
mutation, and callers may legitimately recover by skipping or returning the
|
||||
existing row (see ``superset/commands/importers/v1/examples.py``).
|
||||
|
||||
When it happens for a *child* (``parent is not None``) the parent's fields
|
||||
have already been updated and earlier siblings have already been imported,
|
||||
so recovering by "returning the existing row" would report success over a
|
||||
half-applied import. This subclass lets callers tell the two cases apart and
|
||||
abort the transaction for the child case. It intentionally subclasses
|
||||
``MultipleResultsFound`` so existing ``except MultipleResultsFound`` handlers
|
||||
keep working.
|
||||
"""
|
||||
|
||||
|
||||
class ImportExportMixin(UUIDMixin):
|
||||
export_parent: Optional[str] = None
|
||||
# The name of the attribute
|
||||
@@ -506,7 +528,32 @@ class ImportExportMixin(UUIDMixin):
|
||||
try:
|
||||
obj_query = db.session.query(cls).filter(and_(*filters))
|
||||
obj = obj_query.one_or_none()
|
||||
except MultipleResultsFound:
|
||||
except MultipleResultsFound as ex:
|
||||
# Now that children carry a ``uuid``, this match is a disjunction
|
||||
# (name-match OR uuid-match), so a payload child can match one DB row
|
||||
# by name and a different one by uuid — e.g. metrics renamed/swapped
|
||||
# in the UI, then an older export re-imported with overwrite.
|
||||
#
|
||||
# That stays a hard error on purpose. Resolving it by preferring the
|
||||
# uuid match would rename the uuid-matched row while the name-matched
|
||||
# row still holds that name, trading this clear failure for an opaque
|
||||
# ``UNIQUE constraint failed: (table_id, <name>)`` at the next flush.
|
||||
#
|
||||
# The two cases are not equally recoverable, so they are raised as
|
||||
# distinct types:
|
||||
#
|
||||
# * ``parent is None`` (top-level object) — nothing has been mutated
|
||||
# yet, so a caller may legitimately skip or fall back to the
|
||||
# existing row. See the legacy NULL-schema handling in
|
||||
# ``superset/commands/dataset/importers/v1/utils.py`` (including
|
||||
# its ``deleted_at`` rollback) and the ``continue`` in
|
||||
# ``superset/commands/importers/v1/examples.py`` (issue #16051).
|
||||
# * ``parent is not None`` (child object) — the parent's scalar
|
||||
# fields were already updated and earlier siblings were already
|
||||
# imported, so there is no "unmodified row" to fall back to.
|
||||
# Raising ``ChildMultipleResultsFound`` lets the dataset importer
|
||||
# fail the import atomically (the command's transaction rolls
|
||||
# back) instead of reporting success over a half-applied state.
|
||||
logger.error(
|
||||
"Error importing %s \n %s \n %s",
|
||||
cls.__name__,
|
||||
@@ -514,8 +561,54 @@ class ImportExportMixin(UUIDMixin):
|
||||
yaml.safe_dump(dict_rep),
|
||||
exc_info=True,
|
||||
)
|
||||
if parent is not None:
|
||||
raise ChildMultipleResultsFound(
|
||||
f"{cls.__name__} matches more than one existing row under "
|
||||
f"its parent {type(parent).__name__}"
|
||||
) from ex
|
||||
raise
|
||||
|
||||
# A child ``uuid`` is globally unique, but the match above is scoped to
|
||||
# this ``parent`` (and also matches on name). Importing a config as a
|
||||
# clone — e.g. a dataset re-imported under an edited uuid while the
|
||||
# original still exists — can leave the incoming child uuid owned by a
|
||||
# different row. Writing it, whether on INSERT (new obj) or on an
|
||||
# overwrite UPDATE (obj matched by name), would violate the ``uuid``
|
||||
# unique constraint at flush. Drop the incoming uuid whenever it belongs
|
||||
# to some other row so ``UUIDMixin`` keeps/assigns a distinct one,
|
||||
# reverting to the pre-uuid-export behavior for that clone.
|
||||
if parent is not None and "uuid" in dict_rep:
|
||||
if dict_rep["uuid"] is None:
|
||||
# The child import schemas accept ``uuid: null``. On the
|
||||
# overwrite UPDATE that would write a literal NULL over an
|
||||
# existing child's uuid — silently, since the column is nullable
|
||||
# and ``unique`` permits repeated NULLs — leaving a child no
|
||||
# folder can reference and no export can round-trip.
|
||||
del dict_rep["uuid"]
|
||||
else:
|
||||
# Known limitation: this lookup goes through the normal session
|
||||
# visibility filter, so for child classes that are soft-deletable
|
||||
# it cannot see a soft-deleted owner of the incoming uuid. In
|
||||
# practice the children that matter here (``TableColumn`` /
|
||||
# ``SqlMetric``) are not soft-deletable; the only soft-deletable
|
||||
# child registered anywhere is ``SqlaTable`` via ``Database``'s
|
||||
# ``export_children = ["tables"]`` (the v0 legacy path), which is
|
||||
# reachable only from a hand-crafted v0 file. There a colliding
|
||||
# uuid owned by a soft-deleted dataset would be kept and fail on
|
||||
# the unique constraint at flush rather than being dropped here.
|
||||
#
|
||||
# Skip the lookup on the common idempotent re-import, where the
|
||||
# name-matched ``obj`` already owns the incoming uuid: the guard
|
||||
# would keep it anyway, so the query is pure waste on that path.
|
||||
if obj is None or str(obj.uuid) != str(dict_rep["uuid"]):
|
||||
uuid_owner = (
|
||||
db.session.query(cls)
|
||||
.filter(cls.uuid == dict_rep["uuid"])
|
||||
.first()
|
||||
)
|
||||
if uuid_owner is not None and uuid_owner is not obj:
|
||||
del dict_rep["uuid"]
|
||||
|
||||
if not obj:
|
||||
is_new_obj = True
|
||||
# Create new DB object
|
||||
@@ -615,6 +708,7 @@ class ImportExportMixin(UUIDMixin):
|
||||
recursive=recursive,
|
||||
include_parent_ref=include_parent_ref,
|
||||
include_defaults=include_defaults,
|
||||
export_uuids=export_uuids,
|
||||
)
|
||||
for child in getattr(self, cld)
|
||||
],
|
||||
@@ -1301,6 +1395,26 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
def get_template_processor(self, **kwargs: Any) -> BaseTemplateProcessor:
|
||||
raise NotImplementedError()
|
||||
|
||||
def get_dataset_timezone(self) -> str | None:
|
||||
"""
|
||||
Get the timezone configured for this dataset from the extra JSON field.
|
||||
|
||||
Returns an IANA timezone name (e.g., "Europe/Berlin", "America/New_York")
|
||||
or None if not configured.
|
||||
|
||||
``extra`` is arbitrary user-supplied JSON, so the ``timezone`` key could
|
||||
hold a non-string value (a number, object, list, ...). Only a string is
|
||||
ever a valid IANA name, so anything else is treated as "not configured"
|
||||
rather than propagating a bad value on to ``ZoneInfo``.
|
||||
|
||||
``extra_dict`` is provided by concrete datasources (e.g. ``SqlaTable``)
|
||||
rather than this mixin, so read it defensively: subclasses without it
|
||||
simply have no configured timezone.
|
||||
"""
|
||||
extra = getattr(self, "extra_dict", None) or {}
|
||||
dataset_timezone = extra.get("timezone")
|
||||
return dataset_timezone if isinstance(dataset_timezone, str) else None
|
||||
|
||||
def get_fetch_values_predicate(
|
||||
self,
|
||||
template_processor: Optional[ # pylint: disable=unused-argument
|
||||
@@ -1866,11 +1980,18 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
"""
|
||||
labels = self._collect_dttm_labels(query_object)
|
||||
|
||||
# ``get_dataset_timezone`` lives on ``ExploreMixin``; datasource doubles
|
||||
# that bind only a subset of mixin methods onto a plain object (as some
|
||||
# unit tests do) won't have it, so fall back to "not configured" rather
|
||||
# than raising.
|
||||
get_dataset_timezone = getattr(self, "get_dataset_timezone", None)
|
||||
dataset_timezone = get_dataset_timezone() if get_dataset_timezone else None
|
||||
dttm_cols = [
|
||||
DateColumn(
|
||||
timestamp_format=fmt,
|
||||
offset=self.offset,
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
col_label=label,
|
||||
)
|
||||
for label, fmt in labels
|
||||
@@ -1882,6 +2003,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
timestamp_format=self._python_date_format(query_object.granularity),
|
||||
offset=self.offset,
|
||||
time_shift=query_object.time_shift,
|
||||
timezone=dataset_timezone,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -3269,7 +3391,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
|
||||
return f"""'{dttm.strftime("%Y-%m-%d %H:%M:%S.%f")}'"""
|
||||
|
||||
def get_time_filter( # pylint: disable=too-many-arguments
|
||||
def get_time_filter( # pylint: disable=too-many-arguments # noqa: C901
|
||||
self,
|
||||
time_col: "TableColumn",
|
||||
start_dttm: Optional[sa.DateTime],
|
||||
@@ -3290,12 +3412,45 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
)
|
||||
)
|
||||
|
||||
# Honor the dataset "Hour Offset". Result timestamps are displayed shifted
|
||||
# by +offset hours (see normalize_df / DateColumn in superset.utils.core),
|
||||
# but the time filter compares the raw stored values. Shifting the filter
|
||||
# bounds by -offset keeps the filter consistent with what is displayed;
|
||||
# otherwise a date selection lands on the wrong calendar day (#104810).
|
||||
if offset_hours := getattr(self, "offset", 0) or 0:
|
||||
# Resolve dataset-level time-boundary adjustments. A configured
|
||||
# `extra.timezone` (an IANA name, DST-aware) takes precedence: naive UI
|
||||
# boundaries are interpreted in that zone and converted to UTC for
|
||||
# comparison with UTC-stored data. When no timezone is configured, fall
|
||||
# back to the legacy "Hour Offset" field instead: displayed values are
|
||||
# shifted by +offset hours (see normalize_df / DateColumn in
|
||||
# superset.utils.core), but the time filter compares raw stored values, so
|
||||
# bounds are shifted by -offset to stay consistent with what's displayed
|
||||
# (#104810).
|
||||
dataset_timezone = self.get_dataset_timezone()
|
||||
|
||||
if dataset_timezone and (start_dttm or end_dttm):
|
||||
try:
|
||||
tz = ZoneInfo(dataset_timezone)
|
||||
|
||||
# The datetimes from the UI are naive (no timezone info). We
|
||||
# interpret them as being in the dataset's configured timezone and
|
||||
# convert them to UTC for comparison with UTC-stored data.
|
||||
if start_dttm is not None:
|
||||
start_dttm = (
|
||||
start_dttm.replace(tzinfo=tz)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
if end_dttm is not None:
|
||||
end_dttm = (
|
||||
end_dttm.replace(tzinfo=tz)
|
||||
.astimezone(timezone.utc)
|
||||
.replace(tzinfo=None)
|
||||
)
|
||||
except (ZoneInfoNotFoundError, TypeError):
|
||||
logger.warning(
|
||||
"Invalid timezone %r in dataset extra; falling back to Hour "
|
||||
"Offset if configured",
|
||||
dataset_timezone,
|
||||
)
|
||||
dataset_timezone = None
|
||||
|
||||
if not dataset_timezone and (offset_hours := getattr(self, "offset", 0) or 0):
|
||||
if start_dttm is not None:
|
||||
start_dttm = start_dttm - timedelta(hours=offset_hours)
|
||||
if end_dttm is not None:
|
||||
|
||||
@@ -14,13 +14,14 @@
|
||||
# KIND, either express or implied. See the License for the
|
||||
# specific language governing permissions and limitations
|
||||
# under the License.
|
||||
"""The ``purge_audit_log`` table: immutable, content-free purge records.
|
||||
"""The ``purge_audit_log`` table: content-free purge audit records.
|
||||
|
||||
The write-ahead audit protocol that populates this table lives in
|
||||
:mod:`superset.commands.deletion_retention.audit`; the model is defined
|
||||
here so it registers with ``Model.metadata`` at app init like every other
|
||||
Superset model (Alembic autogenerate and metadata-driven tooling would
|
||||
otherwise not see the table).
|
||||
otherwise not see the table). Completed outcomes are immutable; a current
|
||||
provisional record may be discarded when it is proven redundant.
|
||||
"""
|
||||
|
||||
from uuid import uuid4
|
||||
@@ -28,6 +29,7 @@ from uuid import uuid4
|
||||
import sqlalchemy as sa
|
||||
from flask_appbuilder import Model
|
||||
from sqlalchemy import Column, DateTime, Integer, String, Text
|
||||
from sqlalchemy.dialects import mysql
|
||||
from sqlalchemy_utils import UUIDType
|
||||
|
||||
STATUS_PENDING = "pending"
|
||||
@@ -45,13 +47,20 @@ STATUS_TARGET_ABSENT = "target_absent"
|
||||
|
||||
|
||||
class PurgeAuditLog(Model):
|
||||
"""Immutable, content-free record of a purge."""
|
||||
"""Content-free provisional record or immutable retained purge outcome."""
|
||||
|
||||
__tablename__ = "purge_audit_log"
|
||||
__table_args__ = (
|
||||
# Backs reconcile_pending()'s stale-pending scan; mirrors the
|
||||
# index created by migration e7d93a524ff6.
|
||||
sa.Index("ix_purge_audit_log_status_created_on", "status", "created_on"),
|
||||
sa.Index(
|
||||
"ix_purge_audit_log_retention_predecessor",
|
||||
"entity_uuid",
|
||||
"entity_type",
|
||||
"trigger",
|
||||
"created_on",
|
||||
),
|
||||
)
|
||||
|
||||
id = Column(UUIDType(binary=True), primary_key=True, default=uuid4)
|
||||
@@ -64,5 +73,10 @@ class PurgeAuditLog(Model):
|
||||
# row (force-purge visibility). Free text, content-free.
|
||||
affected_referrers = Column(Text, nullable=True)
|
||||
removed_dashboard_slices = Column(Integer, nullable=False, default=0)
|
||||
created_on = Column(DateTime, nullable=False)
|
||||
created_on = Column(
|
||||
DateTime()
|
||||
.with_variant(mysql.DATETIME(fsp=6), "mysql")
|
||||
.with_variant(mysql.DATETIME(fsp=6), "mariadb"),
|
||||
nullable=False,
|
||||
)
|
||||
confirmed_on = Column(DateTime, nullable=True)
|
||||
|
||||
@@ -127,6 +127,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
"extra",
|
||||
"force_screenshot",
|
||||
"grace_period",
|
||||
"include_cta",
|
||||
"last_eval_dttm",
|
||||
"last_state",
|
||||
"last_value",
|
||||
@@ -204,6 +205,7 @@ class ReportScheduleRestApi(BaseSupersetModelRestApi):
|
||||
"extra",
|
||||
"force_screenshot",
|
||||
"grace_period",
|
||||
"include_cta",
|
||||
"log_retention",
|
||||
"name",
|
||||
"recipients",
|
||||
|
||||
@@ -188,6 +188,10 @@ class ReportSchedule(AuditMixinNullable, ExtraJSONMixin, Model):
|
||||
|
||||
email_subject = Column(String(255))
|
||||
|
||||
# (Alerts/Reports) Include the call-to-action link back to Superset in
|
||||
# notifications? NULL is treated as True.
|
||||
include_cta = Column(Boolean, default=True, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return str(self.name)
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ class NotificationContent:
|
||||
# Populated only when this is a per-retry or final-failure notification
|
||||
retry_attempt: Optional[int] = None
|
||||
retry_max_attempts: Optional[int] = None
|
||||
include_cta: bool = True # include the call-to-action link back to Superset
|
||||
|
||||
|
||||
class BaseNotification: # pylint: disable=too-few-public-methods
|
||||
|
||||
@@ -148,22 +148,29 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
return parseaddr(current_app.config["SMTP_MAIL_FROM"])[1].split("@")[1]
|
||||
|
||||
def _error_template(self, text: str) -> str:
|
||||
call_to_action = self._get_call_to_action()
|
||||
# The error text is derived from exception messages that can embed
|
||||
# data-controlled content (e.g. crafted table/column names in a DB
|
||||
# error). Strip all HTML before interpolating it into the email body,
|
||||
# matching the sanitization applied to the normal content path.
|
||||
# pylint: disable=no-member
|
||||
safe_text = nh3.clean(text, tags=set(), attributes={})
|
||||
if self._content.include_cta:
|
||||
return __(
|
||||
"""
|
||||
<p>Your report/alert was unable to be generated because of the following error: %(text)s</p>
|
||||
<p>Please check your dashboard/chart for errors.</p>
|
||||
<p><b><a href="%(url)s">%(call_to_action)s</a></b></p>
|
||||
""", # noqa: E501
|
||||
text=safe_text,
|
||||
url=self._content.url,
|
||||
call_to_action=self._get_call_to_action(),
|
||||
)
|
||||
return __(
|
||||
"""
|
||||
<p>Your report/alert was unable to be generated because of the following error: %(text)s</p>
|
||||
<p>Please check your dashboard/chart for errors.</p>
|
||||
<p><b><a href="%(url)s">%(call_to_action)s</a></b></p>
|
||||
""", # noqa: E501
|
||||
text=safe_text,
|
||||
url=self._content.url,
|
||||
call_to_action=call_to_action,
|
||||
)
|
||||
|
||||
def _retry_error_template(self, text: str) -> str:
|
||||
@@ -173,7 +180,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
retries_remaining = (max_attempts or 0) - (attempt or 0)
|
||||
# pylint: disable=no-member
|
||||
safe_text = nh3.clean(text, tags=set(), attributes={})
|
||||
call_to_action = self._get_call_to_action()
|
||||
cta_tag = self._render_call_to_action_paragraph()
|
||||
|
||||
return textwrap.dedent(
|
||||
f"""
|
||||
@@ -199,7 +206,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
<p><b>Retry attempt:</b> {attempt} of {max_attempts}
|
||||
<b>Retries remaining:</b> {retries_remaining}</p>
|
||||
<p><b>Error details:</b> {safe_text}</p>
|
||||
<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>
|
||||
{cta_tag}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -209,7 +216,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
"""HTML body for the final-failure email after all retries are exhausted."""
|
||||
# pylint: disable=no-member
|
||||
safe_text = nh3.clean(text, tags=set(), attributes={})
|
||||
call_to_action = self._get_call_to_action()
|
||||
cta_tag = self._render_call_to_action_paragraph()
|
||||
max_attempts = self._content.retry_max_attempts
|
||||
|
||||
return textwrap.dedent(
|
||||
@@ -246,7 +253,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
<li>Try generating the report manually to troubleshoot</li>
|
||||
<li>Contact support if the issue persists</li>
|
||||
</ul>
|
||||
<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>
|
||||
{cta_tag}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
@@ -312,7 +319,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
"""
|
||||
)
|
||||
img_tag = "".join(img_tags)
|
||||
call_to_action = self._get_call_to_action()
|
||||
call_to_action_tag = self._render_call_to_action_tag()
|
||||
body = textwrap.dedent(
|
||||
f"""
|
||||
<html>
|
||||
@@ -333,7 +340,7 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
<body>
|
||||
<div>{description}</div>
|
||||
<br>
|
||||
<b><a href="{self._content.url}">{call_to_action}</a></b><p></p>
|
||||
{call_to_action_tag}
|
||||
{html_table}
|
||||
{img_tag}
|
||||
</body>
|
||||
@@ -404,6 +411,20 @@ class EmailNotification(BaseNotification): # pylint: disable=too-few-public-met
|
||||
def _get_call_to_action(self) -> str:
|
||||
return __(current_app.config["EMAIL_REPORTS_CTA"])
|
||||
|
||||
def _render_call_to_action_tag(self) -> str:
|
||||
"""Anchor markup for the call-to-action link, or "" when disabled."""
|
||||
if not self._content.include_cta:
|
||||
return ""
|
||||
call_to_action = self._get_call_to_action()
|
||||
return f'<b><a href="{self._content.url}">{call_to_action}</a></b><p></p>'
|
||||
|
||||
def _render_call_to_action_paragraph(self) -> str:
|
||||
"""CTA link wrapped in a paragraph, or "" when disabled."""
|
||||
if not self._content.include_cta:
|
||||
return ""
|
||||
call_to_action = self._get_call_to_action()
|
||||
return f'<p><b><a href="{self._content.url}">{call_to_action}</a></b></p>'
|
||||
|
||||
def _get_to(self) -> str:
|
||||
return json.loads(self._recipient.recipient_config_json)["target"]
|
||||
|
||||
|
||||
@@ -31,18 +31,30 @@ class SlackMixin:
|
||||
content: NotificationContent,
|
||||
table: str = "",
|
||||
) -> str:
|
||||
return __(
|
||||
"""*%(name)s*
|
||||
if content.include_cta:
|
||||
return __(
|
||||
"""*%(name)s*
|
||||
|
||||
%(description)s
|
||||
|
||||
<%(url)s|Explore in Superset>
|
||||
|
||||
%(table)s
|
||||
""",
|
||||
name=content.name,
|
||||
description=content.description or "",
|
||||
url=content.url,
|
||||
table=table,
|
||||
)
|
||||
return __(
|
||||
"""*%(name)s*
|
||||
|
||||
%(description)s
|
||||
|
||||
%(table)s
|
||||
""",
|
||||
name=content.name,
|
||||
description=content.description or "",
|
||||
url=content.url,
|
||||
table=table,
|
||||
)
|
||||
|
||||
|
||||
@@ -74,6 +74,10 @@ name_description = "The report schedule name."
|
||||
# :)
|
||||
description_description = "Use a nice description to give context to this Alert/Report"
|
||||
email_subject_description = "The report schedule subject line"
|
||||
include_cta_description = (
|
||||
"Whether to include the call-to-action link back to Superset "
|
||||
"(e.g. 'Explore in Superset') in the delivered notifications"
|
||||
)
|
||||
context_markdown_description = "Markdown description"
|
||||
crontab_description = (
|
||||
"A CRON expression."
|
||||
@@ -269,6 +273,11 @@ class ReportSchedulePostSchema(Schema):
|
||||
dump_default=None,
|
||||
)
|
||||
force_screenshot = fields.Boolean(dump_default=False)
|
||||
include_cta = fields.Boolean(
|
||||
dump_default=True,
|
||||
allow_none=True,
|
||||
metadata={"description": include_cta_description},
|
||||
)
|
||||
custom_width = fields.Integer(
|
||||
metadata={
|
||||
"description": _("Custom width of the screenshot in pixels"),
|
||||
@@ -482,6 +491,11 @@ class ReportSchedulePutSchema(Schema):
|
||||
)
|
||||
extra = fields.Dict(dump_default=None)
|
||||
force_screenshot = fields.Boolean(dump_default=False)
|
||||
include_cta = fields.Boolean(
|
||||
dump_default=True,
|
||||
allow_none=True,
|
||||
metadata={"description": include_cta_description},
|
||||
)
|
||||
|
||||
custom_width = fields.Integer(
|
||||
metadata={
|
||||
|
||||
@@ -69,6 +69,9 @@ class UserSchema(PermissiveSchema):
|
||||
username = fields.String()
|
||||
first_name = fields.String()
|
||||
last_name = fields.String()
|
||||
attributes = fields.Dict(
|
||||
keys=fields.String(), values=fields.Raw(allow_none=True), allow_none=True
|
||||
)
|
||||
|
||||
|
||||
class ResourceSchema(PermissiveSchema):
|
||||
|
||||
@@ -116,6 +116,7 @@ class GuestTokenUser(TypedDict, total=False):
|
||||
username: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
attributes: Optional[dict[str, Any]]
|
||||
|
||||
|
||||
class GuestTokenResourceType(StrEnum):
|
||||
|
||||
@@ -32,6 +32,7 @@ from typing import (
|
||||
TYPE_CHECKING,
|
||||
Union,
|
||||
)
|
||||
from urllib.parse import quote
|
||||
|
||||
from flask import current_app, Flask, g, has_app_context, Request, Response
|
||||
from flask_appbuilder import Model
|
||||
@@ -166,6 +167,42 @@ def get_extra_editor_subject_ids(resource: Model) -> list[int]:
|
||||
return subject_ids
|
||||
|
||||
|
||||
def _render_permission_instructions_link(
|
||||
*,
|
||||
datasource_id: str = "",
|
||||
datasource_name: str = "",
|
||||
table_names: str = "",
|
||||
) -> Optional[str]:
|
||||
"""Render the configured ``PERMISSION_INSTRUCTIONS_LINK``.
|
||||
|
||||
The configured URL may contain ``{datasource_id}``, ``{datasource_name}``,
|
||||
``{table_names}`` and ``{username}`` placeholders, which are substituted with
|
||||
URL-encoded values so the link can deep-link into an organization's access
|
||||
request system. A URL with no placeholders is returned unchanged, and an
|
||||
empty/unset config returns ``None`` (no link). Unsupplied placeholders are
|
||||
replaced with an empty string.
|
||||
"""
|
||||
link = get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
|
||||
if not link:
|
||||
return None
|
||||
|
||||
username = ""
|
||||
user = getattr(g, "user", None)
|
||||
if user is not None and not getattr(user, "is_anonymous", False):
|
||||
username = getattr(user, "username", "") or ""
|
||||
|
||||
for token, value in (
|
||||
("datasource_id", datasource_id),
|
||||
("datasource_name", datasource_name),
|
||||
("table_names", table_names),
|
||||
("username", username),
|
||||
):
|
||||
placeholder = "{" + token + "}"
|
||||
if placeholder in link:
|
||||
link = link.replace(placeholder, quote(str(value), safe=""))
|
||||
return link
|
||||
|
||||
|
||||
DATABASE_PERM_REGEX = re.compile(r"^\[.+\]\.\(id\:(?P<id>\d+)\)$")
|
||||
|
||||
|
||||
@@ -1935,17 +1972,23 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_datasource_access_link( # pylint: disable=unused-argument
|
||||
def get_datasource_access_link(
|
||||
datasource: "BaseDatasource | Explorable",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Return the link for the denied Superset datasource.
|
||||
|
||||
The configured ``PERMISSION_INSTRUCTIONS_LINK`` may template the denied
|
||||
datasource's id/name (and the current username) into the access URL.
|
||||
|
||||
:param datasource: The denied Superset datasource
|
||||
:returns: The access URL
|
||||
"""
|
||||
|
||||
return get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
|
||||
return _render_permission_instructions_link(
|
||||
datasource_id=str(datasource.data["id"]),
|
||||
datasource_name=str(datasource.data["name"]),
|
||||
)
|
||||
|
||||
def get_datasource_access_error_object( # pylint: disable=invalid-name
|
||||
self, datasource: "BaseDatasource | Explorable"
|
||||
@@ -1964,6 +2007,11 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
"link": self.get_datasource_access_link(datasource),
|
||||
"datasource": datasource.data["id"],
|
||||
"datasource_name": datasource.data["name"],
|
||||
# Owner display names give the viewer someone to contact for
|
||||
# access; sorted for a deterministic payload.
|
||||
"owners": sorted(
|
||||
str(owner) for owner in getattr(datasource, "owners", []) or []
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -2000,17 +2048,32 @@ class SupersetSecurityManager( # pylint: disable=too-many-public-methods
|
||||
},
|
||||
)
|
||||
|
||||
def get_table_access_link( # pylint: disable=unused-argument
|
||||
self, tables: set["Table"]
|
||||
) -> Optional[str]:
|
||||
def get_table_access_link(self, tables: set["Table"]) -> Optional[str]:
|
||||
"""
|
||||
Return the access link for the denied SQL tables.
|
||||
|
||||
The configured ``PERMISSION_INSTRUCTIONS_LINK`` may template the denied
|
||||
table names (and the current username) into the access URL.
|
||||
|
||||
:param tables: The set of denied SQL tables
|
||||
:returns: The access URL
|
||||
"""
|
||||
|
||||
return get_conf().get("PERMISSION_INSTRUCTIONS_LINK")
|
||||
# Build display names from the raw parts: Table.__str__ URL-encodes
|
||||
# each segment, and the renderer encodes the whole value again, so
|
||||
# using it here would double-encode. Sorted for deterministic links.
|
||||
return _render_permission_instructions_link(
|
||||
table_names=",".join(
|
||||
sorted(
|
||||
".".join(
|
||||
part
|
||||
for part in (table.catalog, table.schema, table.table)
|
||||
if part
|
||||
)
|
||||
for table in tables
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def get_user_datasources(self) -> list["BaseDatasource"]:
|
||||
"""
|
||||
|
||||
@@ -280,7 +280,17 @@ def _purge_one(
|
||||
removed_dashboard_slices=result.removed_dashboard_slices,
|
||||
)
|
||||
elif result.blocked_reason is not None:
|
||||
audit.block(record_id)
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
if disposition == "suppressed":
|
||||
stats_logger_manager.instance.incr(
|
||||
f"{_METRIC_PREFIX}.blocked_audit_suppressed"
|
||||
)
|
||||
elif disposition == "fallback":
|
||||
stats_logger_manager.instance.incr(
|
||||
f"{_METRIC_PREFIX}.blocked_audit_dedupe_fallback"
|
||||
)
|
||||
else:
|
||||
audit.fail(record_id)
|
||||
return result
|
||||
|
||||
@@ -532,9 +532,9 @@ msgstr "No se pudo agregar la(s) %s vista(s) semántica(s)"
|
||||
msgid "%s semantic view(s) failed to add: %s"
|
||||
msgstr "Error al agregar %s vista(s) semántica(s): %s"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "%s tab selected"
|
||||
msgstr "%s seleccionado"
|
||||
msgstr "Pestaña %s seleccionada"
|
||||
|
||||
#, python-format
|
||||
msgid "%s updated"
|
||||
@@ -1075,13 +1075,11 @@ msgstr ""
|
||||
" Estos valores intermedios pueden basarse en el tiempo o en la "
|
||||
"categoría."
|
||||
|
||||
#, fuzzy
|
||||
msgid "A-Z"
|
||||
msgstr "clave a-z"
|
||||
msgstr "A-Z"
|
||||
|
||||
#, fuzzy
|
||||
msgid "AND"
|
||||
msgstr "aleatorio"
|
||||
msgstr "Y"
|
||||
|
||||
#, fuzzy
|
||||
msgid "API Key Created"
|
||||
@@ -2727,9 +2725,8 @@ msgstr "Básico"
|
||||
msgid "Basic conditional formatting"
|
||||
msgstr "Formato condicional básico"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Basic information about the chart"
|
||||
msgstr "Información básica"
|
||||
msgstr "Información básica sobre el gráfico"
|
||||
|
||||
#, python-format
|
||||
msgid "Batch editing %d filters:"
|
||||
@@ -2753,9 +2750,8 @@ msgstr "Número grande con línea de tendencia"
|
||||
msgid "Bins"
|
||||
msgstr "Contenedores"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Blanks"
|
||||
msgstr "BOOLEANO"
|
||||
msgstr "En blanco"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: fr, ru]
|
||||
#, fuzzy, python-format
|
||||
@@ -3799,9 +3795,8 @@ msgstr "Haz clic para ordenar de forma descendente"
|
||||
msgid "Client ID"
|
||||
msgstr "Anchura de la línea"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Client Secret"
|
||||
msgstr "Selección de columna"
|
||||
msgstr "Secreto de cliente"
|
||||
|
||||
msgid "Close"
|
||||
msgstr "Cerrar"
|
||||
@@ -4281,9 +4276,8 @@ msgstr "La conexión ha fallado; revisa la configuración de tu conexión"
|
||||
msgid "Connection looks good!"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Contains"
|
||||
msgstr "Continuo"
|
||||
msgstr "Contiene"
|
||||
|
||||
# Machine-translated via backfill_po.py (claude-sonnet-4-6) [refs: de, sr,
|
||||
# sr_Latn]
|
||||
@@ -4932,9 +4926,8 @@ msgstr "Datos"
|
||||
msgid "Data Connections"
|
||||
msgstr "Conexiones de la base de datos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Data Export Options"
|
||||
msgstr "Opciones del gráfico"
|
||||
msgstr "Opciones de exportación de datos"
|
||||
|
||||
msgid "Data Table"
|
||||
msgstr "Tabla de datos"
|
||||
@@ -5458,9 +5451,9 @@ msgstr[1] "Retrasado (se omitieron %s actualizaciones)"
|
||||
msgid "Delete"
|
||||
msgstr "Eliminar"
|
||||
|
||||
#, fuzzy, python-format
|
||||
#, python-format
|
||||
msgid "Delete %s"
|
||||
msgstr "Se ha eliminado%s"
|
||||
msgstr "Eliminar %s"
|
||||
|
||||
#, python-format
|
||||
msgid "Delete %s?"
|
||||
@@ -5627,7 +5620,7 @@ msgstr[1] "Se han eliminado%(num)d conjuntos de datos"
|
||||
|
||||
#, python-format
|
||||
msgid "Deleted %s"
|
||||
msgstr "Se ha eliminado%s"
|
||||
msgstr "Se ha eliminado %s"
|
||||
|
||||
#, python-format
|
||||
msgid "Deleted %s item(s)"
|
||||
@@ -6234,9 +6227,8 @@ msgstr "Función de agregación dinámica"
|
||||
msgid "Dynamic Section Label"
|
||||
msgstr "Direccional"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Dynamic group by"
|
||||
msgstr "NO AGRUPADO POR"
|
||||
msgstr "Agrupación dinámica"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Dynamic section description"
|
||||
@@ -6444,9 +6436,8 @@ msgstr ""
|
||||
msgid "Either the username or the password is wrong."
|
||||
msgstr "El nombre de usuario o la contraseña son incorrectos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Elapsed"
|
||||
msgstr "Volver a cargar"
|
||||
msgstr "Transcurrido"
|
||||
|
||||
msgid "Elevation"
|
||||
msgstr "Elevación"
|
||||
@@ -6611,9 +6602,8 @@ msgstr "Fecha final excluida del rango de tiempo"
|
||||
msgid "End date must be after start date"
|
||||
msgstr "La fecha final debe ser posterior a la fecha inicial"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Ends With"
|
||||
msgstr "Anchura del borde"
|
||||
msgstr "Termina con"
|
||||
|
||||
#, python-format
|
||||
msgid "Ends with (ILIKE %x)"
|
||||
@@ -6719,9 +6709,8 @@ msgstr "Tamaños de fecha iguales"
|
||||
msgid "Equal to (=)"
|
||||
msgstr "Igual a (=)"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Equals"
|
||||
msgstr "Secuencial"
|
||||
msgstr "Igual a"
|
||||
|
||||
msgid "Error"
|
||||
msgstr "Error"
|
||||
@@ -6982,13 +6971,11 @@ msgstr "Explorar el conjunto de resultados en la vista de exploración de datos"
|
||||
msgid "Export"
|
||||
msgstr "Exportar"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Export All Data"
|
||||
msgstr "Borrar todos los datos"
|
||||
msgstr "Exportar todos los datos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Export Current View"
|
||||
msgstr "Invertir página actual"
|
||||
msgstr "Exportar la vista actual"
|
||||
|
||||
msgid "Export Data to Excel"
|
||||
msgstr ""
|
||||
@@ -6996,9 +6983,8 @@ msgstr ""
|
||||
msgid "Export Images to Excel"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "Export YAML"
|
||||
msgstr "Nombre del informe"
|
||||
msgstr "Exportar YAML"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Export as Example"
|
||||
@@ -7339,16 +7325,14 @@ msgstr ""
|
||||
"Usando CSV como alternativa; la biblioteca de exportación a Excel no está"
|
||||
" disponible."
|
||||
|
||||
#, fuzzy
|
||||
msgid "False"
|
||||
msgstr "Es falso"
|
||||
msgstr "Falso"
|
||||
|
||||
msgid "Favorite"
|
||||
msgstr "Favorito"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Feature Not Enabled"
|
||||
msgstr "El túnel SSH no está habilitado"
|
||||
msgstr "Función no habilitada"
|
||||
|
||||
msgid "Featured"
|
||||
msgstr "Destacado"
|
||||
@@ -7922,9 +7906,8 @@ msgstr "Tamaño de la cuadrícula"
|
||||
msgid "Grid view"
|
||||
msgstr "Tamaño de la cuadrícula"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Group"
|
||||
msgstr "Agrupar por"
|
||||
msgstr "Grupo"
|
||||
|
||||
msgid "Group By"
|
||||
msgstr "Agrupar por"
|
||||
@@ -8232,9 +8215,8 @@ msgstr "En"
|
||||
msgid "In Progress"
|
||||
msgstr "Progreso"
|
||||
|
||||
#, fuzzy
|
||||
msgid "In Range"
|
||||
msgstr "Intervalo de tiempo"
|
||||
msgstr "En el intervalo"
|
||||
|
||||
msgid ""
|
||||
"In order to connect to non-public sheets you need to either provide a "
|
||||
@@ -10084,9 +10066,8 @@ msgstr "No se han conservado los ajustes del formulario"
|
||||
msgid "No global filters are currently added"
|
||||
msgstr "Actualmente no se han añadido filtros globales"
|
||||
|
||||
#, fuzzy
|
||||
msgid "No groups"
|
||||
msgstr "NO AGRUPADO POR"
|
||||
msgstr "Sin grupos"
|
||||
|
||||
#, fuzzy
|
||||
msgid "No groups yet"
|
||||
@@ -10226,9 +10207,8 @@ msgstr "Normalizar nombres de columnas"
|
||||
msgid "Normalized"
|
||||
msgstr "Normalizado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Not Contains"
|
||||
msgstr "Denunciar un contenido"
|
||||
msgstr "No contiene"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Not Equal"
|
||||
@@ -10402,9 +10382,8 @@ msgstr "OCT"
|
||||
msgid "OK"
|
||||
msgstr "OK"
|
||||
|
||||
#, fuzzy
|
||||
msgid "OR"
|
||||
msgstr "o"
|
||||
msgstr "O"
|
||||
|
||||
#. do-not-translate
|
||||
msgid "OVERWRITE"
|
||||
@@ -10878,9 +10857,8 @@ msgstr "Establecer intervalo de actualización automática"
|
||||
msgid "Pending"
|
||||
msgstr "pendiente"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Per user caching"
|
||||
msgstr "Porcentaje de cambio"
|
||||
msgstr "Caché por usuario"
|
||||
|
||||
msgid "Percent Change"
|
||||
msgstr "Porcentaje de cambio"
|
||||
@@ -11607,9 +11585,8 @@ msgstr "Consulta la"
|
||||
msgid "Referenced columns not available in DataFrame."
|
||||
msgstr "Las columnas referenciadas no están disponibles en el marco de datos."
|
||||
|
||||
#, fuzzy
|
||||
msgid "Referrer"
|
||||
msgstr "Actualizar"
|
||||
msgstr "Referrer"
|
||||
|
||||
msgid "Refetch results"
|
||||
msgstr "Volver a obtener resultados"
|
||||
@@ -11709,9 +11686,8 @@ msgstr "Eliminar"
|
||||
msgid "Remove System Dark Theme"
|
||||
msgstr "Eliminar tema oscuro del sistema"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Remove System Default Theme"
|
||||
msgstr "Actualizar los valores predeterminados"
|
||||
msgstr "Eliminar tema predeterminado del sistema"
|
||||
|
||||
msgid "Remove cross-filter"
|
||||
msgstr "Eliminar filtro cruzado"
|
||||
@@ -12310,9 +12286,8 @@ msgstr "Los parámetros del túnel SSH no son válidos."
|
||||
msgid "SSH Tunneling is not enabled"
|
||||
msgstr "El túnel SSH no está habilitado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "SSL"
|
||||
msgstr "sql"
|
||||
msgstr "SSL"
|
||||
|
||||
msgid "SSL Mode \"require\" will be used."
|
||||
msgstr "Se utilizará el modo SSL «requerido»."
|
||||
@@ -12638,9 +12613,8 @@ msgstr "Seguridad adicional"
|
||||
msgid "Security"
|
||||
msgstr "Seguridad"
|
||||
|
||||
#, fuzzy
|
||||
msgid "See "
|
||||
msgstr "series"
|
||||
msgstr "Ver "
|
||||
|
||||
#, python-format
|
||||
msgid "See all %(tableName)s"
|
||||
@@ -12668,9 +12642,8 @@ msgstr "Selecciona %s o escribe para buscar %s"
|
||||
msgid "Select ..."
|
||||
msgstr "Seleccionar …"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select All"
|
||||
msgstr "Deseleccionar todo"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select Database and Schema"
|
||||
@@ -12787,9 +12760,8 @@ msgstr "Selecciona un tipo de visualización"
|
||||
msgid "Select aggregate options"
|
||||
msgstr "Selecciona las opciones de agregación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Select all"
|
||||
msgstr "Deseleccionar todo"
|
||||
msgstr "Seleccionar todo"
|
||||
|
||||
msgid "Select all data"
|
||||
msgstr "Seleccionar todos los datos"
|
||||
@@ -13732,9 +13704,8 @@ msgstr "Muestra una lista de todas las series disponibles en ese momento"
|
||||
msgid "Shows or hides markers for the time series"
|
||||
msgstr "Muestra u oculta marcadores para la serie temporal"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Sign in"
|
||||
msgstr "No está en"
|
||||
msgstr "Iniciar sesión"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Sign in with"
|
||||
@@ -14158,13 +14129,11 @@ msgstr ""
|
||||
msgid "Started"
|
||||
msgstr "Iniciado"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starts With"
|
||||
msgstr "Anchura del gráfico"
|
||||
msgstr "Empieza con"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Starts with (ILIKE x%)"
|
||||
msgstr "Anchura del gráfico"
|
||||
msgstr "Empieza con (ILIKE x%)"
|
||||
|
||||
msgid "State"
|
||||
msgstr "Estado"
|
||||
@@ -17085,9 +17054,8 @@ msgstr "Activar alerta si..."
|
||||
msgid "Trigger now"
|
||||
msgstr ""
|
||||
|
||||
#, fuzzy
|
||||
msgid "True"
|
||||
msgstr "MAR"
|
||||
msgstr "Verdadero"
|
||||
|
||||
msgid "Truncate Axis"
|
||||
msgstr ""
|
||||
@@ -17248,17 +17216,14 @@ msgstr ""
|
||||
"estén configurados: «bigquery.readsessions.create», "
|
||||
"«bigquery.readsessions.getData»."
|
||||
|
||||
#, fuzzy
|
||||
msgid ""
|
||||
"Unable to connect. Verify that the following roles are set on the service"
|
||||
" account: \"Cloud Datastore Viewer\", \"Cloud Datastore User\", \"Cloud "
|
||||
"Datastore Creator\""
|
||||
msgstr ""
|
||||
"No se ha podido conectar. Comprueba si los siguientes roles están "
|
||||
"configurados en la cuenta de servicio: «BigQuery Data Viewer», «BigQuery "
|
||||
"Metadata Viewer», «BigQuery Job User»; y que los siguientes permisos "
|
||||
"estén configurados: «bigquery.readsessions.create», "
|
||||
"«bigquery.readsessions.getData»."
|
||||
"No se ha podido conectar. Comprueba que los siguientes roles estén "
|
||||
"configurados en la cuenta de servicio: «Cloud Datastore Viewer», «Cloud "
|
||||
"Datastore User», «Cloud Datastore Creator»"
|
||||
|
||||
msgid "Unable to create chart without a query id."
|
||||
msgstr "No se puede crear un gráfico sin un ID de consulta."
|
||||
@@ -17800,9 +17765,8 @@ msgstr "Valor"
|
||||
msgid "Value Aggregation"
|
||||
msgstr "Agregación"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Value Columns"
|
||||
msgstr "Columnas de la tabla"
|
||||
msgstr "Columnas de valores"
|
||||
|
||||
msgid "Value Domain"
|
||||
msgstr "Dominio del valor"
|
||||
@@ -19142,9 +19106,8 @@ msgstr "Su sesión ha finalizado. Por favor, inicie sesión de nuevo."
|
||||
msgid "Your user information"
|
||||
msgstr "Información general"
|
||||
|
||||
#, fuzzy
|
||||
msgid "Z-A"
|
||||
msgstr "clave z-a"
|
||||
msgstr "Z-A"
|
||||
|
||||
msgid "ZIP file contains multiple file types"
|
||||
msgstr "El archivo ZIP contiene varios tipos de archivos"
|
||||
@@ -20139,9 +20102,8 @@ msgstr "Hora/fecha"
|
||||
msgid "timestamp"
|
||||
msgstr "Mostrar marca de tiempo"
|
||||
|
||||
#, fuzzy
|
||||
msgid "to"
|
||||
msgstr "arriba"
|
||||
msgstr "a"
|
||||
|
||||
msgid "top"
|
||||
msgstr "arriba"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+25
-1
@@ -61,6 +61,7 @@ from typing import (
|
||||
)
|
||||
from urllib.parse import unquote_plus, urlparse
|
||||
from zipfile import ZipFile
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
import markdown as md
|
||||
import nh3
|
||||
@@ -1965,6 +1966,7 @@ class DateColumn:
|
||||
timestamp_format: str | None = None
|
||||
offset: int | None = None
|
||||
time_shift: str | None = None
|
||||
timezone: str | None = None # IANA timezone name
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self.col_label)
|
||||
@@ -1978,11 +1980,13 @@ class DateColumn:
|
||||
timestamp_format: str | None,
|
||||
offset: int | None,
|
||||
time_shift: str | None,
|
||||
timezone: str | None = None,
|
||||
) -> DateColumn:
|
||||
return cls(
|
||||
timestamp_format=timestamp_format,
|
||||
offset=offset,
|
||||
time_shift=time_shift,
|
||||
timezone=timezone,
|
||||
col_label=DTTM_ALIAS,
|
||||
)
|
||||
|
||||
@@ -2080,8 +2084,28 @@ def normalize_dttm_col(
|
||||
|
||||
_process_datetime_column(df, _col)
|
||||
|
||||
if _col.offset:
|
||||
if _col.timezone and isinstance(_col.timezone, str):
|
||||
try:
|
||||
tz = ZoneInfo(_col.timezone)
|
||||
# Data is stored in UTC, convert to the dataset's configured timezone
|
||||
# First make the datetime UTC-aware, then convert to target timezone
|
||||
series = df[_col.col_label]
|
||||
if not series.empty and series.notna().any():
|
||||
# Convert UTC to target timezone
|
||||
df[_col.col_label] = (
|
||||
series.dt.tz_localize("UTC")
|
||||
.dt.tz_convert(tz)
|
||||
.dt.tz_localize(None) # Remove timezone info for display
|
||||
)
|
||||
except ZoneInfoNotFoundError:
|
||||
logging.warning(
|
||||
"Unknown timezone '%s', falling back to offset", _col.timezone
|
||||
)
|
||||
if _col.offset:
|
||||
df[_col.col_label] += timedelta(hours=_col.offset)
|
||||
elif _col.offset:
|
||||
df[_col.col_label] += timedelta(hours=_col.offset)
|
||||
|
||||
if _col.time_shift is not None:
|
||||
df[_col.col_label] += parse_human_timedelta(_col.time_shift)
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ NUMPY_FUNCTIONS: dict[str, Callable[..., Any]] = {
|
||||
"min": np.min,
|
||||
"percentile": np.percentile,
|
||||
"prod": np.prod,
|
||||
"product": np.product,
|
||||
"product": np.prod,
|
||||
"std": np.std,
|
||||
"sum": np.sum,
|
||||
"var": np.var,
|
||||
|
||||
@@ -636,6 +636,7 @@ def cached_common_bootstrap_data( # pylint: disable=unused-argument
|
||||
"extra_categorical_color_schemes": app.config[
|
||||
"EXTRA_CATEGORICAL_COLOR_SCHEMES"
|
||||
],
|
||||
"extra_theme_tokens": app.config["EXTRA_THEME_TOKENS"],
|
||||
"menu_data": menu_data(g.user),
|
||||
"pdf_compression_level": app.config["PDF_COMPRESSION_LEVEL"],
|
||||
"user_subject_id": _get_user_subject_id(user_id),
|
||||
|
||||
@@ -178,6 +178,20 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
metadata = yaml.safe_load(contents["datasets/examples/birth_names.yaml"]())
|
||||
metadata.pop("uuid")
|
||||
|
||||
# Datasets in a database bundle are exported with ``export_uuids=True``,
|
||||
# so every column/metric carries a ``uuid`` so that custom folder
|
||||
# references survive the round trip. They are assigned dynamically, so
|
||||
# build lookups by name.
|
||||
birth_names = next(
|
||||
table for table in example_db.tables if table.table_name == "birth_names"
|
||||
)
|
||||
column_uuid_map = {
|
||||
column.column_name: str(column.uuid) for column in birth_names.columns
|
||||
}
|
||||
metric_uuid_map = {
|
||||
metric.metric_name: str(metric.uuid) for metric in birth_names.metrics
|
||||
}
|
||||
|
||||
metadata["columns"].sort(key=lambda x: x["column_name"])
|
||||
expected_metadata = {
|
||||
"cache_timeout": None,
|
||||
@@ -194,6 +208,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": ds_type,
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["ds"],
|
||||
},
|
||||
{
|
||||
"column_name": "gender",
|
||||
@@ -207,6 +222,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": "STRING" if example_db.backend == "hive" else "VARCHAR(16)",
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["gender"],
|
||||
},
|
||||
{
|
||||
"column_name": "name",
|
||||
@@ -222,6 +238,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
),
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["name"],
|
||||
},
|
||||
{
|
||||
"column_name": "num",
|
||||
@@ -235,6 +252,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": big_int_type,
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["num"],
|
||||
},
|
||||
{
|
||||
"column_name": "num_california",
|
||||
@@ -248,6 +266,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": None,
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["num_california"],
|
||||
},
|
||||
{
|
||||
"column_name": "state",
|
||||
@@ -261,6 +280,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": "STRING" if example_db.backend == "hive" else "VARCHAR(10)",
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["state"],
|
||||
},
|
||||
{
|
||||
"column_name": "num_boys",
|
||||
@@ -274,6 +294,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": big_int_type,
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["num_boys"],
|
||||
},
|
||||
{
|
||||
"column_name": "num_girls",
|
||||
@@ -287,6 +308,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"type": big_int_type,
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"uuid": column_uuid_map["num_girls"],
|
||||
},
|
||||
],
|
||||
"database_uuid": str(db_uuid),
|
||||
@@ -306,6 +328,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"metric_type": "count",
|
||||
"verbose_name": "COUNT(*)",
|
||||
"warning_text": None,
|
||||
"uuid": metric_uuid_map["count"],
|
||||
},
|
||||
{
|
||||
"d3format": None,
|
||||
@@ -316,6 +339,7 @@ class TestExportDatabasesCommand(SupersetTestCase):
|
||||
"metric_type": None,
|
||||
"verbose_name": None,
|
||||
"warning_text": None,
|
||||
"uuid": metric_uuid_map["sum__num"],
|
||||
},
|
||||
],
|
||||
"offset": 0,
|
||||
|
||||
@@ -95,6 +95,14 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
type_map = {
|
||||
column.column_name: str(column.type) for column in example_dataset.columns
|
||||
}
|
||||
# column/metric UUIDs are exported so folder references survive import;
|
||||
# they are assigned dynamically, so build lookups by name.
|
||||
column_uuid_map = {
|
||||
column.column_name: str(column.uuid) for column in example_dataset.columns
|
||||
}
|
||||
metric_uuid_map = {
|
||||
metric.metric_name: str(metric.uuid) for metric in example_dataset.metrics
|
||||
}
|
||||
|
||||
assert metadata == {
|
||||
"cache_timeout": None,
|
||||
@@ -115,6 +123,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"extra": None,
|
||||
"uuid": column_uuid_map["source"],
|
||||
},
|
||||
{
|
||||
"column_name": "target",
|
||||
@@ -127,6 +136,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
"is_dttm": False,
|
||||
"python_date_format": None,
|
||||
"type": type_map["target"],
|
||||
"uuid": column_uuid_map["target"],
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"extra": None,
|
||||
@@ -145,6 +155,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
"advanced_data_type": None,
|
||||
"verbose_name": None,
|
||||
"extra": None,
|
||||
"uuid": column_uuid_map["value"],
|
||||
},
|
||||
],
|
||||
"database_uuid": str(example_db.uuid),
|
||||
@@ -165,6 +176,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
"metric_type": "count",
|
||||
"verbose_name": "COUNT(*)",
|
||||
"warning_text": None,
|
||||
"uuid": metric_uuid_map["count"],
|
||||
},
|
||||
{
|
||||
"currency": None,
|
||||
@@ -176,6 +188,7 @@ class TestExportDatasetsCommand(SupersetTestCase):
|
||||
"metric_type": None,
|
||||
"verbose_name": None,
|
||||
"warning_text": None,
|
||||
"uuid": metric_uuid_map["sum__value"],
|
||||
},
|
||||
],
|
||||
"folders": None,
|
||||
|
||||
@@ -19,9 +19,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
from typing import Callable
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from superset import db
|
||||
from superset.commands.deletion_retention import audit
|
||||
from superset.commands.deletion_retention.audit import PurgeAuditLog
|
||||
@@ -32,6 +36,31 @@ from ._base import DeletionRetentionTestBase
|
||||
|
||||
|
||||
class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
def _get_audit_record(self, record_id: UUID) -> PurgeAuditLog:
|
||||
record: PurgeAuditLog | None = db.session.get(PurgeAuditLog, record_id)
|
||||
assert record is not None
|
||||
return record
|
||||
|
||||
def _write_retention_record(
|
||||
self,
|
||||
*,
|
||||
entity_uuid: str | None,
|
||||
entity_type: str = "slices",
|
||||
created_on: datetime | None = None,
|
||||
) -> UUID:
|
||||
record_id: UUID | None = audit.write_ahead(
|
||||
trigger=audit.TRIGGER_RETENTION,
|
||||
actor=audit.ACTOR_SYSTEM,
|
||||
entity_type=entity_type,
|
||||
entity_uuid=entity_uuid,
|
||||
)
|
||||
assert record_id is not None
|
||||
if created_on is not None:
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
record.created_on = created_on
|
||||
db.session.commit()
|
||||
return record_id
|
||||
|
||||
def test_write_ahead_then_confirm(self) -> None:
|
||||
"""A purge writes a pending audit row up front and flips it to
|
||||
confirmed after the delete commits."""
|
||||
@@ -160,3 +189,336 @@ class TestPurgeAudit(DeletionRetentionTestBase):
|
||||
row = db.session.query(PurgeAuditLog).filter_by(id=record_id).one()
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
assert row.removed_dashboard_slices == 0
|
||||
|
||||
def test_first_retention_block_is_retained(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="first-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "retained"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_repeated_retention_block_suppresses_current_provisional(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
second_id: UUID = self._write_retention_record(entity_uuid="repeat-block")
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
)
|
||||
|
||||
assert disposition == "suppressed"
|
||||
first: PurgeAuditLog = self._get_audit_record(first_id)
|
||||
assert first.status == audit.STATUS_BLOCKED
|
||||
assert db.session.get(PurgeAuditLog, second_id) is None
|
||||
|
||||
def test_equal_timestamp_is_ambiguous_and_retains_current(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
first_id: UUID = self._write_retention_record(
|
||||
entity_uuid="equal-time", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
second_id: UUID = self._write_retention_record(
|
||||
entity_uuid="equal-time", created_on=timestamp
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(second_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
second: PurgeAuditLog = self._get_audit_record(second_id)
|
||||
assert second.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_tied_mixed_predecessors_are_ambiguous_and_retain_current(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
blocked_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-tie", created_on=timestamp
|
||||
)
|
||||
audit.finalize(blocked_id, audit.STATUS_BLOCKED)
|
||||
failed_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-tie", created_on=timestamp
|
||||
)
|
||||
audit.finalize(failed_id, audit.STATUS_FAILED)
|
||||
current_id: UUID = self._write_retention_record(
|
||||
entity_uuid="mixed-tie", created_on=timestamp + timedelta(seconds=1)
|
||||
)
|
||||
session: Session = audit._dedicated_session()
|
||||
try:
|
||||
current: PurgeAuditLog | None = session.get(PurgeAuditLog, current_id)
|
||||
assert current is not None
|
||||
predecessor: PurgeAuditLog | None = audit._retention_predecessor(
|
||||
session, current
|
||||
)
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert predecessor is None
|
||||
assert disposition == "retained"
|
||||
retained: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert retained.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_newer_concurrent_row_does_not_become_a_predecessor(self) -> None:
|
||||
current_time: datetime = datetime.utcnow()
|
||||
current_id: UUID = self._write_retention_record(
|
||||
entity_uuid="overlap", created_on=current_time
|
||||
)
|
||||
newer_id: UUID = self._write_retention_record(
|
||||
entity_uuid="overlap", created_on=current_time + timedelta(seconds=1)
|
||||
)
|
||||
audit.finalize_retention_blocked(newer_id)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert current.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_pending_predecessor_retains_current_block(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
self._write_retention_record(entity_uuid="pending-prior", created_on=timestamp)
|
||||
current_id: UUID = self._write_retention_record(
|
||||
entity_uuid="pending-prior", created_on=timestamp + timedelta(seconds=1)
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
assert disposition == "retained"
|
||||
|
||||
def test_null_uuid_retains_blocked_evidence(self) -> None:
|
||||
null_id: UUID = self._write_retention_record(entity_uuid=None)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(null_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(null_id)
|
||||
assert disposition == "retained"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
assert record.removed_dashboard_slices == 0
|
||||
|
||||
def test_same_uuid_across_entity_types_does_not_suppress(self) -> None:
|
||||
chart_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="slices"
|
||||
)
|
||||
audit.finalize_retention_blocked(chart_id)
|
||||
dashboard_id: UUID = self._write_retention_record(
|
||||
entity_uuid="shared-type", entity_type="dashboards"
|
||||
)
|
||||
|
||||
dashboard_disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(dashboard_id)
|
||||
)
|
||||
|
||||
assert dashboard_disposition == "retained"
|
||||
|
||||
def test_completed_current_record_is_immutable(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="completed")
|
||||
audit.fail(record_id)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "retained"
|
||||
assert record.status == audit.STATUS_FAILED
|
||||
|
||||
def test_predecessor_lookup_failure_recovers_blocked_evidence(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="lookup-failure")
|
||||
|
||||
with patch(
|
||||
"superset.commands.deletion_retention.audit._retention_predecessor",
|
||||
side_effect=audit.SQLAlchemyError("lookup failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_suppression_delete_failure_recovers_blocked_evidence(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="delete-failure")
|
||||
|
||||
with patch(
|
||||
"superset.commands.deletion_retention.audit._suppress_redundant_block",
|
||||
side_effect=audit.SQLAlchemyError("delete failed"),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_commit_failure_recovers_blocked_evidence(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="commit-failure")
|
||||
primary_session: Session = audit._dedicated_session()
|
||||
recovery_session: Session = audit._dedicated_session()
|
||||
with (
|
||||
patch.object(
|
||||
primary_session,
|
||||
"commit",
|
||||
side_effect=audit.SQLAlchemyError("commit failed"),
|
||||
),
|
||||
patch(
|
||||
"superset.commands.deletion_retention.audit._dedicated_session",
|
||||
side_effect=[primary_session, recovery_session],
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_uncertain_suppression_commit_recreates_absent_evidence(self) -> None:
|
||||
first_id: UUID = self._write_retention_record(entity_uuid="absent-current")
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid="absent-current")
|
||||
primary_session: Session = audit._dedicated_session()
|
||||
recovery_session: Session = audit._dedicated_session()
|
||||
primary_commit: Callable[[], None] = primary_session.commit
|
||||
|
||||
def commit_then_raise() -> None:
|
||||
primary_commit()
|
||||
raise audit.SQLAlchemyError("commit acknowledgement lost")
|
||||
|
||||
with (
|
||||
patch.object(primary_session, "commit", side_effect=commit_then_raise),
|
||||
patch(
|
||||
"superset.commands.deletion_retention.audit._dedicated_session",
|
||||
side_effect=[primary_session, recovery_session],
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_failed_fallback_leaves_pending_evidence_for_reconciliation(self) -> None:
|
||||
record_id: UUID = self._write_retention_record(entity_uuid="fallback-failure")
|
||||
primary_session: Session = audit._dedicated_session()
|
||||
recovery_session: Session = audit._dedicated_session()
|
||||
with (
|
||||
patch.object(
|
||||
primary_session,
|
||||
"commit",
|
||||
side_effect=audit.SQLAlchemyError("primary commit failed"),
|
||||
),
|
||||
patch.object(
|
||||
recovery_session,
|
||||
"commit",
|
||||
side_effect=audit.SQLAlchemyError("recovery commit failed"),
|
||||
),
|
||||
patch(
|
||||
"superset.commands.deletion_retention.audit._dedicated_session",
|
||||
side_effect=[primary_session, recovery_session],
|
||||
),
|
||||
):
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(record_id)
|
||||
)
|
||||
|
||||
record: PurgeAuditLog = self._get_audit_record(record_id)
|
||||
assert disposition == "fallback"
|
||||
assert record.status == audit.STATUS_PENDING
|
||||
|
||||
def test_indeterminate_delete_rowcount_forces_persistence_recovery(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
current: PurgeAuditLog = PurgeAuditLog(
|
||||
id=UUID("00000000-0000-0000-0000-000000000002"),
|
||||
status=audit.STATUS_PENDING,
|
||||
trigger=audit.TRIGGER_RETENTION,
|
||||
actor=audit.ACTOR_SYSTEM,
|
||||
entity_type="slices",
|
||||
entity_uuid="indeterminate-rowcount",
|
||||
created_on=timestamp,
|
||||
)
|
||||
predecessor: PurgeAuditLog = PurgeAuditLog(
|
||||
id=UUID("00000000-0000-0000-0000-000000000001"),
|
||||
status=audit.STATUS_BLOCKED,
|
||||
trigger=audit.TRIGGER_RETENTION,
|
||||
actor=audit.ACTOR_SYSTEM,
|
||||
entity_type="slices",
|
||||
entity_uuid="indeterminate-rowcount",
|
||||
created_on=timestamp - timedelta(seconds=1),
|
||||
)
|
||||
result: MagicMock = MagicMock(rowcount=-1)
|
||||
session: MagicMock = MagicMock()
|
||||
session.execute.return_value = result
|
||||
|
||||
with pytest.raises(audit.SQLAlchemyError, match="indeterminate"):
|
||||
audit._suppress_redundant_block(session, current, predecessor)
|
||||
|
||||
def test_overlap_duplicates_do_not_cause_unbounded_sequential_growth(self) -> None:
|
||||
timestamp: datetime = datetime.utcnow()
|
||||
first_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(first_id)
|
||||
overlap_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap", created_on=timestamp
|
||||
)
|
||||
audit.finalize_retention_blocked(overlap_id)
|
||||
later_id: UUID = self._write_retention_record(
|
||||
entity_uuid="bounded-overlap",
|
||||
created_on=timestamp + timedelta(seconds=1),
|
||||
)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(later_id)
|
||||
)
|
||||
|
||||
retained_count: int = (
|
||||
db.session.query(PurgeAuditLog)
|
||||
.filter_by(entity_uuid="bounded-overlap")
|
||||
.count()
|
||||
)
|
||||
assert disposition == "suppressed"
|
||||
assert retained_count == 2
|
||||
|
||||
def test_meaningful_outcome_transitions_start_new_blocked_periods(self) -> None:
|
||||
transition: tuple[int, str]
|
||||
for transition in enumerate(
|
||||
(
|
||||
audit.STATUS_FAILED,
|
||||
audit.STATUS_CONFIRMED,
|
||||
audit.STATUS_TARGET_ABSENT,
|
||||
)
|
||||
):
|
||||
index: int = transition[0]
|
||||
status: str = transition[1]
|
||||
entity_uuid: str = f"transition-{index}"
|
||||
prior_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
|
||||
audit.finalize(prior_id, status)
|
||||
current_id: UUID = self._write_retention_record(entity_uuid=entity_uuid)
|
||||
|
||||
disposition: audit.RetentionBlockedDisposition = (
|
||||
audit.finalize_retention_blocked(current_id)
|
||||
)
|
||||
|
||||
current: PurgeAuditLog = self._get_audit_record(current_id)
|
||||
assert disposition == "retained"
|
||||
assert current.status == audit.STATUS_BLOCKED
|
||||
|
||||
@@ -24,6 +24,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from superset import db
|
||||
from superset.commands.deletion_retention import audit
|
||||
from superset.commands.deletion_retention.audit import PurgeAuditLog
|
||||
from superset.commands.deletion_retention.force_purge import (
|
||||
AmbiguousPurgeTargetError,
|
||||
@@ -33,6 +34,7 @@ from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.tasks.deletion_retention import _purge_impl
|
||||
|
||||
from ._base import DeletionRetentionTestBase
|
||||
|
||||
@@ -145,6 +147,36 @@ class TestForcePurge(DeletionRetentionTestBase):
|
||||
"associated alerts or reports exist",
|
||||
)
|
||||
|
||||
def test_force_block_does_not_change_scheduled_deduplication_stream(self) -> None:
|
||||
chart: Slice = self.make_chart("independent_block_streams")
|
||||
report: ReportSchedule = ReportSchedule(
|
||||
type="Report",
|
||||
name="retention_it_independent_block_streams",
|
||||
crontab="0 0 * * *",
|
||||
chart=chart,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
chart_uuid: str = str(chart.uuid)
|
||||
self.soft_delete(chart, days_ago=90)
|
||||
|
||||
first_scheduled: dict[str, object] = _purge_impl(30, dry_run=False)
|
||||
force_result: dict[str, object] = ForcePurgeCommand(chart_uuid).run()
|
||||
second_scheduled: dict[str, object] = _purge_impl(30, dry_run=False)
|
||||
|
||||
records: list[PurgeAuditLog] = (
|
||||
db.session.query(PurgeAuditLog).filter_by(entity_uuid=chart_uuid).all()
|
||||
)
|
||||
assert first_scheduled["blocked_by_reference"] == 1
|
||||
assert force_result["reason"] == "blocked"
|
||||
assert second_scheduled["blocked_by_reference"] == 1
|
||||
assert sorted((record.trigger, record.status) for record in records) == sorted(
|
||||
[
|
||||
(audit.TRIGGER_RETENTION, audit.STATUS_BLOCKED),
|
||||
(audit.TRIGGER_FORCE, audit.STATUS_BLOCKED),
|
||||
]
|
||||
)
|
||||
|
||||
def test_force_purge_refuses_an_ambiguous_uuid(self) -> None:
|
||||
"""A UUID matching two entity types is refused, not guessed.
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ from superset.models.slice import Slice
|
||||
from superset.models.user_attributes import UserAttribute
|
||||
from superset.reports.models import ReportSchedule
|
||||
from superset.tags.models import ObjectType, Tag, TaggedObject
|
||||
from superset.tasks import deletion_retention as deletion_retention_task
|
||||
from superset.tasks.deletion_retention import _purge_impl
|
||||
|
||||
from ._base import DeletionRetentionTestBase
|
||||
@@ -276,6 +277,80 @@ class TestSoftDeletePurge(DeletionRetentionTestBase):
|
||||
)
|
||||
assert row.status == audit.STATUS_BLOCKED
|
||||
|
||||
def test_repeated_report_blocker_preserves_counts_and_suppresses_noise(
|
||||
self,
|
||||
) -> None:
|
||||
chart: Slice = self.make_chart("reported_repeatedly")
|
||||
report: ReportSchedule = ReportSchedule(
|
||||
type="Report",
|
||||
name="retention_it_repeated_report",
|
||||
crontab="0 0 * * *",
|
||||
chart=chart,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
chart_uuid: str = str(chart.uuid)
|
||||
self.soft_delete(chart, days_ago=90)
|
||||
|
||||
incr: MagicMock
|
||||
gauge: MagicMock
|
||||
with (
|
||||
patch.object(
|
||||
deletion_retention_task.stats_logger_manager.instance, "incr"
|
||||
) as incr,
|
||||
patch.object(
|
||||
deletion_retention_task.stats_logger_manager.instance, "gauge"
|
||||
) as gauge,
|
||||
):
|
||||
first_result: dict[str, Any] = _purge(window=30)
|
||||
second_result: dict[str, Any] = _purge(window=30)
|
||||
|
||||
assert first_result["blocked_by_reference"] == 1
|
||||
assert second_result["blocked_by_reference"] == 1
|
||||
assert (
|
||||
db.session.query(audit.PurgeAuditLog)
|
||||
.filter_by(
|
||||
entity_uuid=chart_uuid,
|
||||
trigger=audit.TRIGGER_RETENTION,
|
||||
status=audit.STATUS_BLOCKED,
|
||||
)
|
||||
.count()
|
||||
== 1
|
||||
)
|
||||
incr.assert_called_once_with("deletion_retention.blocked_audit_suppressed")
|
||||
assert gauge.call_count == 2
|
||||
gauge.assert_called_with("deletion_retention.blocked_by_reference", 1)
|
||||
|
||||
def test_blocked_audit_fallback_is_counted_without_changing_task_result(
|
||||
self,
|
||||
) -> None:
|
||||
chart: Slice = self.make_chart("reported_fallback")
|
||||
report: ReportSchedule = ReportSchedule(
|
||||
type="Report",
|
||||
name="retention_it_fallback_report",
|
||||
crontab="0 0 * * *",
|
||||
chart=chart,
|
||||
)
|
||||
db.session.add(report)
|
||||
db.session.commit()
|
||||
self.soft_delete(chart, days_ago=90)
|
||||
|
||||
incr: MagicMock
|
||||
with (
|
||||
patch.object(
|
||||
audit,
|
||||
"finalize_retention_blocked",
|
||||
return_value="fallback",
|
||||
),
|
||||
patch.object(
|
||||
deletion_retention_task.stats_logger_manager.instance, "incr"
|
||||
) as incr,
|
||||
):
|
||||
result: dict[str, Any] = _purge(window=30)
|
||||
|
||||
assert result["blocked_by_reference"] == 1
|
||||
incr.assert_called_once_with("deletion_retention.blocked_audit_dedupe_fallback")
|
||||
|
||||
def test_restrictive_fk_blocks_dashboard_without_rewriting_referrer(self) -> None:
|
||||
"""A welcome-dashboard FK remains authoritative during retention."""
|
||||
dashboard = self.make_dashboard("welcome")
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user