Compare commits

..
Author SHA1 Message Date
Elizabeth Thompson b4fac82c39 fix(explore): catch TemplateError when validating access for query-backed form_data
check_query_access() calls raise_for_access(query=query), which Jinja-renders
the query's SQL to resolve table references. A malformed template surfaces
as a raw jinja2.exceptions.TemplateError instead of a Superset exception,
leaking as an opaque 500 from the explore form_data endpoints (used by the
chart Explore/Drill-by cache) whenever datasource_type=query.

Wrap the call and re-raise as the existing SupersetTemplateException (422),
matching the same conversion already used in datasets/api.py, and map it to
a proper response in ExploreFormDataRestApi's four handlers.
2026-08-24 16:41:44 +00:00
174 changed files with 3004 additions and 10815 deletions
@@ -105,7 +105,6 @@ jobs:
tool: customSmallerIsBetter
output-file-path: bundle-size-summary.json
external-data-json-path: bundle-size-history.json
github-token: ${{ secrets.GITHUB_TOKEN }}
fail-on-alert: false
summary-always: true
@@ -53,6 +53,12 @@ jobs:
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
env:
PYTHONPATH: ${{ github.workspace }}
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
# errors via pytest.ini's `filterwarnings` to actually run in CI, so a
# regression on those fails the build instead of relying on a
# contributor remembering to set this locally. See the migration
# battleplan: https://github.com/apache/superset/discussions/40273
SQLALCHEMY_WARN_20: "1"
steps:
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+12 -19
View File
@@ -58,8 +58,6 @@ the old counter to use the outcome-specific replacements.
- [42930](https://github.com/apache/superset/pull/42930): Dataset import data-URI fetches no longer honor an HTTP(S) proxy when `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS` is `False` (the default): the connection is now made directly to the destination so the peer-address check validates the real target instead of a proxy's. Deployments that require an egress proxy to reach legitimate external data URLs for dataset import should set `DATASET_IMPORT_ALLOW_INTERNAL_DATA_URLS = True` or otherwise ensure those URLs resolve without one.
- [42935](https://github.com/apache/superset/pull/42935): The MCP service now refuses to start (`MCPAuthConfigError`) when `MCP_JWT_ISSUER` trusts more than one issuer and no `MCP_USER_RESOLVER` is configured, instead of only logging a warning. This was already a documented misconfiguration (the default resolver isn't issuer-scoped, so distinct trusted issuers minting the same username/email would resolve to the same Superset user); deployments trusting multiple issuers must configure an `MCP_USER_RESOLVER` that derives its identity from the token's `iss` claim before upgrading. Single-issuer deployments are unaffected.
- [42429](https://github.com/apache/superset/pull/42429): The Country Map chart's Iran GeoJSON now gives Alborz province its own ISO 3166-2 code, `IR-32`, instead of `IR-30`. `ISO` is the join key used to color/filter provinces on this chart, so any existing dataset keyed on `IR-30` for Alborz will silently stop matching after upgrading; re-key that data to `IR-32`.
- [43388](https://github.com/apache/superset/pull/43388): The MCP service now refuses to start (`MCPAuthConfigError`) if `MCP_DEV_USERNAME` and `MCP_AUTH_ENABLED = True` are both set, and separately if `MCP_AUTH_ENABLED = True` but no usable JWT key material is configured (RSA key/JWKS, or an explicit `MCP_JWT_SECRET` for HMAC) — both previously started with authentication silently weaker than configured. Deployments combining a dev-mode username with JWT auth enabled, or enabling JWT auth without key material, must pick one before upgrading: unset `MCP_DEV_USERNAME` for a real auth deployment, or unset `MCP_AUTH_ENABLED` (or configure the key material) for a dev-mode one. Response caching (`MCP_CACHE_CONFIG["enabled"] = True`) now also excludes every tool with a side effect by default, not only a partial list, so a previously-cached mutating tool call is no longer served from cache; no config change is needed to pick this up.
- [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.
- [42300](https://github.com/apache/superset/pull/42300): Timeseries charts (line/area/bar) with a Y-axis bound in effect — either an explicit `yAxisBounds` or one derived from `truncateYAxis` — now clamp out-of-range data points to that bound instead of letting ECharts drop the point (and the line segments around it) entirely. Any existing chart with a configured Y-axis bound and data outside it will look different after upgrading: a gap becomes a point pinned to the boundary. The clamp also rewrites the value ECharts reads for that point's tooltip and data label, so the displayed value is the bound rather than the true observation.
- [42087](https://github.com/apache/superset/pull/42087): Stored calculated-column and metric expressions are validated when a query is built, under the same sub-query policy already applied to adhoc expressions. Previously only the dataset update path checked them on save, so expressions written by v1 import, by dataset duplication, or before that check existed were never validated. Since `ALLOW_ADHOC_SUBQUERY` defaults to `False` (see [19242](https://github.com/apache/superset/pull/19242)), a dataset whose stored expression contains a sub-query works before upgrading and afterwards fails at chart render with `Custom SQL fields cannot contain sub-queries.` There is no migration step, and the error does not name the offending dataset column, so audit stored expressions before upgrading: either rewrite them without the sub-query, or set `ALLOW_ADHOC_SUBQUERY = True` to keep the previous behaviour for both stored and adhoc expressions.
@@ -185,12 +183,10 @@ misrepresents the entity as unchanged.
- **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).
- **Check a replaced `CELERY_CONFIG`.** Carry both the
`superset.tasks.version_history_retention` import and the
`version_history.prune_old_versions` beat entry; see
[Version-history retention (pruning)](#version-history-retention-pruning) for
the startup-warning behavior.
`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.
@@ -199,10 +195,7 @@ misrepresents the entity as unchanged.
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. Restore is unavailable (404) while
capture is off. A full rollback also sets
`FEATURE_FLAGS = {"VERSION_HISTORY": False}` to hide the panel — capture off
with the panel left on shows an empty or stale history.
readable and no entity state is altered.
### Scheduled report execution now enforces one application deadline
@@ -664,9 +657,9 @@ ALTER TABLE tagged_object DROP CONSTRAINT <constraint_name>;
ALTER TABLE tagged_object DROP FOREIGN KEY <constraint_name>;
```
### Entity version-history infrastructure
### Entity version-history infrastructure (gated off by default)
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. Capture is governed by the `ENABLE_VERSIONING_CAPTURE` config value — an operational kill-switch (a release toggle that became a permanent ops switch), not a feature flag; see "Version history is on by default" above for the shipped default. With capture off, no save writes version rows; the endpoints continue to serve already-captured rows read-only. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
Introduces the schema and SQLAlchemy-Continuum wiring that captures version history for charts, dashboards, and datasets, plus read-only `GET /api/v1/{chart,dashboard,dataset}/<uuid>/versions/` endpoints. This ships **inert**: a new config flag `ENABLE_VERSIONING_CAPTURE` defaults to `False`, so no save writes any version rows and the endpoints return empty. It is an operational kill-switch (a release toggle that becomes a permanent ops switch), not a feature flag set it to `True` to enable capture once validated. The migration is additive; existing entity `PUT` responses gain `old_version_uuid` / `new_version_uuid` body fields and an `ETag` header (both null/absent when capture is off).
A few save- and import-path internals change **unconditionally** (independent of the flag), because the versioned mappers must behave correctly whether or not capture is enabled:
@@ -687,7 +680,7 @@ A read-only companion to the version-history endpoints: each entity type gains a
| `q` | string | — | Case-insensitive search over the full history, applied before pagination (so `count` reflects matches) |
| `page` / `page_size` | integer | `0` / `25` | Pagination (`page_size` clamped to 200) |
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream reflects captured history; with capture off it remains readable but stops accruing new entries.
Authorization reuses the resource's `can_read` permission and per-object `raise_for_access`; related-entity rows are visibility-filtered to what the caller may see. The stream is empty unless version capture is on (`ENABLE_VERSIONING_CAPTURE`).
### Version-history retention (pruning)
@@ -707,7 +700,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 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. Blocked audit records carry a stable machine-readable `reason` code (`report_schedule`, `user_attribute`, or `cascade_integrity_failure` for an unexpected cascade failure caused by a database integrity constraint) so the audit table alone answers why an entity was not purged; records finalized before the column existed keep a NULL reason. Apply the migration before rolling out the new code: the audit model declares the column, so a worker on the new code with an un-migrated table fails its write-ahead write and the scheduled purge fails closed until the migration lands. During a rolling deploy, workers still on the old code write reason-less blocked rows and suppress on status alone; both effects are self-healing, since a NULL-reason record never matches a reason code and the next all-new-code run re-anchors the entity. Consecutive scheduled evaluations blocked with the same status **and reason** suppress only the redundant current provisional record — a reason change writes one new blocked record carrying the new code; completed outcomes, outcome transitions, and every force-purge attempt remain independent and immutable. Retained transition records are not automatically expired, so entities whose block reason changes repeatedly can accumulate multiple audit rows. 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.
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
@@ -903,7 +896,7 @@ The migration is transactional (all-or-nothing) and idempotent — it can be saf
### Soft delete and restore for datasets
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dataset/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**The soft-delete behavior in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dataset/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If datasets are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live datasets in all lists, lookups, and relationship loads (including charts that reference them). The `POST /<uuid>/restore` endpoint and the `dataset_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
@@ -933,7 +926,7 @@ With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the
### Soft delete and restore for charts
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/chart/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/chart/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If charts are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live charts in all lists, lookups, and relationship loads (including dashboards that contained them). The `POST /<uuid>/restore` endpoint and the `chart_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
@@ -957,7 +950,7 @@ With the flag enabled: `DELETE /api/v1/chart/<id>` no longer hard-deletes the ch
### Soft delete and restore for dashboards
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `True`** (`@lifecycle: testing`), so on a default deployment `DELETE /api/v1/dashboard/<id>` uses the recoverable soft-delete behavior described below. Setting `SOFT_DELETE` to `False` restores legacy permanent hard-delete behavior for subsequent deletes.
**Everything in this section applies only when the `SOFT_DELETE` feature flag is enabled. The flag defaults to `False`** (`@lifecycle: development`), so on a default deployment `DELETE /api/v1/dashboard/<id>` continues to **hard-delete permanently** — nothing is recoverable. Enable `SOFT_DELETE` to get the behavior described below.
**Flag-toggle caveat:** the soft-delete visibility filter is evaluated per query while the flag is on. If dashboards are soft-deleted during a flag-on window and the flag is later turned **off**, those rows reappear as live dashboards in all lists and lookups (including slug lookups — if a soft-deleted dashboard's slug was reused while the flag was on, both rows become visible with the same slug). The `POST /<uuid>/restore` endpoint and the `dashboard_deleted_state` list filter remain functional regardless of the flag, deliberately, so rows soft-deleted during a flag-on window stay discoverable and restorable after a rollback of the flag.
+1 -6
View File
@@ -782,18 +782,13 @@ Enable response caching for read-heavy workloads (dashboards/datasets that don't
```python
MCP_CACHE_CONFIG = {
"enabled": True,
# Cache keys don't include the requesting principal and hits are served
# ahead of auth/RBAC, so a shared cache can return one caller's response
# to another. Required for caching to actually start -- only appropriate
# when every request is guaranteed to come from the same principal.
"dangerously_share_cache_across_principals": True,
"CACHE_KEY_PREFIX": "mcp_cache_",
"call_tool_ttl": 3600,
}
MCP_STORE_CONFIG = {"enabled": True, "CACHE_REDIS_URL": "redis://redis:6379/0"}
```
Every tool with a side effect (create/update/delete/execute) is always excluded from caching regardless of this setting -- see the `excluded_tools` default in `superset/mcp_service/mcp_config.py` for the current list.
Mutating tools (`generate_chart`, `update_chart`, `execute_sql`, `generate_dashboard`) are always excluded from caching regardless of this setting.
---
-1
View File
@@ -86,7 +86,6 @@
"Israel",
"Italy",
"Italy (regions)",
"Italy (regions and autonomous provinces)",
"Ivory Coast",
"Japan",
"Jordan",
@@ -26,8 +26,7 @@ page and its menu entry are hidden, and deletes are permanent as before.
## Finding archived objects
Open **Recently Archived** and pick a type — **Chart**, **Dashboard**, or
**Dataset** (shown as **Datasource** when semantic layers are enabled) — from
the Type selector. The view shows one type at a time; each
**Dataset** — from the Type selector. The view shows one type at a time; each
type is read from its own list endpoint, so the same row-level access rules that
govern the normal lists apply here.
+12 -12
View File
@@ -15,29 +15,29 @@ description of what changed — "Chart renamed to Q3 Revenue", "Added filter on
'Region'" — rather than a raw diff. You can search the history and filter it
down to changes on the entity itself or on the things it depends on.
## Enabling and disabling it
## Enabling it
Two switches are involved, and both matter.
| Setting | Type | Effect |
| --- | --- | --- |
| `VERSION_HISTORY` | Feature flag | Shows the version history UI |
| `ENABLE_VERSIONING_CAPTURE` | Config value | Records versions as entities are saved |
Both default to on. To turn the feature off:
```python
# superset_config.py
FEATURE_FLAGS = {"VERSION_HISTORY": False}
ENABLE_VERSIONING_CAPTURE = False
FEATURE_FLAGS = {"VERSION_HISTORY": True}
ENABLE_VERSIONING_CAPTURE = True
```
Restart Superset and its workers for the capture change to take effect. Existing
history remains readable while capture is off, but **Restore** is unavailable
(404).
Both default to off. They are separate because capture is the expensive half:
an operator may want to start recording history before exposing the UI, so that
there is something to show when they do.
Disable them together: capture off with the UI left on gives a panel that
stops filling — an empty or stale history misrepresents the entity as
unchanged. History only accrues while capture is on; edits made while it was
off are not reconstructed.
Turning the UI on without capture gives a panel that reports "No history yet"
and never fills, so enable capture first — or at the same time. History only
accrues from the moment capture is switched on; earlier edits are not
reconstructed.
## Viewing history
+7 -7
View File
@@ -58,11 +58,11 @@
"@fontsource/inter": "^5.3.0",
"@mdx-js/react": "^3.1.1",
"@saucelabs/theme-github-codeblock": "^0.3.0",
"@storybook/addon-docs": "^10.5.9",
"@storybook/addon-docs": "^10.5.8",
"@superset-ui/core": "^0.20.4",
"@swc/core": "^1.16.1",
"antd": "^6.6.1",
"baseline-browser-mapping": "^2.11.15",
"@swc/core": "^1.15.47",
"antd": "^6.6.0",
"baseline-browser-mapping": "^2.11.13",
"caniuse-lite": "^1.0.30001809",
"docusaurus-plugin-openapi-docs": "^5.2.0",
"docusaurus-theme-openapi-docs": "^5.2.0",
@@ -77,8 +77,8 @@
"react-table": "^7.8.0",
"remark-import-partial": "^0.0.2",
"reselect": "^5.2.0",
"storybook": "^10.5.9",
"swagger-ui-react": "^5.32.14",
"storybook": "^10.5.8",
"swagger-ui-react": "^5.32.13",
"swc-loader": "^0.2.7",
"tinycolor2": "^1.4.2",
"unist-util-visit": "^5.1.0"
@@ -94,7 +94,7 @@
"eslint": "^9.39.2",
"eslint-plugin-react": "^7.37.5",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
"oxfmt": "^0.63.0",
"typescript": "~6.0.3",
"typescript-eslint": "^8.67.0",
"webpack": "^5.109.2"
+12 -12
View File
@@ -93,6 +93,12 @@
"lifecycle": "development",
"description": "Enable semantic layers and show semantic views alongside datasets"
},
{
"name": "SOFT_DELETE",
"default": true,
"lifecycle": "development",
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
},
{
"name": "TABLE_V2_TIME_COMPARISON_ENABLED",
"default": false,
@@ -104,6 +110,12 @@
"default": false,
"lifecycle": "development",
"description": "Enables the tagging system for organizing assets"
},
{
"name": "VERSION_HISTORY",
"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, so the two ship with matching defaults and should be changed together."
}
],
"testing": [
@@ -221,12 +233,6 @@
"lifecycle": "testing",
"description": "Apply RLS rules to SQL Lab queries. Requires query parsing/manipulation. May break queries or allow RLS bypass. Use with care!"
},
{
"name": "SOFT_DELETE",
"default": true,
"lifecycle": "testing",
"description": "Temporary rollout / kill-switch gate for soft delete (off = legacy hard delete). An emergency stop, not a clean rollback: flipping ON->OFF resurrects already-soft-deleted rows. Retained through this release as the move-back lever; removed (along with its two gate points \u2014 BaseDAO.delete routing and the do_orm_execute visibility listener) once post-flip confidence is established."
},
{
"name": "SSH_TUNNELING",
"default": false,
@@ -239,12 +245,6 @@
"default": false,
"lifecycle": "testing",
"description": "Use analogous colors in charts"
},
{
"name": "VERSION_HISTORY",
"default": true,
"lifecycle": "testing",
"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 empty or stale history, so the two ship with matching defaults and should be changed together."
}
],
"stable": [
+216 -216
View File
@@ -3175,100 +3175,100 @@
resolved "https://registry.yarnpkg.com/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.23.0.tgz#8b66dbfa7b796139e719063fc0e44084e80a1c15"
integrity sha512-gUGJpr+Rn6zMxm5juApV0K3U845i8t47o8k+rbO0BHbi4PoJIfSPeQmrE2dgohQm2g5k6iviNFyXCGqvmaYUpw==
"@oxfmt/binding-android-arm-eabi@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.64.0.tgz#e14e25c032f6d8a6b025eb5ee7bb606c3cbdd10e"
integrity sha512-o6uzh/jTOQeAY5TdkAeXdqv7MBRcPxiRA08zrcBtkKj5cSu/FMu0Hl7Q6Fi1KCKyCWZ6lJVjBzdsJvsKltUsGQ==
"@oxfmt/binding-android-arm-eabi@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.63.0.tgz#136176dc94fdc41e21415cc770d86f5066282e0f"
integrity sha512-YmRth4ZPGgEXcgmkhvANbC9uD67dxmSobW7DQuyt5tOBOKvPnIpk5SVHBj88E+7wMNRI2FhqaDbOhQFBix+b8A==
"@oxfmt/binding-android-arm64@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.64.0.tgz#294a15b8402eedde0e0a467748e3efadf61bf523"
integrity sha512-jRGSUeeP7p3Gynw2YaCVtjBIA6ZxY6bEB/ES5i54OhqmRTyuVg7ZgstEtzgq6GOAJd+2QZ5pvf+bFfmW5Mp9cw==
"@oxfmt/binding-android-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.63.0.tgz#10bc42457179210061c801122a64304619e3bdab"
integrity sha512-icbahX8X2X3sRamOMecvdYeZXWjPDazRDIfvWfy7Ca1nc/ZDT2Y9k5Nt7s46EqFd7NQPdgk+CM3/SgIT5LPCaQ==
"@oxfmt/binding-darwin-arm64@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.64.0.tgz#d55b1a5d5d97d4ccde8e4be7b63e06e4e56f2d13"
integrity sha512-JINwtU2lW7nOFSqi+H2qplipNUqah9Gc1jgGmB82kTD4UnZrZIVxCJ9qEmFiKfjNq27gYLFhrUb0to86aCwMjw==
"@oxfmt/binding-darwin-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.63.0.tgz#5f9084d9a760a1836387f8970a7f9d614ec3d909"
integrity sha512-WV+Ze5v5gI2qoj8jpAovt8KBTW8pjEz/AiMXXjeTQS+Bmf/MmZXTS40S8xNPDszX+W8WDv2Bbk6qKrMTtUGu1A==
"@oxfmt/binding-darwin-x64@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.64.0.tgz#1c9673270ed597ba9456d40fa0607d50e81158ea"
integrity sha512-gCmuswrgrOSajV4HCRFkVCGIruPq8bjYuPYgSE2WQB3mD6XrdyZ3JMSRZCkQ8zCxOyGWriBo6QoZ5nmMHQ1BfA==
"@oxfmt/binding-darwin-x64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.63.0.tgz#badd4a02218a9a62319817d5c337b30159a54a21"
integrity sha512-CJGSBdDxXOWIpoFXHpverimCvz084KA7L483rqJ44c3jDtzv6d4qOSoR/V9ywSHfV+Ks1lwIj2P49BFhunLNAA==
"@oxfmt/binding-freebsd-x64@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.64.0.tgz#9e8f8b3a5a558043c664d43d54e441756af30c56"
integrity sha512-Ab8g7a38pT0MMImjh7anRSTve6buWBIlcXIFBYa5xl4s6UxEgKSc2xOOhbGtLwvXnEi2PsEDGoJh3oUU7xkehQ==
"@oxfmt/binding-freebsd-x64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.63.0.tgz#a17261e95c8ebef1f76d8aaac746a64fdb6ba51e"
integrity sha512-BDfKY+KhL2078cgswBBFQPAYuxCy93bS/iC5frdSeSbTLcGrR6VC2hsuPTanoJmg84+wSyWl0wWC1eR+uTnkRg==
"@oxfmt/binding-linux-arm-gnueabihf@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.64.0.tgz#cfe552538c9e9402ca64d7b83b1ccf02457ef391"
integrity sha512-BgvS3CoQ+Xy2deoZqEN8JVKabcCZi2RxA3yant8G9OAv9KuPJ9TCjHkqigzdHUVwErZxEP5d2bzLIEyKYyBDLg==
"@oxfmt/binding-linux-arm-gnueabihf@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.63.0.tgz#baeee34bb08e0769af878623f442e83bc0aacd7a"
integrity sha512-Ov1cQEXT4mj7cojAokWSS1eoxkoyvbDfAbxNsGIKY2o36kvdAaFzPxRN6NxFRk9fD72B8oCoTTX/NuYTUWlpsg==
"@oxfmt/binding-linux-arm-musleabihf@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.64.0.tgz#1944e367da59e8b1770c5ba96465d0c7e640053e"
integrity sha512-QXpNxwoMj0YvnceCNZadNSden3bIcnvjn/sDp/rwZhRoZoZYGpHvtPyhGsdJz9uvT9GkaMW7SsLddurU56dt8w==
"@oxfmt/binding-linux-arm-musleabihf@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.63.0.tgz#e70d5697ec4b6bb5f87a3f019e01b3f956b8e44b"
integrity sha512-0LE7ro3+6L79jcMANycAZfRaC7zxr9YZ2+vEL5uMD9QlEep+rS/r1kSJsnuLl991NXJZD60euh0PC1GHrR20vw==
"@oxfmt/binding-linux-arm64-gnu@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.64.0.tgz#510386113bf6a128cf3106d612471dbd1a13b0f4"
integrity sha512-BBgH3I1ppDsI5pZ4Pdhw0ceYxwVCfbU/bZEBCeZ6caRS9x0ZabErxubP7riGUn11PXZBhe8DYdjkDKP1FlVQ5w==
"@oxfmt/binding-linux-arm64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.63.0.tgz#638a8ed4f3d256c50aeb6d2c19cfc65792c902e1"
integrity sha512-izPk+2Z4gjuZK32Fqh5qXoMpT/2NXzLh++ob57HiEiVSQZ1iYXu8EKMzb+K5AvWyIEXhdDIt7ADjGGtFhkT9Bw==
"@oxfmt/binding-linux-arm64-musl@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.64.0.tgz#7235405901cb0368b659eb42b362a817fc3330a3"
integrity sha512-v19HSjC/BGXdt26qEvKZtwAHgGmQ2Agcap2kQP+KIqoRZqivVzYth3ui2dJA1i+6/fjpjga85lIOaJJjQ/bOOw==
"@oxfmt/binding-linux-arm64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.63.0.tgz#af5a9b787f5233f27a3360ad56235fc1b011f760"
integrity sha512-alPmbOuWXFXiSo+lOtv6X71C7SYMEDW2WVvywOvf9BwKgEhSNGhMTLeFVSjKUMCamcjbbgVdsWF8GN1uy8xshg==
"@oxfmt/binding-linux-ppc64-gnu@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.64.0.tgz#1f0563c530dfa634682ffa32d16830404b95a8c6"
integrity sha512-PElLnOo4xFTBZrxPhgTIj0eHqZXwEBQoNWtb7facUV170T0B0FRET0iNbb3LUeLWTybkUW+vsdyv4ihOdyXGyw==
"@oxfmt/binding-linux-ppc64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.63.0.tgz#c1a211206134a5577e355a495989e0d733218d60"
integrity sha512-BdzCPvolJc4AWZ+YMzgUDJcDzbQWrFjYuqBHoNHNqP1aCaluQRJNs4k3vNU5IG7vTpjf9zeD73D7MFM1TecZpg==
"@oxfmt/binding-linux-riscv64-gnu@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.64.0.tgz#36f55e955c5b38b587470f181146c9a11cf8bdb1"
integrity sha512-Qzsg15n4F5CH+MorcRW4MkAEMiLzXmeG+DiDSbP/bBTqCmWOH3K9DHryNrve+JHlV0txS+B6Z9P5Xz+cmWeL+g==
"@oxfmt/binding-linux-riscv64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.63.0.tgz#4863f0311e5c1b88f75ef822959b3ca4fd938937"
integrity sha512-7sIgfLzqtNKSkMGsGVyRpHwpjNezRg2XONvUOheFZs95TSZpM0JAuPpA8KrQFsWc4wPU95roX2O69JgH8igOgw==
"@oxfmt/binding-linux-riscv64-musl@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.64.0.tgz#bb9c6c3860c8832fe271623eb6131ea5f5e094cd"
integrity sha512-/GZ358wnQ/Ez4UVnCcZIi56JkY0sOdZ+B108pqXKqZz3jLS59F4KEAB1Qv3fRlObrFEk+3L2vUQ/xoPx+3vjXw==
"@oxfmt/binding-linux-riscv64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.63.0.tgz#ad05a017d12553e2f544743c4940adb552aa1d1c"
integrity sha512-9Tcg0y0WcVa6Mm9AgcgFMseDS+VkFJZpKZ8We9SpDY4gg5jewSwln+0sO04QLcTS1BtfDl9MwR+NfID8L7PUTg==
"@oxfmt/binding-linux-s390x-gnu@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.64.0.tgz#7d736d923f3c7f88743f26479a49903c6dbaf818"
integrity sha512-/C9We3DXegowfLXtVCYHeNiU9azwCDr5cQkEtCVlc74vyn+lLQSPApJ1CZmxAduqeq/Oi3gQ+IVptyhCaTMtkQ==
"@oxfmt/binding-linux-s390x-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.63.0.tgz#2803f539db15bc66db115888fa8f84d6531ed2b9"
integrity sha512-qWKC1pEOpx1qYhXaugPhHUeXwSfqEOk2wJH2LqVXGPV5iQYfdAZdt+d2XDiX4DTSWA2QDMUcFB+wEORh3Xn/sA==
"@oxfmt/binding-linux-x64-gnu@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.64.0.tgz#34dfe2bde9ed124324b45aae078618456e850452"
integrity sha512-91KM2CeRWscIEHlj1NsW2WSnzGeq1Ehq+39bfDowTdkn+fcvK/x4Y1RcyqT7glyBjZio0ldkeCG6Usj3v7ASog==
"@oxfmt/binding-linux-x64-gnu@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.63.0.tgz#c22a06a60ae2d6b3de522095e0c50a816040a033"
integrity sha512-S9wXYOiGSqYGS4Fx/TFsY+xDd/7dE5s+rUgbA4TsHiVF9e8J3ZcKmP7dsP/7iqLI9Wz7Ic7TzEr3mdthRCTdrA==
"@oxfmt/binding-linux-x64-musl@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.64.0.tgz#b5edc644409aff9715279650767d34d2fb65d59a"
integrity sha512-gw7uEk9I+7zoT1EYLra1eWArIzNcz8e3jkv+Noo2+o2T7wPvsNSQbfoa4DSfZlvn1i6mJ05RiZ4/omaXPDNhQg==
"@oxfmt/binding-linux-x64-musl@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.63.0.tgz#48d3eeaf8e3757f638cf92de5ee4858befc9c0a3"
integrity sha512-5eGyTJuMZNwBSHCivXt8Yuta6GeTYksOPXRk2MIhajiyFGQx7bjaHIwY+ZusAoFHhT157A9x6sktLjYo9D5oMQ==
"@oxfmt/binding-openharmony-arm64@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.64.0.tgz#6b1d9c662e08bf5fbc1e9ccdb45ed28c004b90c4"
integrity sha512-HYHFf616FHSPSO07c09mjmXBfQ73wIVM3m0txOiooa5XZkGoxFd6B14PVj0LB0DXIqJ6wAO/dDR/NX/5UUaqnw==
"@oxfmt/binding-openharmony-arm64@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.63.0.tgz#02be9e140ae35ba30f52bdce27612fece4a01ab3"
integrity sha512-Rz7hx+Dv3DoW/S6pwVAyjfFXp7/trdQ1zg+vNmsdsdDNlUccugp4XNqambSuEAeP0DaG9k72AtNyfDXCEg0AGw==
"@oxfmt/binding-win32-arm64-msvc@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.64.0.tgz#bc5a005e159a8f9af4168eed2e61fe477f4029db"
integrity sha512-uQjFp081IZSWD6VAofX2iO2z01awAdHmfC+NrieWIPKrT2hZKQDyq/U18M7ifC0sm0Wz8aHY/p6+FDYIzs/CrQ==
"@oxfmt/binding-win32-arm64-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.63.0.tgz#2226eaf52b6345a2cb926499216b2486cf0dbec2"
integrity sha512-T/IuizKN9mr4Xw6YYnptkXRNdLkyIlUZ7c8zfTOBpoytZyJ1BAsMUvsMDEx0X4YvSMpaivm+DR8112rQfzC25g==
"@oxfmt/binding-win32-ia32-msvc@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.64.0.tgz#88e90b96f7b39e4b6f75178c94c52d464fa58b53"
integrity sha512-lNM6byTAQ881jugzFu8juJTbNRgsUTlswMA6pJmwi1XDvmIqnnb49lcUAs5gz94fCJLrVN+/X3s3jOKqx23WIQ==
"@oxfmt/binding-win32-ia32-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.63.0.tgz#58d263bb5ecd7330c02f9dcd8cda10f66e42e74b"
integrity sha512-XjrO5FJ5Wl9vsAxtCP1G/eaeT6y1K2s9CICUHGE42cEjou32/J6S+B1KnrOAboj6E7uhJnwPbRSvznWcxNdA0g==
"@oxfmt/binding-win32-x64-msvc@0.64.0":
version "0.64.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.64.0.tgz#788c7fe26f89e57269f79e8f8a34e9b1497bc674"
integrity sha512-BtmbtL/QjMtF1a6C3CqoDluH2IfB6fJt62E+B9RFfUPtFk4Iz9PFS6+y/SzzOvSxc7aUk2Kphwg7Dh8lMbwu6g==
"@oxfmt/binding-win32-x64-msvc@0.63.0":
version "0.63.0"
resolved "https://registry.yarnpkg.com/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.63.0.tgz#02a166c8a8049c55d0096d1ba9d8e73f3a4d26a7"
integrity sha512-sgsHCQy432OTQH4Ikk3tZptp3GqwnhwUDuY0loBH41zyHWfMZY9v8Dy78wsnSofHejvFozZGgJgBB1A0LQRwMQ==
"@parcel/watcher-android-arm64@2.5.6":
version "2.5.6"
@@ -3789,7 +3789,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/select@~1.10.0", "@rc-component/select@~1.10.1":
"@rc-component/select@~1.10.0":
version "1.10.1"
resolved "https://registry.yarnpkg.com/@rc-component/select/-/select-1.10.1.tgz#323b2f458a637e8e752f8341094783741c613c34"
integrity sha512-H+yQsl+qED9NilQ3g6zdpsMwUgwVjrcMTkNHAWRVU/MoNCYgTbDgU+MIMgZDK+rVdd2JUfI/MkysMcZZ0cyQKw==
@@ -3824,7 +3824,7 @@
"@rc-component/util" "^1.3.0"
clsx "^2.1.1"
"@rc-component/table@~1.11.1":
"@rc-component/table@~1.11.0":
version "1.11.1"
resolved "https://registry.yarnpkg.com/@rc-component/table/-/table-1.11.1.tgz#7b5c2a7c26fd37b6a403082029b5a72fcb330a4d"
integrity sha512-OWdS6DMmeWb7bJBGqPxYZpQbzBlBiXZUu2sqo6Ii7Sjs9GeK1IsrXrWk26SL2c6KEseabswdxrRj7WUm9LdECw==
@@ -3866,7 +3866,7 @@
"@rc-component/util" "^1.7.0"
clsx "^2.1.1"
"@rc-component/tree-select@~1.16.1":
"@rc-component/tree-select@~1.16.0":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@rc-component/tree-select/-/tree-select-1.16.1.tgz#dcaea96e396e98108cb29cc051840d4fbdda38cc"
integrity sha512-a1Oi6EJhqAhdOxxupdJi6fP0RPHMKn5TcfkX2+llaQ4lF4nwfH7b6SCHcnsybaa2s+pk1yZYwVyeOYkDnEBRdg==
@@ -4122,23 +4122,23 @@
resolved "https://registry.yarnpkg.com/@standard-schema/utils/-/utils-0.3.0.tgz#3d5e608f16c2390c10528e98e59aef6bf73cae7b"
integrity sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==
"@storybook/addon-docs@^10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.9.tgz#6d871977f7ad833dc142d12ee43490105dec4d07"
integrity sha512-8sFsMkZYrrdqCLdV+hnwTwDF7RaBsBPRwl4wfc8ve9Q/7Yhi5REe/Xjvd8x1yn6fBPPw9tnID9dx6Agdnr81fw==
"@storybook/addon-docs@^10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/addon-docs/-/addon-docs-10.5.8.tgz#767c10c7a4cc1b625b93f869b2a2b09fc8514f2e"
integrity sha512-NlHiMKW/UvW/uL8HXFDCEVwoH3qZeGYZ/qlWax4d7H471b/T54MBq2KcB4ZrdA785FfIH3numAJdBb5jwn00Mg==
dependencies:
"@mdx-js/react" "^3.0.0"
"@storybook/csf-plugin" "10.5.9"
"@storybook/csf-plugin" "10.5.8"
"@storybook/icons" "^2.0.2"
"@storybook/react-dom-shim" "10.5.9"
"@storybook/react-dom-shim" "10.5.8"
react "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
react-dom "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
ts-dedent "^2.0.0"
"@storybook/csf-plugin@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.9.tgz#805e4c93a1704b220351d62bb0c74ce3b78c5e10"
integrity sha512-4H5QIHQVtQYCuL43GCRLGjNQhZpQg9gL03ja0DV80kO2Dn9LEt6ol87bSnSjn4VDgcAXtgTzXFvRLknfVgAAqg==
"@storybook/csf-plugin@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/csf-plugin/-/csf-plugin-10.5.8.tgz#c626c5bfe55d0e279b2457e5cf150a788d1fc637"
integrity sha512-/FHiMyOWWEXfwK/lM0WxmkP9GLzbSJJuzGtfeuNWSOVDnvAMbjavitxfHb5wSbWKIQo0XYC1EJ2Y7x91XNYP4w==
dependencies:
unplugin "^2.3.5"
@@ -4152,10 +4152,10 @@
resolved "https://registry.yarnpkg.com/@storybook/icons/-/icons-2.1.0.tgz#edfc2450a39c5e780f28c6cbc49acd7bff59b41a"
integrity sha512-Fxh9vYpX9bQqFeHRiY8h2ApeRGDzRSMLwJwNZ/AIRqnyOKHxRKL+yFe+ctEkVJmuptRE9u1Hrn8ZZNHyfDKKNg==
"@storybook/react-dom-shim@10.5.9":
version "10.5.9"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.9.tgz#549793845bb8b966acd36002d33d7b54cc5c92d4"
integrity sha512-7qZD6CSa64p1m/zX9tG4ALcEHlEk0Bx+5++4RL3MFlRCgCFfAomXsCHTzF0RyF8cI4vl+hS7Y0ryjQchVs6UQA==
"@storybook/react-dom-shim@10.5.8":
version "10.5.8"
resolved "https://registry.yarnpkg.com/@storybook/react-dom-shim/-/react-dom-shim-10.5.8.tgz#40cc3e32af424baa2e4109a325dae2ede29999e2"
integrity sha512-N8D13/Xny+V3kfe1KBgsAHS0nKWXLLdgOOXS9poKdYzVwVCN+CGEGBxWX0zMMtdCptqa6/57em9coPlZMoO+bg==
"@superset-ui/core@^0.20.4":
version "0.20.4"
@@ -4855,86 +4855,86 @@
dependencies:
apg-lite "^1.0.4"
"@swc/core-darwin-arm64@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.16.1.tgz#f6f6983e2268888558cdbe043001d82449445def"
integrity sha512-zlJblJ8ncErD43lKdxjbUaUskJQf+LxiPXYcWXD8/8ZMV+7uuAT+CwjciLXpyZBd5Pq/S726bMpeeAwSeL1hhg==
"@swc/core-darwin-arm64@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.15.47.tgz#345ce6a1bf4033da189c2e3eff1244190195d15b"
integrity sha512-GsoMtan3ojGGMGFbl31mmRu5ctZ56re8grGE8mO/OHJ8O+JRkzod02fe7X6ZQ8JvamA3imkEkx/h3u+vsOgPgA==
"@swc/core-darwin-x64@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.16.1.tgz#98b61e8c7ffe9f6263a08677353ba5606f6992de"
integrity sha512-IN0BmPWb0YAh/17mmlWB/HDBtTw2MfuW4hulf/tQAgTQBRH17l+z499bNJLK6LizSjqs0P7V+jU38Zj+vJC1DA==
"@swc/core-darwin-x64@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.15.47.tgz#f3debf50b5c1602bf392acb412bd33fd6d7e4f98"
integrity sha512-leTi7Rx3KF4zcC637iqWgk9SoV8VXAD8ppQYXsep63px5A/UftOcxLN1pmr8Z1si/YvX90ompP/rHgpYkgwXWg==
"@swc/core-linux-arm-gnueabihf@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.16.1.tgz#afc245521cd43a65a87cdd87fe99fb9e4f4eaa58"
integrity sha512-EYgrx2YOCQ2Twz2S793kqNjPkpvYVUPzzR95bIb7by+VQcyaai4lZZ2iz/tZvcFVKSNcN3/JTKwx+aBn2ZL52A==
"@swc/core-linux-arm-gnueabihf@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.15.47.tgz#14a247a12c6d3de1ee63fa4fdbf5a4302936b5d6"
integrity sha512-hBqHuoWKKIsKmDBn9qVeWqj5GWZhtlcczVaqQmNRXsDfq+voR5CxKRfamA367QjJXtceYuliLFfEL8QsskRM2g==
"@swc/core-linux-arm64-gnu@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.16.1.tgz#c44ca749af555ef8127795de141094cd28da9714"
integrity sha512-moyKm0YZlHdHohzm1YwgAyesqnE853rO0REMfJLFAova51wF9BNi+3ZW2PeS7Vqvn6HeJuepLpAHbBdZctxpHA==
"@swc/core-linux-arm64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.15.47.tgz#3b8d09c481ae51c7b72d98fb6ce98f7b90065a1a"
integrity sha512-TBxvRz+B4K205TWHHZxWVxkC2RFNP/Mz3PNcECBos5PsKwxjg3QSJzdoebr0VCf0Bfh8HOPldKxAP/8XkFe9gA==
"@swc/core-linux-arm64-musl@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.16.1.tgz#a1a3d15d5fb074c474c9a60a14488ec16124253f"
integrity sha512-kKGBO9wdapiSzuf5ZzZ2fYtlu1BNSYtIIUxvH1ir/gcelTOREEHGDCLTDFx/2Knf878nU11A40z7LxwasEFxqA==
"@swc/core-linux-arm64-musl@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.15.47.tgz#7ff2baa16e67b29017fdf7c6b69e40de7920ce1a"
integrity sha512-3Yu3Uq/VgytqsPjTMbkPU1ExADytbdWbruJYhA584E9jrpE2Ki+R6VVPoZCeAVk1Cb7QxcRTgblw6bSa6a/R+w==
"@swc/core-linux-ppc64-gnu@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.16.1.tgz#7eb33976ece5e45e63f9c9c1ab0da9405df76f7f"
integrity sha512-nZ6qahtLxC3PM54cWOQZHxt4lTCF/3J4LIoWWzz6v7A+rLs8Dx54anYQf7mH3eIi8KlNpgKci/ie8ZSqFN8O7A==
"@swc/core-linux-ppc64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-ppc64-gnu/-/core-linux-ppc64-gnu-1.15.47.tgz#a3841982fe2eb2d889648c8e212b6d821db316d6"
integrity sha512-wfdMi5IaOaNtmh2/6geRoxIdNfqylUZFdtzTKS655y1axWfIWyx7As74vv0wVdjeCIZ3WmCI9odDd4rUttXOSQ==
"@swc/core-linux-s390x-gnu@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.16.1.tgz#f02f2687d2ee1c8f59430ef638c63714862c9389"
integrity sha512-4ji5PNzhYq193Z4/4xUaSoNJza6iCkDJSzhetrbB6KOYxsr+kxtQr8ePWhMJUiMt6JUWtXaZ1PYT8FhtED+nGA==
"@swc/core-linux-s390x-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-s390x-gnu/-/core-linux-s390x-gnu-1.15.47.tgz#edbfd705d6285f7dce48915871478bc9603904c3"
integrity sha512-3hHYBY0yx8Ez7GMRrkhXHQzMdR5IZA6Wq5Ee4svlgwvSECLpnAJ9+0AimEGUFDvuLwE7nV/2+PYe8+Nm4rvNcQ==
"@swc/core-linux-x64-gnu@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.16.1.tgz#af4c571bbe07044ee0bec49ade1e53c1022d4979"
integrity sha512-VJQxqrisHV+B394IgrOu8YsIIXZgffnf5tO+yc9Z/hoUpuZEvuQTjWwlnpZdpyD+0nx6LTD1/3k646JYm43yJA==
"@swc/core-linux-x64-gnu@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.15.47.tgz#e7f61a7771d6a9b5b274521ba61809b3d7644325"
integrity sha512-TjfhjgP/jGCfFHYC3JQPhJA1HwErbIJ9JfREDc1KNkvY6P0LodCgKVIlQ5deeTbkG7ih3bF5PHJLuLpaZjdRyQ==
"@swc/core-linux-x64-musl@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.16.1.tgz#113eb36a1d3bd21bbf4a48a22fad97dc1c7cc91c"
integrity sha512-r9oV1mwxxsIGcLV1IQ/tw76MW3doatKze1QFWuC+a7QqJUkhY/bKTSVk6NpKKUGm2LDsE33Va8VqSClfA7vSiQ==
"@swc/core-linux-x64-musl@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.15.47.tgz#7c1ef8305444bcc7894de177fe225f2d8f3be609"
integrity sha512-CQpS8Ge/avfjZd0UEwG/sds83Uu32deQXcV1Jo3jD0mmvQQqtYAjpsDZXugmheeAwmt+YIuoVtVHro8LMYHqsQ==
"@swc/core-win32-arm64-msvc@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.16.1.tgz#7cc6cfde26ad7e15fe93de98033e7c1892bcf127"
integrity sha512-6huNRessoBLxWEqBm5zJXyCQ27TO7anvkdiuQ5MDO4CJni0nOXEqKtV9RllQ2TdyENKKsUMXVnIfW2hIXx/R5Q==
"@swc/core-win32-arm64-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.15.47.tgz#953856d26b28956d1a18ef10e5f221202b2cb8f1"
integrity sha512-0W8IKHsUTYiT7G2RqtOoVWk+89yzZikIiDUb/sCK6BmQDBhN91hQSfyUtW12jhEWLzYgcfmisfsZrmZE+84U1A==
"@swc/core-win32-ia32-msvc@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.16.1.tgz#2330c734f4129c2064b8848fb956501788aab9a3"
integrity sha512-OVKJFUzphrGmsh+BGtcZDesx0YryV7/Yvy5XGgTqnrZfjnyfcr5uaqYQugCckdIlupc5Vs3XtDjRAj12z4ZPlw==
"@swc/core-win32-ia32-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.15.47.tgz#2743a5bccc49f252c23bad3135193640cbdcef3a"
integrity sha512-ZIp49d2Z4/ka2jO9otOg4hDvTdPmp86kVOgS2M5FCPI7eKKZ1W0boxWn+8XeZrfERtFGW0AlMRm4JhlJa7l3NA==
"@swc/core-win32-x64-msvc@1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.16.1.tgz#04825a3f9e6fbe390825ff02708a5ebdd3a9841b"
integrity sha512-Bt+VIhWYCGk4urklnkkteLUOeLv1VxigwTCeB/xC6rBZxY6IIKdDwCJf6on3E3SUGsIqmQS6QqtuJQc1VxF4Aw==
"@swc/core-win32-x64-msvc@1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.15.47.tgz#9674ad0c9187b7cbe5cc3080b31b960d3ee688b9"
integrity sha512-2h8Iek95vnixkBRCo+H8p09+Q5ll2NgSMFrWTy0iKt7+/t+8/T5mBpiT6c0ZxSS7wcWjwZ9sGZkK70tTSYHdDw==
"@swc/core@^1.15.40", "@swc/core@^1.16.1":
version "1.16.1"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.16.1.tgz#5ea7ff32f3b352c871aa47195efd4932f709a569"
integrity sha512-nUaeu91O5QZKrQdaDCHd402ogUIoNOOjpkZNq0UomWK0G6gDaGmLhvddF1/3BXf5O8aLyo6ZPY/aMDWvaJQ/hg==
"@swc/core@^1.15.40", "@swc/core@^1.15.47":
version "1.15.47"
resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.15.47.tgz#6226e842160e247eb79a9aeac1095ebddb56639f"
integrity sha512-FbsO5JcfOjfH38W/rohBRBweJeERsAuIP4f377lmkmxTcq9exjtx4SkRuZY5CdfhR2CBVwDIJegBpJDffwNsOg==
dependencies:
"@swc/counter" "^0.1.3"
"@swc/types" "^0.1.28"
"@swc/types" "^0.1.27"
optionalDependencies:
"@swc/core-darwin-arm64" "1.16.1"
"@swc/core-darwin-x64" "1.16.1"
"@swc/core-linux-arm-gnueabihf" "1.16.1"
"@swc/core-linux-arm64-gnu" "1.16.1"
"@swc/core-linux-arm64-musl" "1.16.1"
"@swc/core-linux-ppc64-gnu" "1.16.1"
"@swc/core-linux-s390x-gnu" "1.16.1"
"@swc/core-linux-x64-gnu" "1.16.1"
"@swc/core-linux-x64-musl" "1.16.1"
"@swc/core-win32-arm64-msvc" "1.16.1"
"@swc/core-win32-ia32-msvc" "1.16.1"
"@swc/core-win32-x64-msvc" "1.16.1"
"@swc/core-darwin-arm64" "1.15.47"
"@swc/core-darwin-x64" "1.15.47"
"@swc/core-linux-arm-gnueabihf" "1.15.47"
"@swc/core-linux-arm64-gnu" "1.15.47"
"@swc/core-linux-arm64-musl" "1.15.47"
"@swc/core-linux-ppc64-gnu" "1.15.47"
"@swc/core-linux-s390x-gnu" "1.15.47"
"@swc/core-linux-x64-gnu" "1.15.47"
"@swc/core-linux-x64-musl" "1.15.47"
"@swc/core-win32-arm64-msvc" "1.15.47"
"@swc/core-win32-ia32-msvc" "1.15.47"
"@swc/core-win32-x64-msvc" "1.15.47"
"@swc/counter@^0.1.3":
version "0.1.3"
@@ -5021,10 +5021,10 @@
"@swc/html-win32-ia32-msvc" "1.15.43"
"@swc/html-win32-x64-msvc" "1.15.43"
"@swc/types@^0.1.28":
version "0.1.28"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.28.tgz#e3cd892383fba3b8904c40518bbe1265a50753f2"
integrity sha512-V6Mnml8v09QALx6K0elJ7o9K/MkVDtW3t6L+7Ou/JcWtb3xwId2AH4FeOceySd2JaO87IMw4+6vSZxLm34LPbw==
"@swc/types@^0.1.27":
version "0.1.27"
resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.27.tgz#12080b0c426dea450634f202d9a3c82ac396e793"
integrity sha512-K6h3iUlqeM946U4sXFYeahefR1YBbXJvko+hv8WS8/0BNJ4OHiHRywMnQUJCqkR7Y9+hqQ1TvEpiKqUhz7NEFg==
dependencies:
"@swc/counter" "^0.1.3"
@@ -6181,10 +6181,10 @@ ansis@^3.2.0:
resolved "https://registry.yarnpkg.com/ansis/-/ansis-3.17.0.tgz#fa8d9c2a93fe7d1177e0c17f9eeb562a58a832d7"
integrity sha512-0qWUglt9JEqLFr3w1I1pbrChn1grhaiAR2ocX1PP/flRmxgtwTzPFFFnfIlD6aMOLQZgSuCRlidD70lvx8yhzg==
antd@^6.6.1:
version "6.6.1"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.1.tgz#3235d76413b525b1f3287b87bdaf6ba0e7148521"
integrity sha512-QHIHYoUk9N9nJy1T9fyxWKjY0qApdTEDd/6lzqYng8Uryv9FejNmbhKvYF7obGqB+TuLXQsPVF7fOVgyzM1KrQ==
antd@^6.6.0:
version "6.6.0"
resolved "https://registry.yarnpkg.com/antd/-/antd-6.6.0.tgz#8acb84c54b36594b5c1a9084c8acb6a03b79961b"
integrity sha512-UDwWIbpmrCHB9ZQ+bPh4vQfB6DTI2ulIyoQ0Tc9xxalFblttiNGHl3ySBD9SyV/8+gUjFzfSx1+iU1Fog2i46w==
dependencies:
"@ant-design/colors" "^8.0.1"
"@ant-design/cssinjs" "^2.1.2"
@@ -6217,16 +6217,16 @@ antd@^6.6.1:
"@rc-component/rate" "~1.0.1"
"@rc-component/resize-observer" "^1.1.2"
"@rc-component/segmented" "~1.3.0"
"@rc-component/select" "~1.10.1"
"@rc-component/select" "~1.10.0"
"@rc-component/slider" "~1.1.1"
"@rc-component/steps" "~1.2.2"
"@rc-component/switch" "~1.0.3"
"@rc-component/table" "~1.11.1"
"@rc-component/table" "~1.11.0"
"@rc-component/tabs" "~1.12.0"
"@rc-component/tooltip" "~1.5.0"
"@rc-component/tour" "~2.4.0"
"@rc-component/tree" "~1.4.0"
"@rc-component/tree-select" "~1.16.1"
"@rc-component/tree-select" "~1.16.0"
"@rc-component/trigger" "^3.10.1"
"@rc-component/upload" "~1.1.1"
"@rc-component/util" "^1.12.0"
@@ -6522,10 +6522,10 @@ base64-js@^1.3.1, base64-js@^1.5.1:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.15, baseline-browser-mapping@^2.9.19:
version "2.11.15"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.15.tgz#9c0cac93d7d304f3d61bb41088a102cd62e68676"
integrity sha512-FwMjJJ7HnyZpWe+oWxegG0fezZyBZUagI5LZEoO3GCbtbKNwRfMH9Ue5d5v01PNePBy1QSfPSDTTeVL0Hb9EzA==
baseline-browser-mapping@^2.10.38, baseline-browser-mapping@^2.11.13, baseline-browser-mapping@^2.9.19:
version "2.11.13"
resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.13.tgz#660073103c1bee93e54df55f117b7528adf6af19"
integrity sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ==
batch@0.6.1:
version "0.6.1"
@@ -11849,10 +11849,10 @@ neotraverse@0.6.15:
resolved "https://registry.yarnpkg.com/neotraverse/-/neotraverse-0.6.15.tgz#dc4abb64700c52440f13bc53635b559862420360"
integrity sha512-HZpdkco+JeXq0G+WWpMJ4NsX3pqb5O7eR9uGz3FfoFt+LYzU8iRWp49nJtud6hsDoywM8tIrDo3gjgmOqJA8LA==
neotraverse@=1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/neotraverse/-/neotraverse-1.0.1.tgz#7c89b43f6504ef85928c718f578c68621576d194"
integrity sha512-WmmLty1YWwJl9yZi77v2dVIV6X2kuYV8YYBI/G3LWGKdGHmHUvL1z7FW0iDvEvGAwNEoc5x1tOOOyDnf5jJw/w==
neotraverse@=0.6.18:
version "0.6.18"
resolved "https://registry.yarnpkg.com/neotraverse/-/neotraverse-0.6.18.tgz#abcb33dda2e8e713cf6321b29405e822230cdb30"
integrity sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==
no-case@^3.0.4:
version "3.0.4"
@@ -12262,32 +12262,32 @@ oxc-resolver@^11.19.1:
"@oxc-resolver/binding-win32-arm64-msvc" "11.23.0"
"@oxc-resolver/binding-win32-x64-msvc" "11.23.0"
oxfmt@^0.64.0:
version "0.64.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.64.0.tgz#666a5148cdf7385007cd46e35e8ff8f94ecfd96b"
integrity sha512-XZ4GFBN/PLbXKq+0zrgpQfPKYuJlUuj+nzZJY7UpIbFMNyefNLCdN9EwViycNqnYcv0wrn0jXcQLlqJp8RCKBg==
oxfmt@^0.63.0:
version "0.63.0"
resolved "https://registry.yarnpkg.com/oxfmt/-/oxfmt-0.63.0.tgz#c7338e6c43a68d5cf8dc61c08b617d77cb54e323"
integrity sha512-kgdDwv35wvVf6554U2Ab8Jnd0zTM+TsEQWwaB70RAjK3gICFAFGO+2Hd3Be27GMoXj3XRL9IKSNRVl7KBQL6iw==
dependencies:
tinypool "2.1.0"
optionalDependencies:
"@oxfmt/binding-android-arm-eabi" "0.64.0"
"@oxfmt/binding-android-arm64" "0.64.0"
"@oxfmt/binding-darwin-arm64" "0.64.0"
"@oxfmt/binding-darwin-x64" "0.64.0"
"@oxfmt/binding-freebsd-x64" "0.64.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.64.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.64.0"
"@oxfmt/binding-linux-arm64-gnu" "0.64.0"
"@oxfmt/binding-linux-arm64-musl" "0.64.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.64.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.64.0"
"@oxfmt/binding-linux-riscv64-musl" "0.64.0"
"@oxfmt/binding-linux-s390x-gnu" "0.64.0"
"@oxfmt/binding-linux-x64-gnu" "0.64.0"
"@oxfmt/binding-linux-x64-musl" "0.64.0"
"@oxfmt/binding-openharmony-arm64" "0.64.0"
"@oxfmt/binding-win32-arm64-msvc" "0.64.0"
"@oxfmt/binding-win32-ia32-msvc" "0.64.0"
"@oxfmt/binding-win32-x64-msvc" "0.64.0"
"@oxfmt/binding-android-arm-eabi" "0.63.0"
"@oxfmt/binding-android-arm64" "0.63.0"
"@oxfmt/binding-darwin-arm64" "0.63.0"
"@oxfmt/binding-darwin-x64" "0.63.0"
"@oxfmt/binding-freebsd-x64" "0.63.0"
"@oxfmt/binding-linux-arm-gnueabihf" "0.63.0"
"@oxfmt/binding-linux-arm-musleabihf" "0.63.0"
"@oxfmt/binding-linux-arm64-gnu" "0.63.0"
"@oxfmt/binding-linux-arm64-musl" "0.63.0"
"@oxfmt/binding-linux-ppc64-gnu" "0.63.0"
"@oxfmt/binding-linux-riscv64-gnu" "0.63.0"
"@oxfmt/binding-linux-riscv64-musl" "0.63.0"
"@oxfmt/binding-linux-s390x-gnu" "0.63.0"
"@oxfmt/binding-linux-x64-gnu" "0.63.0"
"@oxfmt/binding-linux-x64-musl" "0.63.0"
"@oxfmt/binding-openharmony-arm64" "0.63.0"
"@oxfmt/binding-win32-arm64-msvc" "0.63.0"
"@oxfmt/binding-win32-ia32-msvc" "0.63.0"
"@oxfmt/binding-win32-x64-msvc" "0.63.0"
p-cancelable@^3.0.0:
version "3.0.0"
@@ -13583,7 +13583,7 @@ react-modal@^3.16.3:
react-lifecycles-compat "^3.0.0"
warning "^4.0.3"
react-redux@^9.2.0, react-redux@^9.3.0:
react-redux@^9.2.0:
version "9.3.0"
resolved "https://registry.yarnpkg.com/react-redux/-/react-redux-9.3.0.tgz#a30113bb6d95c0a715d54dda4308d450fca6ce09"
integrity sha512-KQopgqFo/p/fgmAs5qz6p5RWaNAzq40WAu7fJIXnQpYxFPbJYtsJPWvGeF2rOBaY/kEuV77AVsX8TsQzKm+A/g==
@@ -14783,10 +14783,10 @@ stop-iteration-iterator@^1.1.0:
es-errors "^1.3.0"
internal-slot "^1.1.0"
storybook@^10.5.9:
version "10.5.9"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.9.tgz#61f476fd73785dcf09e9198ddf404b9b8c06964a"
integrity sha512-UfdMKSjEhIKr8LbqYyIE5r7vT/drL/PxN75YaouJ+UG0FssEy6cf49OdTF3kstAqVMHskc+zEqyRoiQHZXHwgA==
storybook@^10.5.8:
version "10.5.8"
resolved "https://registry.yarnpkg.com/storybook/-/storybook-10.5.8.tgz#d5f051983e6232c0a73ea02149a72c7bafb43275"
integrity sha512-rR4oFMSiWBSqI0lvsJPtcQUPj8+hzj3TkLu+Mw61Wo6YxPSb5FsLSHai0jZnuaIdKIlmu25KCfwlSQl4e1uvnA==
dependencies:
"@storybook/global" "^5.0.0"
"@storybook/icons" "^2.0.2"
@@ -15057,10 +15057,10 @@ svgo@^3.0.2, svgo@^3.2.0:
picocolors "^1.0.0"
sax "^1.5.0"
swagger-client@^3.38.0:
version "3.38.0"
resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.38.0.tgz#542431f02d809b49115272ff8b9e48d545b9f53c"
integrity sha512-n7aykm1BEdQ3fKePJJx63UGjYe8/5fuxFMi3qZP4OJGZvzljKvmhxNwIF/MB71sF/lop9NeWZReKvPib9CY+2g==
swagger-client@^3.37.8:
version "3.37.8"
resolved "https://registry.yarnpkg.com/swagger-client/-/swagger-client-3.37.8.tgz#26c24c89cbfda7459f6afb53bdfcb6d8dbe9ac82"
integrity sha512-uoKwfq+8DvWVDhoALDrEtex9f26Yi2VkvEFjsrMHd8Gl+TcApJkVXtNiE35p5JQjMsvwkvr1eLVlOFNF4GL1bQ==
dependencies:
"@babel/runtime-corejs3" "^7.22.15"
"@scarf/scarf" "=1.4.0"
@@ -15074,7 +15074,7 @@ swagger-client@^3.38.0:
deepmerge "~4.3.0"
fast-json-patch "^3.0.0-1"
js-yaml "^4.2.0"
neotraverse "=1.0.1"
neotraverse "=0.6.18"
node-abort-controller "^3.1.1"
openapi-path-templating "^2.2.1"
openapi-server-url-templating "^1.3.0"
@@ -15103,10 +15103,10 @@ swagger-client@^3.38.0:
"@swagger-api/apidom-parser-adapter-openapi-yaml-3-2" "^1.12.0"
"@swagger-api/apidom-parser-adapter-yaml-1-2" "^1.12.0"
swagger-ui-react@^5.32.14:
version "5.32.14"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.14.tgz#31b69b0f6910e87dbcc81886208061ca1f72e034"
integrity sha512-6LAVBeC78DplbJ7kutm/YeBYo22nPzGOca4bIZAvQG4w2eSetnYDdazaUfY0qzQUlg/H90HnYZX3rg67EmENOw==
swagger-ui-react@^5.32.13:
version "5.32.13"
resolved "https://registry.yarnpkg.com/swagger-ui-react/-/swagger-ui-react-5.32.13.tgz#04c96140b0a2d4ea01ebec4d4cfc655d5ed9a500"
integrity sha512-XIDl+Ny6kE1N8wpSPiOFrjPfAevs4GR4XmV6BT6NLMikkMFIbIVocWbA8pnKYyYXQe8Rccfli5o2zDfySw0FnQ==
dependencies:
"@babel/runtime-corejs3" "^7.27.1"
"@scarf/scarf" "=1.4.0"
@@ -15129,7 +15129,7 @@ swagger-ui-react@^5.32.14:
react-immutable-proptypes "2.2.0"
react-immutable-pure-component "^2.2.0"
react-inspector "^6.0.1"
react-redux "^9.3.0"
react-redux "^9.2.0"
react-syntax-highlighter "^16.0.0"
redux "^5.0.1"
redux-immutable "^4.0.0"
@@ -15137,7 +15137,7 @@ swagger-ui-react@^5.32.14:
reselect "^5.1.1"
serialize-error "^8.1.0"
sha.js "^2.4.12"
swagger-client "^3.38.0"
swagger-client "^3.37.8"
url-parse "^1.5.10"
xml "=1.0.1"
xml-but-prettier "^1.0.1"
+9 -13
View File
@@ -101,7 +101,7 @@ dependencies = [
"python-dateutil",
"python-dotenv", # optional dependencies for Flask but required for Superset, see https://flask.palletsprojects.com/en/stable/installation/#optional-dependencies
"pygeohash",
"pyarrow>=25.0.1, <26", # before upgrading pyarrow, check that all db dependencies support this, see e.g. https://github.com/apache/superset/pull/34693
"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",
@@ -111,10 +111,10 @@ dependencies = [
"sshtunnel>=0.4.0, <0.5",
"simplejson>=4.1.1",
"slack_sdk>=3.43.0, <4",
"sqlalchemy>=2.0.52, <2.1",
"sqlalchemy>=2.0.0, <2.1",
"sqlalchemy-continuum>=1.6.0, <2.0.0",
"sqlalchemy-utils>=0.42.1, <0.43", # expanding lowerbound to work with pydoris
"sqlglot>=30.17.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
"sqlglot>=30.16.0, <31", # 30.16.0 adds Trino inline UDF IF/CASE routine statement parsing
# newer pandas needs 0.9+
"tabulate>=0.10.0, <1.0",
"typing-extensions>=4.16.0, <5",
@@ -141,7 +141,7 @@ bigquery = [
"sqlalchemy-bigquery>=1.17.2",
"google-cloud-bigquery>=3.42.3",
]
clickhouse = ["clickhouse-connect>=1.7.1, <2.0"]
clickhouse = ["clickhouse-connect>=1.6.0, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
crate = ["sqlalchemy-cratedb>=0.43.1, <1"]
# sqlalchemy-d1's only release (0.1.0, Nov 2025) pins sqlalchemy<2,>=1.4,
@@ -197,7 +197,7 @@ fastmcp = [
# landed (discussion #40273).
firebird = ["sqlalchemy-firebird>=2.2.0"]
firebolt = ["firebolt-sqlalchemy>=1.1.2, <2"]
gevent = ["gevent>=26.8.0"]
gevent = ["gevent>=26.7.0"]
gsheets = ["shillelagh[gsheetsapi]>=1.4.5, <2"]
hana = ["hdbcli==2.29.25", "sqlalchemy_hana==3.0.3"]
hive = [
@@ -218,12 +218,8 @@ motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.2.8, <3"]
ocient = [
# Closed-source vendor package with no public changelog; permissive
# unpinned sqlalchemy>=1.4 declared. Verified compatible with SQLAlchemy
# 2.0 against pyocient>=3.9.0 (discussion #40273): dialect construction,
# error extraction, and GIS-type sanitization all pass under 2.0.52. Note
# pyocient 3.9.0 relocated its geo-type classes from private top-level
# names (pyocient._STPoint) to public ones under pyocient.api
# (pyocient.api.STPoint), which is unrelated to the SQLAlchemy bump.
# unpinned sqlalchemy>=1.4 declared, but SQLAlchemy 2.0 support is
# unverified. Lower confidence than the other bumps in this PR.
"sqlalchemy-ocient>=3.0.0, <4",
"pyocient>=3.9.0, <4",
"shapely",
@@ -236,7 +232,7 @@ playwright = ["playwright>=1.62.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
presto = ["pyhive[presto]>=0.6.5"]
trino = ["trino>=0.338.0"]
prophet = ["prophet>=1.4.0, <2"]
prophet = ["prophet>=1.3.0, <2"]
# sqlalchemy-redshift cuts hard from SQLAlchemy 1.4-only (0.8.x) to 2.0-only
# (>=1.0.0) with no dual-compat release. Bumped now that Superset's own
# SQLAlchemy 2.0 core bump has landed (discussion #40273).
@@ -259,7 +255,7 @@ tdengine = [
"taospy>=2.8.10",
"taos-ws-py>=0.7.0"
]
teradata = ["teradatasql>=20.0.0.65"]
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"]
+1 -1
View File
@@ -30,7 +30,7 @@ cryptography>=50.0.0,<51.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.4.1,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers
pyarrow>=25.0.1,<26.0.0
pyarrow>=24.0.0,<26.0.0
# Security: CVE-2026-27459 - pyopenssl certificate validation
pyopenssl>=26.0.0,<27.0.0
# Security: CVE-2026-25645 (MEDIUM) - Insecure Temporary File
+4 -4
View File
@@ -222,7 +222,7 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==4.3.1
marshmallow==4.3.0
# via
# apache-superset (pyproject.toml)
# flask-appbuilder
@@ -287,7 +287,7 @@ prison==0.2.1
# via flask-appbuilder
prompt-toolkit==3.0.51
# via click-repl
pyarrow==25.0.1
pyarrow==25.0.0
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -381,7 +381,7 @@ six==1.17.0
# wtforms-json
slack-sdk==3.43.0
# via apache-superset (pyproject.toml)
sqlalchemy==2.0.52
sqlalchemy==2.0.51
# via
# apache-superset (pyproject.toml)
# alembic
@@ -399,7 +399,7 @@ sqlalchemy-utils==0.42.1
# apache-superset (pyproject.toml)
# apache-superset-core
# flask-appbuilder
sqlglot==30.17.0
sqlglot==30.16.0
# via
# apache-superset (pyproject.toml)
# apache-superset-core
+8 -6
View File
@@ -337,7 +337,7 @@ geopy==2.4.1
# via
# -c requirements/base-constraint.txt
# apache-superset
gevent==26.8.0
gevent==26.7.0
# via apache-superset
google-api-core==2.33.0
# via
@@ -434,6 +434,8 @@ importlib-metadata==8.7.0
# via
# keyring
# opentelemetry-api
importlib-resources==6.5.2
# via prophet
iniconfig==2.0.0
# via pytest
isodate==0.7.2
@@ -528,7 +530,7 @@ markupsafe==3.0.2
# mako
# werkzeug
# wtforms
marshmallow==4.3.1
marshmallow==4.3.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -691,7 +693,7 @@ prompt-toolkit==3.0.51
# via
# -c requirements/base-constraint.txt
# click-repl
prophet==1.4.0
prophet==1.3.0
# via apache-superset
proto-plus==1.25.0
# via google-api-core
@@ -709,7 +711,7 @@ psycopg2-binary==2.9.12
# via apache-superset
py-key-value-aio==0.4.4
# via fastmcp-slim
pyarrow==25.0.1
pyarrow==25.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -948,7 +950,7 @@ slack-sdk==3.43.0
# apache-superset
sniffio==1.3.1
# via anyio
sqlalchemy==2.0.52
sqlalchemy==2.0.51
# via
# -c requirements/base-constraint.txt
# alembic
@@ -974,7 +976,7 @@ sqlalchemy-utils==0.42.1
# apache-superset
# apache-superset-core
# flask-appbuilder
sqlglot==30.17.0
sqlglot==30.16.0
# via
# -c requirements/base-constraint.txt
# apache-superset
-1
View File
@@ -42,7 +42,6 @@ RETRYABLE_STATUS_CODES: frozenset[int] = frozenset({429})
PATTERNS = {
"python": [
r"^\.github/workflows/.*python",
r"^\.github/workflows/frontend-bundle-size-nightly\.yml$",
r"^\.github/workflows/scheduled-docker-image-refresh\.yml$",
r"^docker-compose-image-tag\.yml$",
r"^tests/",
@@ -1,18 +0,0 @@
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
"""Semantic layer contracts for extension authors."""
+512 -346
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -122,9 +122,9 @@
"@luma.gl/shadertools": "~9.2.5",
"@luma.gl/webgl": "~9.2.5",
"@reduxjs/toolkit": "^1.9.3",
"@rjsf/core": "^6.8.0",
"@rjsf/core": "^6.7.1",
"@rjsf/utils": "^6.6.2",
"@rjsf/validator-ajv8": "^6.8.0",
"@rjsf/validator-ajv8": "^6.7.1",
"@scarf/scarf": "^1.4.0",
"@superset-ui/chart-controls": "file:./packages/superset-ui-chart-controls",
"@superset-ui/core": "file:./packages/superset-ui-core",
@@ -158,12 +158,12 @@
"@visx/xychart": "^4.0.0",
"ag-grid-community": "36.1.0",
"ag-grid-react": "36.1.0",
"antd": "^6.6.1",
"antd": "^6.6.0",
"chrono-node": "^2.10.1",
"classnames": "^2.2.5",
"content-disposition": "^2.0.1",
"d3-scale": "^4.0.2",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dom-to-image-more": "^3.10.2",
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
@@ -230,7 +230,7 @@
"use-event-callback": "^0.1.0",
"use-immer": "^0.11.0",
"use-query-params": "^2.2.2",
"uuid": "^14.0.2",
"uuid": "^14.0.1",
"xlsx": "https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz",
"yargs": "^18.1.0"
},
@@ -257,14 +257,14 @@
"@istanbuljs/nyc-config-typescript": "^1.0.1",
"@playwright/test": "^1.62.1",
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
"@storybook/addon-docs": "10.5.9",
"@storybook/addon-links": "10.5.9",
"@storybook/react-webpack5": "10.5.9",
"@storybook/addon-docs": "10.5.8",
"@storybook/addon-links": "10.5.8",
"@storybook/react-webpack5": "10.5.8",
"@storybook/test-runner": "0.24.4",
"@svgr/webpack": "^8.1.0",
"@swc/core": "^1.16.1",
"@swc/plugin-emotion": "^15.0.0",
"@swc/plugin-transform-imports": "^13.0.0",
"@swc/core": "^1.15.47",
"@swc/plugin-emotion": "^14.19.0",
"@swc/plugin-transform-imports": "^12.5.0",
"@testing-library/dom": "^10.4.1",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^15.0.0",
@@ -295,7 +295,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.15",
"baseline-browser-mapping": "^2.11.14",
"cheerio": "1.2.0",
"concurrently": "^10.0.5",
"copy-webpack-plugin": "^14.0.0",
@@ -312,7 +312,7 @@
"eslint-plugin-no-only-tests": "^3.4.0",
"eslint-plugin-react-prefer-function-component": "^5.0.0",
"eslint-plugin-react-you-might-not-need-an-effect": "^1.0.1",
"eslint-plugin-storybook": "10.5.9",
"eslint-plugin-storybook": "10.5.8",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -331,8 +331,8 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxfmt": "^0.64.0",
"oxlint": "^1.79.0",
"oxfmt": "^0.63.0",
"oxlint": "^1.78.0",
"po2json": "^0.4.5",
"postcss-styled-syntax": "^0.7.2",
"process": "^0.11.10",
@@ -343,7 +343,7 @@
"source-map": "^0.8.0",
"source-map-support": "^0.5.21",
"speed-measure-webpack-plugin": "^1.6.0",
"storybook": "10.5.9",
"storybook": "10.5.8",
"style-loader": "^4.0.0",
"stylelint": "^17.14.1",
"swc-loader": "^0.2.7",
@@ -122,12 +122,6 @@ export const timeComparisonControls: ({
}
return newState;
},
// Re-run this control's validation whenever `time_compare` changes so
// the "date required" error clears once a non-custom shift is picked.
// Without it the stale error survives in Redux (see the
// dependantControls path in exploreReducer's SET_FIELD_VALUE handler)
// and blocks further chart updates until a page refresh.
validationDependencies: ['time_compare'],
},
},
],
@@ -17,7 +17,7 @@
* under the License.
*/
import { QueryFormMetric } from '@superset-ui/core';
import { getTotalsMetrics, toTotalsAggregate } from './getTotalsMetrics';
import { getTotalsMetrics } from './getTotalsMetrics';
const simpleMetric = (aggregate: string): QueryFormMetric =>
({
@@ -76,31 +76,4 @@ describe('getTotalsMetrics', () => {
test('returns an empty array when given no metrics', () => {
expect(getTotalsMetrics([], 'AVG')).toEqual([]);
});
test("ORIGINAL keeps each metric's own aggregate", () => {
const metrics = [
simpleMetric('COUNT_DISTINCT'),
sqlMetric(),
savedMetric(),
];
const result = getTotalsMetrics(metrics, 'ORIGINAL');
expect(result).toBe(metrics);
expect(result[0]).toEqual(
expect.objectContaining({ aggregate: 'COUNT_DISTINCT' }),
);
});
});
describe('toTotalsAggregate', () => {
test.each(['SUM', 'AVG'] as const)('passes %s through', value => {
expect(toTotalsAggregate(value)).toBe(value);
});
test.each([undefined, null, '', 'MEDIAN', 'sum'])(
'falls back to ORIGINAL for %p',
value => {
expect(toTotalsAggregate(value)).toBe('ORIGINAL');
},
);
});
@@ -18,46 +18,26 @@
*/
import { isAdhocMetricSimple, QueryFormMetric } from '@superset-ui/core';
/**
* How the "Show summary" totals row aggregates each metric.
*
* ``ORIGINAL`` keeps every metric's own aggregation. It is the default because
* overriding is not universally valid: ``SUM`` over a ``COUNT_DISTINCT`` of a
* non-numeric column (a uuid, say) is rejected outright by the database, and
* over a numeric id column it silently produces a meaningless number.
*/
export type TotalsAggregate = 'ORIGINAL' | 'SUM' | 'AVG';
export type TotalsAggregate = 'SUM' | 'AVG';
/**
* Build the metrics for a chart's "Show summary" totals query.
* Build the metrics for a chart's "Show summary" totals query, overriding
* each Simple (adhoc) metric's aggregate function with the user-chosen
* totals aggregate. The totals query has no GROUP BY, so the database
* evaluates each metric fresh over all rows -- swapping the aggregate here
* is a correct, independent computation, not a re-aggregation of
* already-aggregated per-row values.
*
* With SUM or AVG, each Simple (adhoc) metric is cloned with its aggregate
* replaced. The totals query has no GROUP BY, so the database evaluates each
* metric fresh over all rows -- that swap is an independent computation, not a
* re-aggregation of already-aggregated per-row values.
*
* Custom-SQL and saved (string) metrics always pass through unchanged: there is
* no safe way to rewrite an arbitrary SQL expression's aggregate without
* parsing it, so the totals row keeps their own native aggregate.
* Custom-SQL metrics and saved (string) metrics pass through unchanged:
* there is no safe way to rewrite an arbitrary SQL expression's aggregate
* function without parsing it, so the totals row keeps their own native
* aggregate for those.
*/
export function getTotalsMetrics(
metrics: QueryFormMetric[],
aggregate: TotalsAggregate,
): QueryFormMetric[] {
if (aggregate === 'ORIGINAL') {
return metrics;
}
return metrics.map(metric =>
isAdhocMetricSimple(metric) ? { ...metric, aggregate } : metric,
);
}
/**
* Narrow a raw ``totals_aggregate`` form-data value to a TotalsAggregate.
*
* Anything other than an explicit SUM/AVG — including charts saved before the
* control existed — keeps each metric's own aggregation.
*/
export function toTotalsAggregate(value: unknown): TotalsAggregate {
return value === 'SUM' || value === 'AVG' ? value : 'ORIGINAL';
}
@@ -67,7 +67,7 @@
"d3-scale": "^4.0.2",
"d3-time": "^3.1.0",
"d3-time-format": "^4.1.0",
"dayjs": "^1.11.23",
"dayjs": "^1.11.22",
"dompurify": "^3.4.13",
"fetch-retry": "^6.0.0",
"handlebars": "^4.7.9",
@@ -71,9 +71,6 @@ export type AntdExposedProps = Pick<
| 'virtual'
| 'getPopupContainer'
| 'menuItemSelectedIcon'
// lets a caller with long option labels stop the popup inheriting the
// trigger's width, which otherwise truncates every option
| 'popupMatchSelectWidth'
>;
export type SelectOptionsType = Exclude<AntdProps['options'], undefined>;
@@ -64,8 +64,9 @@ export default function createSmartNumberFormatter(
description,
formatFunc: value => `${getSign(value)}${formatValue(value)}`,
id:
id ??
(signed ? NumberFormats.SMART_NUMBER_SIGNED : NumberFormats.SMART_NUMBER),
id || signed
? NumberFormats.SMART_NUMBER_SIGNED
: NumberFormats.SMART_NUMBER,
label: label ?? 'Adaptive formatter',
});
}
@@ -24,12 +24,6 @@ describe('createSmartNumberFormatter(options)', () => {
const formatter = createSmartNumberFormatter();
expect(formatter).toBeInstanceOf(NumberFormatter);
});
test('uses the supplied formatter id regardless of signed option', () => {
expect(createSmartNumberFormatter({ id: 'custom' }).id).toBe('custom');
expect(
createSmartNumberFormatter({ id: 'custom-signed', signed: true }).id,
).toBe('custom-signed');
});
describe('using default options', () => {
const formatter = createSmartNumberFormatter();
test('formats 0 correctly', () => {
@@ -96,57 +96,16 @@ export class Menu {
itemText: string,
options?: { timeout?: number },
): Promise<void> {
const popup = await this.openSubmenu(submenuText, {
timeout: options?.timeout,
itemText,
});
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
* Opens a submenu and returns its popup locator, without selecting an item.
* Useful when the caller needs to read the popup's contents (e.g. the set of
* offered items) rather than clicking a known item.
*
* Uses hover as primary approach, falls back to keyboard then dispatchEvent -
* same fallback chain as {@link selectSubmenuItem}.
*
* @param submenuText - The text of the submenu to open (e.g., "Download")
* @param options - Optional timeout, an `itemText` to scope the popup lookup
* to (useful when multiple submenu popups could otherwise match), and a
* `popupSelector` override for submenus that render with an additional,
* more specific class than the generic Ant Design popup class.
*/
async openSubmenu(
submenuText: string,
options?: { timeout?: number; itemText?: string; popupSelector?: string },
): Promise<Locator> {
const timeout = options?.timeout ?? TIMEOUT.FORM_LOAD;
const matchPopup = (): Locator => {
const base = this.page.locator(
options?.popupSelector ?? Menu.SELECTORS.SUBMENU_POPUP,
);
return options?.itemText
? base.filter({ hasText: options.itemText })
: base;
};
// Try hover first (most natural user interaction)
let popup = await this.openSubmenuWithHover(
submenuText,
matchPopup,
timeout,
);
let popup = await this.openSubmenuWithHover(submenuText, itemText, timeout);
// Fallback to keyboard navigation
if (!popup) {
popup = await this.openSubmenuWithKeyboard(
submenuText,
matchPopup,
itemText,
timeout,
);
}
@@ -155,7 +114,7 @@ export class Menu {
if (!popup) {
popup = await this.openSubmenuWithDispatchEvent(
submenuText,
matchPopup,
itemText,
timeout,
);
}
@@ -166,7 +125,10 @@ export class Menu {
);
}
return popup;
// Use dispatchEvent instead of click to bypass viewport and pointer interception
// issues. Ant Design renders submenu popups in a portal that can be positioned
// outside the viewport or behind chart content (e.g., large tables with z-index).
await popup.getByText(itemText, { exact: true }).dispatchEvent('click');
}
/**
@@ -175,14 +137,17 @@ export class Menu {
*/
private async openSubmenuWithHover(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
const submenuTitle = this.getSubmenuTitle(submenuText);
await submenuTitle.hover();
const popup = matchPopup();
// Find the popup that contains the expected item (scopes to correct popup)
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
// Allow Ant Design's slide-in animation to complete before clicking.
@@ -201,7 +166,7 @@ export class Menu {
*/
private async openSubmenuWithKeyboard(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
@@ -209,7 +174,9 @@ export class Menu {
await submenuTitle.focus();
await this.page.keyboard.press('ArrowRight');
const popup = matchPopup();
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
@@ -224,7 +191,7 @@ export class Menu {
*/
private async openSubmenuWithDispatchEvent(
submenuText: string,
matchPopup: () => Locator,
itemText: string,
timeout: number,
): Promise<Locator | null> {
try {
@@ -247,7 +214,9 @@ export class Menu {
);
});
const popup = matchPopup();
const popup = this.page
.locator(Menu.SELECTORS.SUBMENU_POPUP)
.filter({ hasText: itemText });
await popup.waitFor({ state: 'visible', timeout });
return popup;
@@ -1,133 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Locator, Page } from '@playwright/test';
import { Modal } from '../core';
/**
* The "Drill to detail" modal (`DrillDetailModal.tsx`), opened from a chart's
* "More Options" menu or its right-click context menu. Renders the chart's
* underlying sample rows, optionally scoped to a drilled-by value, via the
* `/datasource/samples` API.
*/
export class DrillDetailModal extends Modal {
private static readonly SELECTORS = {
CLOSE_BUTTON: '[data-test="close-drilltodetail-modal"]',
ROW_COUNT_LABEL: '[data-test="row-count-label"]',
METADATA_BAR: '[data-test="metadata-bar"]',
FILTER_COLUMN: '[data-test="filter-col"]',
FILTER_VALUE: '[data-test="filter-val"]',
PAGE_ITEM: '.ant-pagination-item',
ACTIVE_PAGE_ITEM: '.ant-pagination-item-active',
GRID_CELL: '.virtual-table-cell',
} as const;
private readonly specificLocator: Locator;
constructor(page: Page) {
super(page);
// Matched by accessible name rather than a data-test: the antd Modal's own
// data-test (`${name}-modal`) is derived from this same i18n'd `name`
// prop, so it isn't a locale-independent alternative. No data-test exists
// on the dialog root itself.
this.specificLocator = page.getByRole('dialog', {
name: /^Drill to detail:/,
});
}
override get element(): Locator {
return this.specificLocator;
}
/**
* The applied-filter value tags (`<col>=<val>`). Empty when the drill was
* whole-chart (no row/point-level filter applied).
*/
get filterValues(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_VALUE);
}
/** The applied-filter chip(s); each is closable via its own "Close" icon. */
get filterColumns(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.FILTER_COLUMN);
}
/** Row-count label above the results grid, e.g. "1-50 of 500 rows". */
get rowCountLabel(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ROW_COUNT_LABEL);
}
/** The metadata bar (column/row summary) shown once samples have loaded. */
get metadataBar(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.METADATA_BAR);
}
/** Pagination page-number items below the results grid. */
get pageItems(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.PAGE_ITEM);
}
/** The currently active pagination page-number item. */
get activePageItem(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.ACTIVE_PAGE_ITEM);
}
/** Cells of the virtualized results grid. */
get gridCells(): Locator {
return this.element.locator(DrillDetailModal.SELECTORS.GRID_CELL);
}
/**
* Removes the first applied filter by clicking its chip's Close icon,
* re-fetching the unfiltered samples.
*/
async clearFirstFilter(): Promise<void> {
await this.filterColumns.first().getByLabel('Close').click();
}
/** Navigates to the given 1-indexed pagination page. */
async goToPage(pageNumber: number): Promise<void> {
await this.pageItems.nth(pageNumber - 1).click();
}
/**
* Re-fetches the current samples query, resetting pagination to page 1.
*
* Matched by accessible name: the Reload icon carries an i18n'd
* `aria-label` (`t('Reload')`) and no data-test, so this breaks in
* non-English locales the same way `DrillDetailModal.tsx`'s dialog `name`
* does above; the predecessor Cypress test used the same English string.
*/
async reload(): Promise<void> {
await this.element.getByRole('button', { name: 'Reload' }).click();
}
/**
* Closes the modal via its footer Close button.
*
* Targets the button by data-test rather than Modal.clickFooterButton,
* which finds buttons by their visible text. The button label is i18n'd
* ("Close" / "Fermer" / ), so name-based lookups break in non-English
* locales; see DeleteConfirmationModal.clickDelete for the same rationale.
*/
async close(): Promise<void> {
await this.element.locator(DrillDetailModal.SELECTORS.CLOSE_BUTTON).click();
await this.waitForHidden();
}
}
@@ -21,7 +21,6 @@
export { ChartPropertiesModal } from './ChartPropertiesModal';
export { ConfirmDialog } from './ConfirmDialog';
export { DeleteConfirmationModal } from './DeleteConfirmationModal';
export { DrillDetailModal } from './DrillDetailModal';
export { DuplicateDatasetModal } from './DuplicateDatasetModal';
export { EditDatasetModal } from './EditDatasetModal';
export { ImportDatasetModal } from './ImportDatasetModal';
@@ -20,7 +20,6 @@
import { Page, Download, Locator, expect } from '@playwright/test';
import { Button, Input, Menu, Tabs } from '../components/core';
import { DashboardFilterBar } from '../components/dashboard';
import { DrillDetailModal } from '../components/modals';
import { gotoWithRetry } from '../helpers/navigation';
import { html5DragAndDrop } from '../helpers/dnd';
import { TIMEOUT } from '../utils/constants';
@@ -455,124 +454,4 @@ export class DashboardPage {
return { heightBefore: boxBefore.height, heightAfter: boxAfter.height };
}
// ---------------------------------------------------------------------------
// Drill to detail
//
// Charts that implement the DRILL_TO_DETAIL behavior expose two entry points:
// the chart's "More Options" header menu, and a right-click context menu on
// the chart body (a cell, the big-number value, or a canvas data point). Both
// open the same DrillDetailModal, which renders the underlying sample rows for
// the (optionally filtered) chart by calling the `/datasource/samples` API.
// ---------------------------------------------------------------------------
/**
* Open the "Drill to detail" item from a chart's "More Options" header menu.
* This is the whole-chart entry point (no row-level filters applied).
*/
async openDrillToDetailFromMenu(chartId: number): Promise<void> {
const moreOptions = new Button(
this.page,
this.getChart(chartId).getByLabel('More Options', { exact: true }),
);
await moreOptions.click();
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The DrillDetailModal dialog (titled "Drill to detail: <chart name>").
*/
drillModal(): DrillDetailModal {
return new DrillDetailModal(this.page);
}
/**
* Click the plain "Drill to detail" item in an open chart context menu
* (whole chart, no row-level filter).
*/
async contextMenuDrillToDetail(): Promise<void> {
await this.page
.getByRole('menuitem', { name: 'Drill to detail', exact: true })
.click();
}
/**
* The "Drill to detail by" submenu parent (title) in an open context menu.
* Targeted by its submenu-title element rather than role+name because antd
* appends the arrow-icon name ("right") to the accessible name, and the leaf
* items ("Drill to detail by boy") would otherwise match a role+name lookup.
*/
drillBySubmenuTitle(): Locator {
return this.page.locator('.ant-dropdown-menu-submenu-title', {
hasText: 'Drill to detail by',
});
}
/**
* The chart context menu's Menu component, scoped to the open context
* menu's root. Used to open the "Drill to detail by" submenu robustly:
* plain hover is not reliably picked up by Ant Design's submenu trigger in
* headless Chromium, so this falls back to keyboard and dispatchEvent - see
* {@link Menu.openSubmenu}.
*/
private contextMenu(): Menu {
return new Menu(this.page, '[data-test="chart-context-menu"]');
}
/**
* Opens the "Drill to detail by" submenu and returns its popup, containing
* the leaf value items (e.g. "Drill to detail by boy").
*/
private openDrillBySubmenu(): Promise<Locator> {
return this.contextMenu().openSubmenu('Drill to detail by', {
popupSelector: '.chart-context-submenu',
});
}
/**
* From an open chart context menu, open the "Drill to detail by" submenu and
* click the entry for a specific value (e.g. "boy", "1965", "all").
*/
async contextMenuDrillToDetailBy(value: string): Promise<void> {
const popup = await this.openDrillBySubmenu();
// Use dispatchEvent instead of click to bypass viewport and pointer
// interception issues - see Menu.selectSubmenuItem.
await popup
.getByRole('menuitem', {
name: `Drill to detail by ${value}`,
exact: true,
})
.dispatchEvent('click');
}
/**
* From an open chart context menu, open "Drill to detail by" and return the
* concrete values offered by the submenu (e.g. ["1965", "boy"]), skipping the
* aggregate "all" entry. Used by canvas charts where the value under the
* cursor is data-dependent: the test drills by whatever the menu actually
* offers and asserts that same value round-trips into the modal, which keeps
* the assertion independent of exact pixel/slice geometry.
*
* Reads rendered (HTML-stripped) menu text rather than the item's
* `aria-label`, which carries the raw, unstripped formatted value
* (`useDrillDetailMenuItems`). The two only diverge for formatted values
* that contain HTML markup; callers pass the returned value both to
* `contextMenuDrillToDetailBy` (accessible-name lookup) and to a
* displayed-text assertion on the modal's filter chip, so a value straddling
* both uses only works when it's markup-free. Every value currently offered
* by this dashboard's charts is a plain string, so this hasn't been
* reachable in practice; revisit if a test starts exercising HTML-formatted
* dimension values.
*/
async drillByOfferedValues(): Promise<string[]> {
const popup = await this.openDrillBySubmenu();
const items = popup.locator('[role="menuitem"]');
await items.first().waitFor();
const labels = await items.allInnerTexts();
return labels
.map(l => l.replace(/^Drill to detail by\s*/i, '').trim())
.filter(v => v.length > 0 && v.toLowerCase() !== 'all');
}
}
@@ -1,747 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* E2E migration of the Cypress "Drill to detail modal" suite
* (dashboard/drilltodetail.test.ts).
*
* Drill to detail lets a viewer open a modal of the underlying sample rows for a
* chart optionally filtered to a single data point by either the chart's
* "More Options" header menu or a right-click context menu on the chart body.
* The modal calls the real `/datasource/samples` API, so this is genuinely
* end-to-end: each test API-builds a hermetic dashboard from the `birth_names`
* dataset, renders it in the browser, drives the real menus, and asserts the
* resulting backend round-trip (the samples POST and the filter the modal
* applies).
*
* Why the original suite was fully `describe.skip`:
* "it has issues with autoscrolling and the locked title flakes intricately
* when the rightClick is obstructed by the title."
* That failure mode is Cypress-specific Cypress auto-scrolls the target under
* the sticky chart header before every action. Playwright scrolls once and the
* target stays put, so the entry points are portable here.
*
* What is migrated, and how it is kept deterministic:
* - Modal mechanics (open from header menu, pagination, reload-resets-page)
* and the no-filter big-number drill use stable DOM elements.
* - Table and Pivot drills right-click real DOM cells (no canvas pixels).
* - Canvas (echarts) charts Pie, Line, Scatter, generic/smooth/step
* time-series, Mixed, Box plot, Funnel, Gauge, Treemap DID rely on
* hard-coded pixel coordinates in Cypress to land on a specific slice/point.
* Instead of reproducing those brittle pixels, these tests scan a stable
* region of the canvas (see `rightClickCanvasDatum`), read whichever value
* the drill submenu actually offers for the point under the cursor, drill by
* that value, and assert the SAME value round-trips into the modal filter.
* This exercises the full canvas contextmenu datum samples pipeline
* while staying independent of exact geometry. `Big Number with Trendline`
* drills the whole chart (no datum filter), like `Big Number`.
*
* Excluded (kept out, matching the original's own `describe.skip`s): Bar, Area,
* World Map, Radar skipped upstream for chart-specific reasons.
*/
import {
testWithAssets,
expect,
type TestAssets,
} from '../../helpers/fixtures';
import type { Page, TestInfo } from '@playwright/test';
import { TIMEOUT } from '../../utils/constants';
import { DashboardPage } from '../../pages/DashboardPage';
import { createDashboardWithCharts } from './dashboard-test-helpers';
const DATASET_NAME = 'birth_names';
/**
* Parse a RowCountLabel value ("75.7k rows", "1,234 rows") into a number so
* tests can assert the *invariant* (filtered < unfiltered) without hard-coding
* the dataset-specific totals the original Cypress suite baked in.
*/
function parseRowCount(text: string): number {
const m = text.match(/([\d.,]+)\s*([kKmM]?)/);
if (!m) return NaN;
let n = parseFloat(m[1].replace(/,/g, ''));
const suffix = m[2].toLowerCase();
if (suffix === 'k') n *= 1e3;
if (suffix === 'm') n *= 1e6;
return n;
}
interface ChartSpec {
vizType: string;
chartNamePrefix: string;
params: Record<string, unknown>;
}
/**
* API-build a hermetic single-chart dashboard from birth_names and return its
* dashboard and chart ids. Thin single-chart wrapper around
* `createDashboardWithCharts`, the build helper shared by the other migrated
* dashboard specs reused here rather than hand-rolling position-json and id
* extraction again.
*/
async function buildSingleChartDashboard(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
spec: ChartSpec,
): Promise<{ dashboardId: number; chartId: number }> {
const { dashboardId, charts } = await createDashboardWithCharts(
page,
testAssets,
testInfo,
{
datasetName: DATASET_NAME,
chartNamePrefix: spec.chartNamePrefix,
dashboardTitlePrefix: spec.chartNamePrefix,
chartSpecs: [{ viz_type: spec.vizType, params: spec.params }],
},
);
return { dashboardId, chartId: charts[0].id };
}
/**
* Right-click an echarts canvas until a data point is hit i.e. until the
* context menu offers an *enabled* "Drill to detail by" submenu (a miss renders
* that item disabled, as a plain menu item rather than a submenu title).
*
* echarts renders to a single canvas, so there is no per-datum DOM element to
* target and the exact pixel of a mark depends on chart geometry (donut hole,
* legend size, axis padding). Rather than hard-code Cypress's brittle pixel
* coordinates, this scans a small set of candidate points a radial ring for
* pie/radial charts, a grid for cartesian charts and stops at the first that
* lands on a mark. The drill value is then whatever that mark represents, so the
* caller asserts a value round-trip rather than a specific geometry.
*/
async function rightClickCanvasDatum(
page: Page,
dashboard: DashboardPage,
canvas: ReturnType<Page['locator']>,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const box = await canvas.boundingBox();
if (!box) throw new Error('canvas has no bounding box');
const ringPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
const cx = box.width / 2;
const cy = box.height / 2;
const minSide = Math.min(box.width, box.height);
for (const rf of [0.3, 0.22, 0.38]) {
for (let a = 0; a < 360; a += 45) {
const rad = (a * Math.PI) / 180;
pts.push({
x: cx + Math.cos(rad) * minSide * rf,
y: cy + Math.sin(rad) * minSide * rf,
});
}
}
return pts;
};
const gridPoints = (): Array<{ x: number; y: number }> => {
const pts: Array<{ x: number; y: number }> = [];
for (const yf of [0.5, 0.4, 0.6, 0.3, 0.7]) {
for (const xf of [0.3, 0.45, 0.6, 0.2, 0.75]) {
pts.push({ x: box.width * xf, y: box.height * yf });
}
}
return pts;
};
// 'dense' merges both scans for radial/stacked shapes (gauge, funnel, box
// plot) whose drillable marks don't fall neatly on a single ring or grid.
let candidates: Array<{ x: number; y: number }>;
if (pattern === 'ring') candidates = ringPoints();
else if (pattern === 'grid') candidates = gridPoints();
else candidates = [...gridPoints(), ...ringPoints()];
// The submenu *title* element only exists when "Drill to detail by" is an
// enabled submenu (a real datum was hit); a miss renders a disabled item.
const enabledDrillBy = dashboard.drillBySubmenuTitle();
const contextMenu = page.locator('[data-test="chart-context-menu"]');
for (const pt of candidates) {
await canvas.click({ button: 'right', position: pt });
const hit = await enabledDrillBy
.waitFor({ state: 'visible', timeout: 400 })
.then(() => true)
.catch(() => false);
if (hit) return;
await page.keyboard.press('Escape');
// Wait for the portal to actually close before the next right-click;
// otherwise a still-open (or mid-close-animation) menu can make the
// next click/locator behave nondeterministically on slower/contended CI.
await contextMenu
.waitFor({ state: 'hidden', timeout: 400 })
.catch(() => {});
}
throw new Error(
`no drillable datum found on canvas after scanning ${candidates.length} points`,
);
}
/** A samples POST fired (proves the modal hit the real backend). */
function expectSamplesPost(page: Page) {
return page.waitForResponse(
r =>
r.url().includes('/datasource/samples') &&
r.request().method() === 'POST',
{ timeout: TIMEOUT.API_RESPONSE },
);
}
async function loadDashboardWithChart(
dashboard: DashboardPage,
dashboardId: number,
chartId: number,
): Promise<void> {
await dashboard.gotoById(dashboardId);
await dashboard.waitForLoad();
await dashboard
.getChart(chartId)
.locator('[data-test="chart-container"]')
.first()
.waitFor({ state: 'visible', timeout: TIMEOUT.QUERY_EXECUTION });
await dashboard.waitForChartsToLoad();
}
/**
* From an already-open "Drill to detail by" submenu, drill by the first
* offered value and assert that same value lands in the modal filter. The
* shared tail of every "drill by whatever value is under the cursor" test
* canvas charts and the pivot table alike, which differ only in how they open
* the submenu in the first place.
*/
async function drillByFirstOfferedValueAndAssert(
page: Page,
dashboard: DashboardPage,
): Promise<void> {
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThan(0);
const [value] = offered;
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
await expect(dashboard.drillModal().filterValues.first()).toContainText(
value,
);
}
/**
* Full canvas-drill round-trip for an echarts (canvas-rendered) chart: build a
* hermetic single-chart dashboard, render it, right-click a real datum, drill by
* whatever value the submenu offers under the cursor, and assert that same value
* lands in the modal filter. Geometry-independent see rightClickCanvasDatum.
* Reused across every canvas viz type so each migrated chart is a thin caller.
*/
async function expectCanvasDrillByValueRoundTrips(
page: Page,
testAssets: TestAssets,
testInfo: TestInfo,
spec: ChartSpec,
pattern: 'ring' | 'grid' | 'dense',
): Promise<void> {
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testInfo,
spec,
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
const canvas = dashboard.getChart(chartId).locator('canvas').first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, dashboard, canvas, pattern);
await drillByFirstOfferedValueAndAssert(page, dashboard);
}
/**
* Right-click a big-number chart's rendered value to open its context menu,
* drill the whole chart (no row/point filter), and assert the modal opened
* with no filter tags and a real row count. Shared by Big Number and Big
* Number with Trendline, which differ only in their chart params.
*/
async function expectWholeChartDrillFromContextMenu(
page: Page,
dashboard: DashboardPage,
chartId: number,
): Promise<void> {
const samples = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.locator('.header-line')
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetail();
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
// Whole-chart drill: no per-value filter tag.
await expect(dashboard.drillModal().filterValues).toHaveCount(0);
await expect(dashboard.drillModal().rowCountLabel).toContainText('rows');
}
// Shared form-data fragment for the echarts time-series family (line/scatter/
// generic/smooth/step): one temporal axis, one metric, split by gender series.
const TIMESERIES_PARAMS = {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
row_limit: 1000,
};
testWithAssets(
'drill-to-detail modal: opens from the header menu, paginates, and reload resets to page 1',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number_total',
chartNamePrefix: 'drill_bignum',
params: { metric: 'count', adhoc_filters: [] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
// Open the modal from the chart's "More Options" header menu.
const samplesOnOpen = expectSamplesPost(page);
await dashboard.openDrillToDetailFromMenu(chartId);
await samplesOnOpen;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.element).toContainText('Drill to detail:');
// The metadata bar and a real row count prove the modal loaded backend data.
await expect(modal.metadataBar).toBeVisible();
await expect(modal.rowCountLabel).toContainText('rows');
// No drill filter was applied (whole-chart drill).
await expect(modal.filterValues).toHaveCount(0);
// The full dataset spans multiple pages, and the grid has rendered rows.
expect(await modal.pageItems.count()).toBeGreaterThan(1);
await expect(modal.gridCells.first()).toBeVisible();
await expect(modal.activePageItem).toContainText('1');
// Paginate forward: clicking page 2 fires a real samples fetch and moves the
// active page to 2.
const samplesOnPage2 = expectSamplesPost(page);
await modal.goToPage(2);
await samplesOnPage2;
await expect(modal.activePageItem).toContainText('2');
// Reload re-fetches and resets back to the first page.
const samplesOnReload = expectSamplesPost(page);
await modal.reload();
await samplesOnReload;
await expect(modal.activePageItem).toContainText('1');
},
);
testWithAssets(
'drill-to-detail modal: big number value right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number_total',
chartNamePrefix: 'drill_bignum_rc',
params: { metric: 'count', adhoc_filters: [] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
},
);
testWithAssets(
'drill-to-detail modal: table cell right-click drills by that value and clearing the filter restores the full set',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'table',
chartNamePrefix: 'drill_table',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
// Right-click the "boy" dimension cell and drill by it.
const samplesOnDrill = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.getByText('boy', { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy('boy');
await samplesOnDrill;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText('boy');
const filteredCount = parseRowCount(await modal.rowCountLabel.innerText());
expect(filteredCount).toBeGreaterThan(0);
// Clearing the filter reloads the samples and restores the larger, unfiltered total.
const samplesOnClear = expectSamplesPost(page);
await modal.clearFirstFilter();
await samplesOnClear;
await expect(modal.filterValues).toHaveCount(0);
await expect
.poll(async () => parseRowCount(await modal.rowCountLabel.innerText()))
.toBeGreaterThan(filteredCount);
},
);
testWithAssets(
'drill-to-detail modal: pivot table cell right-click drills by the cell value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'pivot_table_v2',
chartNamePrefix: 'drill_pivot',
params: {
groupbyRows: ['gender'],
groupbyColumns: [],
metrics: ['count'],
aggregateFunction: 'Sum',
rowTotals: false,
colTotals: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await dashboard
.getChart(chartId)
.locator('[role="gridcell"]')
.first()
.click({ button: 'right' });
// The cell's row dimension determines the offered value; drill by it and
// assert the same value lands in the modal filter.
await drillByFirstOfferedValueAndAssert(page, dashboard);
},
);
testWithAssets(
'drill-to-detail modal: pie slice right-click (canvas) drills by the slice value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Pie is a donut by default (center is a hole), so scan the ring for a slice.
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'pie',
chartNamePrefix: 'drill_pie',
params: { groupby: ['gender'], metric: 'count' },
},
'ring',
);
},
);
testWithAssets(
'drill-to-detail modal: line chart point right-click (canvas) drills by the point value',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
// Scan the plot grid for a point on one of the series lines.
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'echarts_timeseries_line',
chartNamePrefix: 'drill_line',
params: TIMESERIES_PARAMS,
},
'grid',
);
},
);
testWithAssets(
'drill-to-detail modal: big number with trendline right-click drills the whole chart (no filter)',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'big_number',
chartNamePrefix: 'drill_bignum_trend',
params: {
metric: 'count',
x_axis: 'ds',
time_grain_sqla: 'P1Y',
adhoc_filters: [],
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
await expectWholeChartDrillFromContextMenu(page, dashboard, chartId);
},
);
interface CanvasDrillCase {
title: string;
spec: ChartSpec;
pattern: 'ring' | 'grid' | 'dense';
}
// Every remaining canvas (echarts) chart is a thin caller of
// expectCanvasDrillByValueRoundTrips, differing only in viz type, chart
// params, and which point-scan pattern finds a drillable mark.
const CANVAS_DRILL_CASES: CanvasDrillCase[] = [
{
title:
'drill-to-detail modal: scatter chart point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_scatter',
chartNamePrefix: 'drill_scatter',
// Enlarge the markers so a region scan reliably lands on a point;
// scatter's default dots are a few pixels wide and a sparse grid misses
// them.
params: { ...TIMESERIES_PARAMS, markerSize: 20 },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: generic time-series point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries',
chartNamePrefix: 'drill_generic',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: smooth line point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_smooth',
chartNamePrefix: 'drill_smooth',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: step line point right-click (canvas) drills by the point value',
spec: {
vizType: 'echarts_timeseries_step',
chartNamePrefix: 'drill_step',
params: TIMESERIES_PARAMS,
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: mixed time-series point right-click (canvas) drills by the point value',
spec: {
vizType: 'mixed_timeseries',
chartNamePrefix: 'drill_mixed',
params: {
x_axis: 'ds',
time_grain_sqla: 'P1Y',
metrics: ['count'],
groupby: ['gender'],
metrics_b: ['count'],
groupby_b: ['gender'],
row_limit: 1000,
},
},
pattern: 'grid',
},
{
title:
'drill-to-detail modal: box plot right-click (canvas) drills by the box value',
spec: {
vizType: 'box_plot',
chartNamePrefix: 'drill_boxplot',
params: {
groupby: ['gender'],
metrics: ['count'],
columns: ['ds'],
},
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: funnel segment right-click (canvas) drills by the segment value',
spec: {
vizType: 'funnel',
chartNamePrefix: 'drill_funnel',
params: { groupby: ['gender'], metric: 'count' },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: gauge right-click (canvas) drills by the gauge value',
spec: {
vizType: 'gauge_chart',
chartNamePrefix: 'drill_gauge',
params: { groupby: ['gender'], metric: 'count' },
},
pattern: 'dense',
},
{
title:
'drill-to-detail modal: treemap tile right-click (canvas) drills by the tile value',
spec: {
vizType: 'treemap_v2',
chartNamePrefix: 'drill_treemap',
params: { metric: 'count', groupby: ['gender'] },
},
pattern: 'dense',
},
];
for (const { title, spec, pattern } of CANVAS_DRILL_CASES) {
testWithAssets(title, async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
await expectCanvasDrillByValueRoundTrips(
page,
testAssets,
testWithAssets.info(),
spec,
pattern,
);
});
}
testWithAssets(
'drill-to-detail modal: drilling a time-series point "by all" applies every dimension of that point',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'echarts_timeseries_line',
chartNamePrefix: 'drill_all',
// Two groupby dimensions so each point genuinely carries more than one
// drillable value — the whole point of "Drill to detail by all".
params: { ...TIMESERIES_PARAMS, groupby: ['gender', 'state'] },
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
const canvas = dashboard.getChart(chartId).locator('canvas').first();
await expect(canvas).toBeVisible();
await rightClickCanvasDatum(page, dashboard, canvas, 'grid');
// A line point carries two dimensions (the temporal value and the gender
// series), so "Drill to detail by all" must apply both as filters.
const offered = await dashboard.drillByOfferedValues();
expect(offered.length).toBeGreaterThanOrEqual(2);
const samples = expectSamplesPost(page);
await dashboard.contextMenuDrillToDetailBy('all');
await samples;
await expect(dashboard.drillModal().element).toBeVisible();
expect(
await dashboard.drillModal().filterValues.count(),
).toBeGreaterThanOrEqual(2);
},
);
testWithAssets(
'drill-to-detail modal: table drills correctly by each of multiple dimension values',
async ({ page, testAssets }) => {
testWithAssets.setTimeout(TIMEOUT.SLOW_TEST);
const dashboard = new DashboardPage(page);
const { dashboardId, chartId } = await buildSingleChartDashboard(
page,
testAssets,
testWithAssets.info(),
{
vizType: 'table',
chartNamePrefix: 'drill_table_multi',
params: {
query_mode: 'aggregate',
groupby: ['gender'],
metrics: ['count'],
row_limit: 100,
server_pagination: false,
},
},
);
await loadDashboardWithChart(dashboard, dashboardId, chartId);
for (const value of ['boy', 'girl']) {
const samples = expectSamplesPost(page);
await dashboard
.getChart(chartId)
.getByText(value, { exact: true })
.first()
.click({ button: 'right' });
await dashboard.contextMenuDrillToDetailBy(value);
await samples;
const modal = dashboard.drillModal();
await expect(modal.element).toBeVisible();
await expect(modal.filterValues.first()).toContainText(value);
await modal.close();
}
},
);
@@ -38,7 +38,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -696,16 +696,13 @@ export const buildQueryUncached: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate,
);
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
// Raw-mode summary columns have no metric of their own to preserve, so
// ORIGINAL has nothing to fall back to; sum them as before.
const rawSummaryAggregate =
totalsAggregate === 'ORIGINAL' ? 'SUM' : totalsAggregate;
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
const totalsMetrics =
rawSummaryColumns.length > 0
? rawSummaryColumns.map(columnName => ({
expressionType: 'SIMPLE' as const,
aggregate: rawSummaryAggregate,
aggregate: totalsAggregate,
column: { column_name: columnName },
label: columnName,
}))
@@ -503,18 +503,14 @@ const config: ControlPanelConfig = {
label: t('Summary aggregation'),
renderTrigger: true,
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -1561,7 +1561,7 @@ describe('plugin-chart-ag-grid-table', () => {
expect(queries[1].metrics).toEqual(['count']);
});
test("defaults aggregate-mode totals to the metric's own aggregate", () => {
test('defaults aggregate-mode totals to SUM for a simple metric', () => {
const simpleMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'sales' },
@@ -1580,29 +1580,9 @@ describe('plugin-chart-ag-grid-table', () => {
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in aggregate-mode totals by default', () => {
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQuery(
{
viz_type: VizType.Table,
datasource: '11__table',
query_mode: QueryMode.Aggregate,
groupby: ['state'],
metrics: [countDistinctMetric],
show_totals: true,
},
{ ownState: {} },
);
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides aggregate-mode totals to AVG for a simple metric when totals_aggregate is set', () => {
@@ -105,7 +105,7 @@
"source": [
"## Download Data\n",
"\n",
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 \u2013 States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
"Download datasets (_Admin 0 - Countries_ in [1:10](https://www.naturalearthdata.com/downloads/10m-cultural-vectors/), and _Admin 1 States, Provinces_ in 1:10 and [1:50](https://www.naturalearthdata.com/downloads/50m-cultural-vectors/)) from Natural Earch Data:"
]
},
{
@@ -584,7 +584,7 @@
" </tr>\n",
" </tbody>\n",
"</table>\n",
"<p>9 rows \u00d7 121 columns</p>\n",
"<p>9 rows × 121 columns</p>\n",
"</div>"
],
"text/plain": [
@@ -926,33 +926,33 @@
" <td>11.0</td>\n",
" <td>11.0</td>\n",
" <td>Q34617</td>\n",
" <td>\u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646</td>\n",
" <td>\u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981</td>\n",
" <td>سان بيير وميكلون</td>\n",
" <td>সাঁ পিয়ের ও মিকলোঁ</td>\n",
" <td>Saint-Pierre und Miquelon</td>\n",
" <td>Saint Pierre and Miquelon</td>\n",
" <td>San Pedro y Miquel\u00f3n</td>\n",
" <td>San Pedro y Miquelón</td>\n",
" <td>Saint-Pierre-et-Miquelon</td>\n",
" <td>\u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd</td>\n",
" <td>\u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928</td>\n",
" <td>Saint-Pierre \u00e9s Miquelon</td>\n",
" <td>Σαιν-Πιερ και Μικελόν</td>\n",
" <td>सन्त पियर और मिकलान</td>\n",
" <td>Saint-Pierre és Miquelon</td>\n",
" <td>Saint Pierre dan Miquelon</td>\n",
" <td>Saint-Pierre e Miquelon</td>\n",
" <td>\u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6</td>\n",
" <td>\uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871</td>\n",
" <td>サンピエール島・ミクロン島</td>\n",
" <td>생피에르 미클롱</td>\n",
" <td>Saint-Pierre en Miquelon</td>\n",
" <td>Saint-Pierre i Miquelon</td>\n",
" <td>Saint-Pierre e Miquelon</td>\n",
" <td>\u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d</td>\n",
" <td>Сен-Пьер и Микелон</td>\n",
" <td>Saint-Pierre och Miquelon</td>\n",
" <td>Saint Pierre ve Miquelon</td>\n",
" <td>Saint-Pierre v\u00e0 Miquelon</td>\n",
" <td>\u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686</td>\n",
" <td>Saint-Pierre và Miquelon</td>\n",
" <td>圣皮埃尔和密克隆</td>\n",
" <td>1159315673</td>\n",
" <td>\u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df</td>\n",
" <td>\u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d</td>\n",
" <td>\u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646</td>\n",
" <td>\u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646</td>\n",
" <td>\u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6</td>\n",
" <td>סן-פייר ומיקלון</td>\n",
" <td>Сен-П'єр і Мікелон</td>\n",
" <td>سینٹ پیئر و میکیلون</td>\n",
" <td>سن پیر و میکلن</td>\n",
" <td>聖皮埃與密克隆群島</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
@@ -1051,33 +1051,33 @@
" <td>11.0</td>\n",
" <td>11.0</td>\n",
" <td>None</td>\n",
" <td>\u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f</td>\n",
" <td>\u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1</td>\n",
" <td>ميكلون ولانغليد</td>\n",
" <td>মিকুইলন-ল্যাংলেড</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquel\u00f3n-Langlade</td>\n",
" <td>Miquelón-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4</td>\n",
" <td>\u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921</td>\n",
" <td>Μικελόν-Λαγκλέιντ</td>\n",
" <td>मिकेलॉन-लैंगलेड</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9</td>\n",
" <td>\ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc</td>\n",
" <td>ミクロン=ラングラード</td>\n",
" <td>미클롱-랭글레이드</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquel\u00e3o-Langlade</td>\n",
" <td>\u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434</td>\n",
" <td>Miquelão-Langlade</td>\n",
" <td>Микелон-Ланглад</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>Miquelon-Langlade</td>\n",
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
" <td>密克隆-朗格拉德</td>\n",
" <td>1159315961</td>\n",
" <td>\u05de\u05d9\u05e8\u05d4</td>\n",
" <td>\u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435</td>\n",
" <td>\u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2</td>\n",
" <td>\u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f</td>\n",
" <td>\u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7</td>\n",
" <td>מירה</td>\n",
" <td>Міквелон-Лангладе</td>\n",
" <td>میکیولون لینگلاڈے</td>\n",
" <td>میکوئلون-لانگلید</td>\n",
" <td>密克隆-朗格拉德</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
" <td>None</td>\n",
@@ -1167,48 +1167,48 @@
"2177 PM.97501 None None 1.0 fra SB00 None \n",
"\n",
" min_label max_label min_zoom wikidataid name_ar \\\n",
"2176 11.0 11.0 11.0 Q34617 \u0633\u0627\u0646 \u0628\u064a\u064a\u0631 \u0648\u0645\u064a\u0643\u0644\u0648\u0646 \n",
"2177 11.0 11.0 11.0 None \u0645\u064a\u0643\u0644\u0648\u0646 \u0648\u0644\u0627\u0646\u063a\u0644\u064a\u062f \n",
"2176 11.0 11.0 11.0 Q34617 سان بيير وميكلون \n",
"2177 11.0 11.0 11.0 None ميكلون ولانغليد \n",
"\n",
" name_bn name_de \\\n",
"2176 \u09b8\u09be\u0981 \u09aa\u09bf\u09af\u09bc\u09c7\u09b0 \u0993 \u09ae\u09bf\u0995\u09b2\u09cb\u0981 Saint-Pierre und Miquelon \n",
"2177 \u09ae\u09bf\u0995\u09c1\u0987\u09b2\u09a8-\u09b2\u09cd\u09af\u09be\u0982\u09b2\u09c7\u09a1 Miquelon-Langlade \n",
"2176 সাঁ পিয়ের ও মিকলোঁ Saint-Pierre und Miquelon \n",
"2177 মিকুইলন-ল্যাংলেড Miquelon-Langlade \n",
"\n",
" name_en name_es \\\n",
"2176 Saint Pierre and Miquelon San Pedro y Miquel\u00f3n \n",
"2177 Miquelon-Langlade Miquel\u00f3n-Langlade \n",
"2176 Saint Pierre and Miquelon San Pedro y Miquelón \n",
"2177 Miquelon-Langlade Miquelón-Langlade \n",
"\n",
" name_fr name_el name_hi \\\n",
"2176 Saint-Pierre-et-Miquelon \u03a3\u03b1\u03b9\u03bd-\u03a0\u03b9\u03b5\u03c1 \u03ba\u03b1\u03b9 \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd \u0938\u0928\u094d\u0924 \u092a\u093f\u092f\u0930 \u0914\u0930 \u092e\u093f\u0915\u0932\u093e\u0928 \n",
"2177 Miquelon-Langlade \u039c\u03b9\u03ba\u03b5\u03bb\u03cc\u03bd-\u039b\u03b1\u03b3\u03ba\u03bb\u03ad\u03b9\u03bd\u03c4 \u092e\u093f\u0915\u0947\u0932\u0949\u0928-\u0932\u0948\u0902\u0917\u0932\u0947\u0921 \n",
"2176 Saint-Pierre-et-Miquelon Σαιν-Πιερ και Μικελόν सन्त पियर और मिकलान \n",
"2177 Miquelon-Langlade Μικελόν-Λαγκλέιντ मिकेलॉन-लैंगलेड \n",
"\n",
" name_hu name_id \\\n",
"2176 Saint-Pierre \u00e9s Miquelon Saint Pierre dan Miquelon \n",
"2176 Saint-Pierre és Miquelon Saint Pierre dan Miquelon \n",
"2177 Miquelon-Langlade Miquelon-Langlade \n",
"\n",
" name_it name_ja name_ko \\\n",
"2176 Saint-Pierre e Miquelon \u30b5\u30f3\u30d4\u30a8\u30fc\u30eb\u5cf6\u30fb\u30df\u30af\u30ed\u30f3\u5cf6 \uc0dd\ud53c\uc5d0\ub974 \ubbf8\ud074\ub871 \n",
"2177 Miquelon-Langlade \u30df\u30af\u30ed\u30f3\uff1d\u30e9\u30f3\u30b0\u30e9\u30fc\u30c9 \ubbf8\ud074\ub871-\ub7ad\uae00\ub808\uc774\ub4dc \n",
"2176 Saint-Pierre e Miquelon サンピエール島・ミクロン島 생피에르 미클롱 \n",
"2177 Miquelon-Langlade ミクロン=ラングラード 미클롱-랭글레이드 \n",
"\n",
" name_nl name_pl \\\n",
"2176 Saint-Pierre en Miquelon Saint-Pierre i Miquelon \n",
"2177 Miquelon-Langlade Miquelon-Langlade \n",
"\n",
" name_pt name_ru name_sv \\\n",
"2176 Saint-Pierre e Miquelon \u0421\u0435\u043d-\u041f\u044c\u0435\u0440 \u0438 \u041c\u0438\u043a\u0435\u043b\u043e\u043d Saint-Pierre och Miquelon \n",
"2177 Miquel\u00e3o-Langlade \u041c\u0438\u043a\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434 Miquelon-Langlade \n",
"2176 Saint-Pierre e Miquelon Сен-Пьер и Микелон Saint-Pierre och Miquelon \n",
"2177 Miquelão-Langlade Микелон-Ланглад Miquelon-Langlade \n",
"\n",
" name_tr name_vi name_zh \\\n",
"2176 Saint Pierre ve Miquelon Saint-Pierre v\u00e0 Miquelon \u5723\u76ae\u57c3\u5c14\u548c\u5bc6\u514b\u9686 \n",
"2177 Miquelon-Langlade Miquelon-Langlade \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 \n",
"2176 Saint Pierre ve Miquelon Saint-Pierre và Miquelon 圣皮埃尔和密克隆 \n",
"2177 Miquelon-Langlade Miquelon-Langlade 密克隆-朗格拉德 \n",
"\n",
" ne_id name_he name_uk name_ur \\\n",
"2176 1159315673 \u05e1\u05df-\u05e4\u05d9\u05d9\u05e8 \u05d5\u05de\u05d9\u05e7\u05dc\u05d5\u05df \u0421\u0435\u043d-\u041f'\u0454\u0440 \u0456 \u041c\u0456\u043a\u0435\u043b\u043e\u043d \u0633\u06cc\u0646\u0679 \u067e\u06cc\u0626\u0631 \u0648 \u0645\u06cc\u06a9\u06cc\u0644\u0648\u0646 \n",
"2177 1159315961 \u05de\u05d9\u05e8\u05d4 \u041c\u0456\u043a\u0432\u0435\u043b\u043e\u043d-\u041b\u0430\u043d\u0433\u043b\u0430\u0434\u0435 \u0645\u06cc\u06a9\u06cc\u0648\u0644\u0648\u0646 \u0644\u06cc\u0646\u06af\u0644\u0627\u0688\u06d2 \n",
"2176 1159315673 סן-פייר ומיקלון Сен-П'єр і Мікелон سینٹ پیئر و میکیلون \n",
"2177 1159315961 מירה Міквелон-Лангладе میکیولون لینگلاڈے \n",
"\n",
" name_fa name_zht FCLASS_ISO FCLASS_US FCLASS_FR FCLASS_RU \\\n",
"2176 \u0633\u0646 \u067e\u06cc\u0631 \u0648 \u0645\u06cc\u06a9\u0644\u0646 \u8056\u76ae\u57c3\u8207\u5bc6\u514b\u9686\u7fa4\u5cf6 None None None None \n",
"2177 \u0645\u06cc\u06a9\u0648\u0626\u0644\u0648\u0646-\u0644\u0627\u0646\u06af\u0644\u06cc\u062f \u5bc6\u514b\u9686-\u6717\u683c\u62c9\u5fb7 None None None None \n",
"2176 سن پیر و میکلن 聖皮埃與密克隆群島 None None None None \n",
"2177 میکوئلون-لانگلید 密克隆-朗格拉德 None None None None \n",
"\n",
" FCLASS_ES FCLASS_CN FCLASS_TW FCLASS_IN FCLASS_NP FCLASS_PK FCLASS_DE \\\n",
"2176 None None None None None None None \n",
@@ -1330,7 +1330,7 @@
" 'costa rica',\n",
" 'croatia',\n",
" 'cuba',\n",
" 'cura\u00e7ao',\n",
" 'curaçao',\n",
" 'cyprus',\n",
" 'czech republic',\n",
" 'denmark',\n",
@@ -1343,7 +1343,7 @@
" 'equatorial guinea',\n",
" 'eritrea',\n",
" 'estonia',\n",
" # 'eswatini', # not sure why this doesn't work \u2014 Swaziland isn't available to alias, either.\n",
" # 'eswatini', # not sure why this doesn't work Swaziland isn't available to alias, either.\n",
" 'ethiopia',\n",
" 'falkland islands',\n",
" 'faroe islands',\n",
@@ -1443,7 +1443,7 @@
" 'portugal',\n",
" 'puerto rico',\n",
" 'qatar',\n",
" # 'r\u00e9union', # part of France, in Natural Earth data\n",
" # 'réunion', # part of France, in Natural Earth data\n",
" 'republic of serbia',\n",
" 'romania',\n",
" 'russia',\n",
@@ -1911,34 +1911,34 @@
" <td>9.0</td>\n",
" <td>1159320473</td>\n",
" <td>Q8646</td>\n",
" <td>\u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a</td>\n",
" <td>\u09b9\u0982\u0995\u0982</td>\n",
" <td>هونغ كونغ</td>\n",
" <td>হংকং</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0647\u0646\u06af \u06a9\u0646\u06af</td>\n",
" <td>هنگ کنگ</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba</td>\n",
" <td>\u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2</td>\n",
" <td>\u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917</td>\n",
" <td>Χονγκ Κονγκ</td>\n",
" <td>הונג קונג</td>\n",
" <td>हांगकांग</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>\ud64d\ucf69</td>\n",
" <td>香港</td>\n",
" <td>홍콩</td>\n",
" <td>Hongkong</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
" <td>Гонконг</td>\n",
" <td>Hongkong</td>\n",
" <td>Hong Kong</td>\n",
" <td>\u0413\u043e\u043d\u043a\u043e\u043d\u0433</td>\n",
" <td>\u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af</td>\n",
" <td>H\u1ed3ng K\u00f4ng</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>\u9999\u6e2f</td>\n",
" <td>Гонконг</td>\n",
" <td>ہانگ کانگ</td>\n",
" <td>Hồng Kông</td>\n",
" <td>香港</td>\n",
" <td>香港</td>\n",
" <td>MULTIPOLYGON (((114.22983 22.55581, 114.23471 ...</td>\n",
" <td>\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a</td>\n",
" <td>香港特别行政区</td>\n",
" <td>CN-91</td>\n",
" </tr>\n",
" <tr>\n",
@@ -1965,34 +1965,34 @@
" <td>8.0</td>\n",
" <td>1159321335</td>\n",
" <td>Q865</td>\n",
" <td>\u062a\u0627\u064a\u0648\u0627\u0646</td>\n",
" <td>\u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8</td>\n",
" <td>تايوان</td>\n",
" <td>তাইওয়ান</td>\n",
" <td>Republik China</td>\n",
" <td>Taiwan</td>\n",
" <td>Rep\u00fablica de China</td>\n",
" <td>\u062a\u0627\u06cc\u0648\u0627\u0646</td>\n",
" <td>Ta\u00efwan</td>\n",
" <td>\u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2</td>\n",
" <td>\u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df</td>\n",
" <td>\u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f</td>\n",
" <td>K\u00ednai K\u00f6zt\u00e1rsas\u00e1g</td>\n",
" <td>República de China</td>\n",
" <td>تایوان</td>\n",
" <td>Taïwan</td>\n",
" <td>Δημοκρατία της Κίνας</td>\n",
" <td>טאיוואן</td>\n",
" <td>चीनी गणराज्य</td>\n",
" <td>Kínai Köztársaság</td>\n",
" <td>Taiwan</td>\n",
" <td>Taiwan</td>\n",
" <td>\u4e2d\u83ef\u6c11\u56fd</td>\n",
" <td>\uc911\ud654\ubbfc\uad6d</td>\n",
" <td>中華民国</td>\n",
" <td>중화민국</td>\n",
" <td>Taiwan</td>\n",
" <td>Republika Chi\u0144ska</td>\n",
" <td>Republika Chińska</td>\n",
" <td>Taiwan</td>\n",
" <td>\u0422\u0430\u0439\u0432\u0430\u043d\u044c</td>\n",
" <td>Тайвань</td>\n",
" <td>Taiwan</td>\n",
" <td>\u00c7in Cumhuriyeti</td>\n",
" <td>\u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439</td>\n",
" <td>\u062a\u0627\u0626\u06cc\u0648\u0627\u0646</td>\n",
" <td>\u0110\u00e0i Loan</td>\n",
" <td>\u4e2d\u534e\u6c11\u56fd</td>\n",
" <td>\u4e2d\u83ef\u6c11\u570b</td>\n",
" <td>Çin Cumhuriyeti</td>\n",
" <td>Республіка Китай</td>\n",
" <td>تائیوان</td>\n",
" <td>Đài Loan</td>\n",
" <td>中华民国</td>\n",
" <td>中華民國</td>\n",
" <td>MULTIPOLYGON (((121.90577 24.9501, 121.83473 2...</td>\n",
" <td>\u4e2d\u56fd\u53f0\u6e7e</td>\n",
" <td>中国台湾</td>\n",
" <td>CN-71</td>\n",
" </tr>\n",
" <tr>\n",
@@ -2019,34 +2019,34 @@
" <td>9.0</td>\n",
" <td>1159320475</td>\n",
" <td>Q14773</td>\n",
" <td>\u0645\u0627\u0643\u0627\u0648</td>\n",
" <td>\u09ae\u09be\u0995\u09be\u0993</td>\n",
" <td>ماكاو</td>\n",
" <td>মাকাও</td>\n",
" <td>Macau</td>\n",
" <td>Macau</td>\n",
" <td>Macao</td>\n",
" <td>\u0645\u0627\u06a9\u0627\u0626\u0648</td>\n",
" <td>ماکائو</td>\n",
" <td>Macao</td>\n",
" <td>\u039c\u03b1\u03ba\u03ac\u03bf\u03c5</td>\n",
" <td>\u05de\u05e7\u05d0\u05d5</td>\n",
" <td>\u092e\u0915\u093e\u0909</td>\n",
" <td>Maka\u00f3</td>\n",
" <td>Μακάου</td>\n",
" <td>מקאו</td>\n",
" <td>मकाउ</td>\n",
" <td>Makaó</td>\n",
" <td>Makau</td>\n",
" <td>Macao</td>\n",
" <td>\u30de\u30ab\u30aa</td>\n",
" <td>\ub9c8\uce74\uc624</td>\n",
" <td>マカオ</td>\n",
" <td>마카오</td>\n",
" <td>Macau</td>\n",
" <td>Makau</td>\n",
" <td>Macau</td>\n",
" <td>\u041c\u0430\u043a\u0430\u043e</td>\n",
" <td>Макао</td>\n",
" <td>Macao</td>\n",
" <td>Makao</td>\n",
" <td>\u0410\u043e\u043c\u0438\u043d\u044c</td>\n",
" <td>\u0645\u06a9\u0627\u0624</td>\n",
" <td>Аоминь</td>\n",
" <td>مکاؤ</td>\n",
" <td>Ma Cao</td>\n",
" <td>\u6fb3\u95e8</td>\n",
" <td>\u6fb3\u9580</td>\n",
" <td>澳门</td>\n",
" <td>澳門</td>\n",
" <td>MULTIPOLYGON (((113.5586 22.16303, 113.56943 2...</td>\n",
" <td>\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a</td>\n",
" <td>澳门特别行政区</td>\n",
" <td>CN-92</td>\n",
" </tr>\n",
" </tbody>\n",
@@ -2070,34 +2070,34 @@
"2 4 3 MO 20070017 5 0.0 4.0 \n",
"\n",
" max_label ne_id wikidataid name_ar name_bn name_de \\\n",
"0 9.0 1159320473 Q8646 \u0647\u0648\u0646\u063a \u0643\u0648\u0646\u063a \u09b9\u0982\u0995\u0982 Hongkong \n",
"1 8.0 1159321335 Q865 \u062a\u0627\u064a\u0648\u0627\u0646 \u09a4\u09be\u0987\u0993\u09af\u09bc\u09be\u09a8 Republik China \n",
"2 9.0 1159320475 Q14773 \u0645\u0627\u0643\u0627\u0648 \u09ae\u09be\u0995\u09be\u0993 Macau \n",
"0 9.0 1159320473 Q8646 هونغ كونغ হংকং Hongkong \n",
"1 8.0 1159321335 Q865 تايوان তাইওয়ান Republik China \n",
"2 9.0 1159320475 Q14773 ماكاو মাকাও Macau \n",
"\n",
" name_en name_es name_fa name_fr name_el \\\n",
"0 Hong Kong Hong Kong \u0647\u0646\u06af \u06a9\u0646\u06af Hong Kong \u03a7\u03bf\u03bd\u03b3\u03ba \u039a\u03bf\u03bd\u03b3\u03ba \n",
"1 Taiwan Rep\u00fablica de China \u062a\u0627\u06cc\u0648\u0627\u0646 Ta\u00efwan \u0394\u03b7\u03bc\u03bf\u03ba\u03c1\u03b1\u03c4\u03af\u03b1 \u03c4\u03b7\u03c2 \u039a\u03af\u03bd\u03b1\u03c2 \n",
"2 Macau Macao \u0645\u0627\u06a9\u0627\u0626\u0648 Macao \u039c\u03b1\u03ba\u03ac\u03bf\u03c5 \n",
"0 Hong Kong Hong Kong هنگ کنگ Hong Kong Χονγκ Κονγκ \n",
"1 Taiwan República de China تایوان Taïwan Δημοκρατία της Κίνας \n",
"2 Macau Macao ماکائو Macao Μακάου \n",
"\n",
" name_he name_hi name_hu name_id name_it name_ja \\\n",
"0 \u05d4\u05d5\u05e0\u05d2 \u05e7\u05d5\u05e0\u05d2 \u0939\u093e\u0902\u0917\u0915\u093e\u0902\u0917 Hongkong Hong Kong Hong Kong \u9999\u6e2f \n",
"1 \u05d8\u05d0\u05d9\u05d5\u05d5\u05d0\u05df \u091a\u0940\u0928\u0940 \u0917\u0923\u0930\u093e\u091c\u094d\u092f K\u00ednai K\u00f6zt\u00e1rsas\u00e1g Taiwan Taiwan \u4e2d\u83ef\u6c11\u56fd \n",
"2 \u05de\u05e7\u05d0\u05d5 \u092e\u0915\u093e\u0909 Maka\u00f3 Makau Macao \u30de\u30ab\u30aa \n",
"0 הונג קונג हांगकांग Hongkong Hong Kong Hong Kong 香港 \n",
"1 טאיוואן चीनी गणराज्य Kínai Köztársaság Taiwan Taiwan 中華民国 \n",
"2 מקאו मकाउ Makaó Makau Macao マカオ \n",
"\n",
" name_ko name_nl name_pl name_pt name_ru name_sv \\\n",
"0 \ud64d\ucf69 Hongkong Hongkong Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 Hongkong \n",
"1 \uc911\ud654\ubbfc\uad6d Taiwan Republika Chi\u0144ska Taiwan \u0422\u0430\u0439\u0432\u0430\u043d\u044c Taiwan \n",
"2 \ub9c8\uce74\uc624 Macau Makau Macau \u041c\u0430\u043a\u0430\u043e Macao \n",
"0 홍콩 Hongkong Hongkong Hong Kong Гонконг Hongkong \n",
"1 중화민국 Taiwan Republika Chińska Taiwan Тайвань Taiwan \n",
"2 마카오 Macau Makau Macau Макао Macao \n",
"\n",
" name_tr name_uk name_ur name_vi name_zh_x name_zht \\\n",
"0 Hong Kong \u0413\u043e\u043d\u043a\u043e\u043d\u0433 \u06c1\u0627\u0646\u06af \u06a9\u0627\u0646\u06af H\u1ed3ng K\u00f4ng \u9999\u6e2f \u9999\u6e2f \n",
"1 \u00c7in Cumhuriyeti \u0420\u0435\u0441\u043f\u0443\u0431\u043b\u0456\u043a\u0430 \u041a\u0438\u0442\u0430\u0439 \u062a\u0627\u0626\u06cc\u0648\u0627\u0646 \u0110\u00e0i Loan \u4e2d\u534e\u6c11\u56fd \u4e2d\u83ef\u6c11\u570b \n",
"2 Makao \u0410\u043e\u043c\u0438\u043d\u044c \u0645\u06a9\u0627\u0624 Ma Cao \u6fb3\u95e8 \u6fb3\u9580 \n",
"0 Hong Kong Гонконг ہانگ کانگ Hồng Kông 香港 香港 \n",
"1 Çin Cumhuriyeti Республіка Китай تائیوان Đài Loan 中华民国 中華民國 \n",
"2 Makao Аоминь مکاؤ Ma Cao 澳门 澳門 \n",
"\n",
" geometry name_zh_y iso_3166_2 \n",
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... \u9999\u6e2f\u7279\u522b\u884c\u653f\u533a CN-91 \n",
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... \u4e2d\u56fd\u53f0\u6e7e CN-71 \n",
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... \u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a CN-92 "
"0 MULTIPOLYGON (((114.22983 22.55581, 114.23471 ... 香港特别行政区 CN-91 \n",
"1 MULTIPOLYGON (((121.90577 24.9501, 121.83473 2... 中国台湾 CN-71 \n",
"2 MULTIPOLYGON (((113.5586 22.16303, 113.56943 2... 澳门特别行政区 CN-92 "
]
},
"execution_count": 14,
@@ -2114,7 +2114,7 @@
"china_sars = china_sars.merge(pd.DataFrame(\n",
" data={\n",
" \"name_en\": [\"Taiwan\", \"Hong Kong\", \"Macau\"],\n",
" \"name_zh\": [\"\u4e2d\u56fd\u53f0\u6e7e\", \"\u9999\u6e2f\u7279\u522b\u884c\u653f\u533a\", \"\u6fb3\u95e8\u7279\u522b\u884c\u653f\u533a\"],\n",
" \"name_zh\": [\"中国台湾\", \"香港特别行政区\", \"澳门特别行政区\"],\n",
" \"iso_3166_2\": [\"CN-71\", \"CN-91\", \"CN-92\"],\n",
" },\n",
"), on=\"name_en\", how=\"left\")\n",
@@ -2252,7 +2252,7 @@
" }\n",
")[[\"geometry\", \"iso_3166_2\", \"name\"]].copy()\n",
"\n",
"# Convert MA01 \u2192 MA-01\n",
"# Convert MA01 MA-01\n",
"morocco_copy[\"iso_3166_2\"] = morocco_copy[\n",
" \"iso_3166_2\"\n",
"].str.replace(\n",
@@ -2290,7 +2290,7 @@
"source": [
"#### Finland\n",
"\n",
"- The \u00c5land Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
"- The Åland Islands (ISO country code AX) is an autonomous region of Finland, and carries the ISO-3166 code FI-01."
]
},
{
@@ -2312,12 +2312,12 @@
"outputs": [],
"source": [
"finland_aland = df_admin0_10m.loc[\n",
" df_admin0_10m.name_en.isin(['\u00c5land']),\n",
" df_admin0_10m.name_en.isin(['Åland']),\n",
" [x for x in df_admin0_10m.columns if x in df.columns]\n",
"]\n",
"finland_aland = finland_aland.merge(pd.DataFrame(\n",
" data={\n",
" \"name_en\": [\"\u00c5land\"],\n",
" \"name_en\": [\"Åland\"],\n",
" \"name_fi\": [\"Ahvenanmaan maakunta\"],\n",
" \"iso_3166_2\": [\"FI-01\"],\n",
" },\n",
@@ -3197,34 +3197,34 @@
"\n",
"# Turkey city name corrections\n",
"# Fix completely wrong spellings\n",
"replace_column('name', turkey, 'Kinkkale', 'K\u0131r\u0131kkale')\n",
"replace_column('name', turkey, 'Kinkkale', 'Kırıkkale')\n",
"replace_column('name', turkey, 'Zinguldak', 'Zonguldak')\n",
"replace_column('name', turkey, 'K. Maras', 'Kahramanmara\u015f')\n",
"replace_column('name', turkey, 'K. Maras', 'Kahramanmaraş')\n",
"\n",
"# Fix missing Turkish characters\n",
"replace_column('name', turkey, 'Adiyaman', 'Ad\u0131yaman')\n",
"replace_column('name', turkey, 'Agri', 'A\u011fr\u0131')\n",
"replace_column('name', turkey, 'Aydin', 'Ayd\u0131n')\n",
"replace_column('name', turkey, 'Balikesir', 'Bal\u0131kesir')\n",
"replace_column('name', turkey, '\u00c7ankiri', '\u00c7ank\u0131r\u0131')\n",
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbak\u0131r')\n",
"replace_column('name', turkey, 'Elazig', 'El\u00e2z\u0131\u011f')\n",
"replace_column('name', turkey, 'Eskisehir', 'Eski\u015fehir')\n",
"replace_column('name', turkey, 'G\u00fcm\u00fcshane', 'G\u00fcm\u00fc\u015fhane')\n",
"replace_column('name', turkey, 'Hakkari', 'Hakk\u00e2ri')\n",
"replace_column('name', turkey, 'Istanbul', '\u0130stanbul')\n",
"replace_column('name', turkey, 'Izmir', '\u0130zmir')\n",
"replace_column('name', turkey, 'I\u011fdir', 'I\u011fd\u0131r')\n",
"replace_column('name', turkey, 'Kirklareli', 'K\u0131rklareli')\n",
"replace_column('name', turkey, 'Kirsehir', 'K\u0131r\u015fehir')\n",
"replace_column('name', turkey, 'Mugla', 'Mu\u011fla')\n",
"replace_column('name', turkey, 'Mus', 'Mu\u015f')\n",
"replace_column('name', turkey, 'Nevsehir', 'Nev\u015fehir')\n",
"replace_column('name', turkey, 'Nigde', 'Ni\u011fde')\n",
"replace_column('name', turkey, 'Sanliurfa', '\u015eanl\u0131urfa')\n",
"replace_column('name', turkey, 'Sirnak', '\u015e\u0131rnak')\n",
"replace_column('name', turkey, 'Tekirdag', 'Tekirda\u011f')\n",
"replace_column('name', turkey, 'Usak', 'U\u015fak')\n",
"replace_column('name', turkey, 'Adiyaman', 'Adıyaman')\n",
"replace_column('name', turkey, 'Agri', 'Ağrı')\n",
"replace_column('name', turkey, 'Aydin', 'Aydın')\n",
"replace_column('name', turkey, 'Balikesir', 'Balıkesir')\n",
"replace_column('name', turkey, 'Çankiri', 'Çankırı')\n",
"replace_column('name', turkey, 'Diyarbakir', 'Diyarbakır')\n",
"replace_column('name', turkey, 'Elazig', 'Elâzığ')\n",
"replace_column('name', turkey, 'Eskisehir', 'Eskişehir')\n",
"replace_column('name', turkey, 'Gümüshane', 'Gümüşhane')\n",
"replace_column('name', turkey, 'Hakkari', 'Hakkâri')\n",
"replace_column('name', turkey, 'Istanbul', 'İstanbul')\n",
"replace_column('name', turkey, 'Izmir', 'İzmir')\n",
"replace_column('name', turkey, 'Iğdir', 'Iğdır')\n",
"replace_column('name', turkey, 'Kirklareli', 'Kırklareli')\n",
"replace_column('name', turkey, 'Kirsehir', 'Kıehir')\n",
"replace_column('name', turkey, 'Mugla', 'Muğla')\n",
"replace_column('name', turkey, 'Mus', 'Muş')\n",
"replace_column('name', turkey, 'Nevsehir', 'Nevşehir')\n",
"replace_column('name', turkey, 'Nigde', 'Niğde')\n",
"replace_column('name', turkey, 'Sanliurfa', 'Şanlıurfa')\n",
"replace_column('name', turkey, 'Sirnak', 'Şırnak')\n",
"replace_column('name', turkey, 'Tekirdag', 'Tekirdağ')\n",
"replace_column('name', turkey, 'Usak', 'Uşak')\n",
"turkey_copy = turkey.copy()"
]
},
@@ -3263,18 +3263,18 @@
"\n",
"# Region names corresponding to NUTS-1\n",
"\n",
"region_name_dict = {'TR1':'\u0130stanbul',\n",
" 'TR2':'Bat\u0131 Marmara',\n",
"region_name_dict = {'TR1':'İstanbul',\n",
" 'TR2':'Batı Marmara',\n",
" 'TR3':'Ege',\n",
" 'TR4':'Do\u011fu Marmara',\n",
" 'TR5':'Bat\u0131 Anadolu',\n",
" 'TR4':'Doğu Marmara',\n",
" 'TR5':'Batı Anadolu',\n",
" 'TR6':'Akdeniz',\n",
" 'TR7':'Orta Anadolu',\n",
" 'TR8':'Bat\u0131 Karadeniz',\n",
" 'TR9':'Do\u011fu Karadeniz',\n",
" 'TRA':'Kuzeydo\u011fu Anadolu',\n",
" 'TRC':'G\u00fcneydo\u011fu Anadolu',\n",
" 'TRB':'Ortado\u011fu Anadolu'\n",
" 'TR8':'Batı Karadeniz',\n",
" 'TR9':'Doğu Karadeniz',\n",
" 'TRA':'Kuzeydoğu Anadolu',\n",
" 'TRC':'Güneydoğu Anadolu',\n",
" 'TRB':'Ortadoğu Anadolu'\n",
" }\n",
"\n",
"\n",
@@ -3517,8 +3517,8 @@
"france_copy = france.copy()\n",
"reposition(france_copy, france.name=='Guadeloupe', 57.4, 25.4, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Martinique', 58.4, 27.1, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Guyane fran\u00e7aise', 52, 37.7, 0.35, 0.35)\n",
"reposition(france_copy, france.name=='La R\u00e9union', -55, 62.8, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Guyane française', 52, 37.7, 0.35, 0.35)\n",
"reposition(france_copy, france.name=='La Réunion', -55, 62.8, 1.5, 1.5)\n",
"reposition(france_copy, france.name=='Mayotte', -43, 54.3, 1.5, 1.5)\n",
"\n",
"not speed_run and france_copy.plot(figsize=(8, 8), **plot_styles)"
@@ -3669,8 +3669,8 @@
"france_overseas = france.copy()\n",
"reposition(france_overseas, france.name=='Guadeloupe', 53.2, 29, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Martinique', 52.8, 27.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Guyane fran\u00e7aise', 45, 35.5, 0.3, 0.3)\n",
"reposition(france_overseas, france.name=='La R\u00e9union', -58.2, 60.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Guyane française', 45, 35.5, 0.3, 0.3)\n",
"reposition(france_overseas, france.name=='La Réunion', -58.2, 60.5, 1.5, 1.5)\n",
"reposition(france_overseas, france.name=='Mayotte', -50.5, 52.2, 2, 2)\n",
"\n",
"# Tahiti\n",
@@ -3713,7 +3713,7 @@
"france_overseas = pd.concat([france_overseas, saint_martin_data], ignore_index=True)\n",
"reposition(france_overseas, france_overseas.admin=='Saint Martin', 54.8, 30.3, 5, 5)\n",
"\n",
"# Saint Barth\u00e9l\u00e9my\n",
"# Saint Barthélémy\n",
"saint_barthelemy_data = df[(df['admin'] == 'Saint Barthelemy')]\n",
"france_overseas = pd.concat([france_overseas, saint_barthelemy_data], ignore_index=True)\n",
"reposition(france_overseas, france_overseas.admin=='Saint Barthelemy', 54.5, 30, 8, 8)\n",
@@ -3729,13 +3729,13 @@
"france_overseas = pd.concat([france_overseas, paris_and_littlecrowndpts_copy], ignore_index=True)\n",
"\n",
"# Update metadata properly\n",
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polyn\u00e9sie fran\u00e7aise', 'FR-PF']\n",
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques fran\u00e7aises', 'FR-TF']\n",
"france_overseas.loc[france_overseas['name'] == 'Windward Islands', ['name', 'iso_3166_2']] = ['Polynésie française', 'FR-PF']\n",
"france_overseas.loc[france_overseas['name'] == 'Archipel des Kerguelen', ['name', 'iso_3166_2']] = ['Terres australes et antarctiques françaises', 'FR-TF']\n",
"france_overseas.loc[france_overseas['admin'] == 'Wallis and Futuna', ['name', 'iso_3166_2']] = ['Wallis et Futuna', 'FR-WF']\n",
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Cal\u00e9donie', 'FR-NC']\n",
"france_overseas.loc[france_overseas['admin'] == 'New Caledonia', ['name', 'iso_3166_2']] = ['Nouvelle-Calédonie', 'FR-NC']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Pierre and Miquelon', ['name', 'iso_3166_2']] = ['Saint-Pierre-et-Miquelon', 'FR-PM']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Martin', ['name', 'iso_3166_2']] = ['Saint-Martin', 'FR-MF']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barth\u00e9l\u00e9my', 'FR-BL']\n",
"france_overseas.loc[france_overseas['admin'] == 'Saint Barthelemy', ['name', 'iso_3166_2']] = ['Saint-Barthélémy', 'FR-BL']\n",
"\n",
"# Plot data\n",
"france_overseas = france_overseas.rename(columns={'NAME_1': 'name','ISO': 'iso_3166_2'})\n",
@@ -3821,51 +3821,6 @@
"not speed_run and italy_regions.plot(figsize=(10, 7), **plot_styles)"
]
},
{
"cell_type": "markdown",
"metadata": {
"id": "65aIalqEt1LR"
},
"source": [
"#### Italy Regions and Autonomous Provinces"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
}
},
"outputs": [],
"source": [
"trento_and_bozen = df[(df.admin == 'Italy') & (df.iso_3166_2.isin(['IT-TN', 'IT-BZ']))][['geometry','iso_3166_2','name']]\n",
"\n",
"italy_regions_and_autonomous_provinces = pd.concat([italy_regions, trento_and_bozen])\n",
"\n",
"italy_regions_and_autonomous_provinces = italy_regions_and_autonomous_provinces[italy_regions_and_autonomous_provinces['iso_3166_2'] != 'IT-32']"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"execution": {
"iopub.execute_input": "2026-07-27T19:54:28.892892Z",
"iopub.status.busy": "2026-07-27T19:54:28.892454Z",
"iopub.status.idle": "2026-07-27T19:54:31.123499Z",
"shell.execute_reply": "2026-07-27T19:54:31.122932Z"
}
},
"outputs": [],
"source": [
"not speed_run and italy_regions_and_autonomous_provinces.plot(figsize=(10, 7), **plot_styles)"
]
},
{
"cell_type": "markdown",
"metadata": {
@@ -4376,86 +4331,86 @@
"output_type": "stream",
"text": [
"Kon Tum\n",
"\u0110\u1eafk N\u00f4ng\n",
"\u0110\u1eafk L\u1eafk\n",
"Đắk Nông\n",
"Đắk Lắk\n",
"Gia Lai\n",
"B\u00ecnh Ph\u01b0\u1edbc\n",
"T\u00e2y Ninh\n",
"Bình Phước\n",
"Tây Ninh\n",
"Long An\n",
"\u0110\u1ed3ng Th\u00e1p\n",
"Đồng Tháp\n",
"An Giang\n",
"Ki\u00ean Giang\n",
"\u0110i\u1ec7n Bi\u00ean\n",
"S\u01a1n La\n",
"Thanh H\u00f3a\n",
"Ngh\u1ec7 An\n",
"H\u00e0 T\u0129nh\n",
"Qu\u1ea3ng B\u00ecnh\n",
"Qu\u1ea3ng Tr\u1ecb\n",
"Th\u1eeba Thi\u00ean - Hu\u1ebf\n",
"Qu\u1ea3ng Nam\n",
"H\u00e0 Giang\n",
"Cao B\u1eb1ng\n",
"L\u00e0o Cai\n",
"Lai Ch\u00e2u\n",
"L\u1ea1ng S\u01a1n\n",
"Qu\u1ea3ng Ninh\n",
"S\u00f3c Tr\u0103ng\n",
"Ti\u1ec1n Giang\n",
"B\u00e0 R\u1ecba - V\u0169ng T\u00e0u\n",
"Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh\n",
"Kh\u00e1nh H\u00f2a\n",
"C\u00e0 Mau\n",
"B\u1ea1c Li\u00eau\n",
"H\u1eadu Giang\n",
"V\u0129nh Long\n",
"Tr\u00e0 Vinh\n",
"B\u1ebfn Tre\n",
"\u0110\u1ed3ng Nai\n",
"B\u00ecnh Thu\u1eadn\n",
"Ninh Thu\u1eadn\n",
"Ph\u00fa Y\u00ean\n",
"B\u00ecnh \u0110\u1ecbnh\n",
"Qu\u1ea3ng Ng\u00e3i\n",
"\u0110\u00e0 N\u1eb5ng\n",
"Ninh B\u00ecnh\n",
"Nam \u0110\u1ecbnh\n",
"Th\u00e1i B\u00ecnh\n",
"H\u1ea3i Ph\u00f2ng\n",
"H\u00f2a B\u00ecnh\n",
"Tuy\u00ean Quang\n",
"Y\u00ean B\u00e1i\n",
"V\u0129nh Ph\u00fac\n",
"Ph\u00fa Th\u1ecd\n",
"H\u00e0 N\u1ed9i\n",
"B\u1eafc K\u1ea1n\n",
"H\u01b0ng Y\u00ean\n",
"B\u1eafc Ninh\n",
"B\u1eafc Giang\n",
"Th\u00e1i Nguy\u00ean\n",
"H\u1ea3i D\u01b0\u01a1ng\n",
"H\u00e0 Nam\n",
"B\u00ecnh D\u01b0\u01a1ng\n",
"L\u00e2m \u0110\u1ed3ng\n",
"C\u1ea7n Th\u01a1\n"
"Kiên Giang\n",
"Điện Biên\n",
"Sơn La\n",
"Thanh Hóa\n",
"Ngh An\n",
"Hà Tĩnh\n",
"Quảng Bình\n",
"Quảng Trị\n",
"Thừa Thiên - Huế\n",
"Qung Nam\n",
"Hà Giang\n",
"Cao Bng\n",
"Lào Cai\n",
"Lai Châu\n",
"Lạng Sơn\n",
"Qung Ninh\n",
"Sóc Trăng\n",
"Tin Giang\n",
"Bà Rịa - Vũng Tàu\n",
"Thành phố Hồ Chí Minh\n",
"Khánh Hòa\n",
"Cà Mau\n",
"Bạc Liêu\n",
"Hu Giang\n",
"Vĩnh Long\n",
"Trà Vinh\n",
"Bến Tre\n",
"Đồng Nai\n",
"Bình Thuận\n",
"Ninh Thun\n",
"Phú Yên\n",
"Bình Định\n",
"Quảng Ngãi\n",
"Đà Nẵng\n",
"Ninh Bình\n",
"Nam Định\n",
"Thái Bình\n",
"Hải Phòng\n",
"Hòa Bình\n",
"Tuyên Quang\n",
"Yên Bái\n",
"Vĩnh Phúc\n",
"Phú Thọ\n",
"Hà Nội\n",
"Bắc Kạn\n",
"Hưng Yên\n",
"Bc Ninh\n",
"Bc Giang\n",
"Thái Nguyên\n",
"Hải Dương\n",
"Hà Nam\n",
"Bình Dương\n",
"Lâm Đồng\n",
"Cần Thơ\n"
]
}
],
"source": [
"vietnam = df[df.admin == 'Vietnam']\n",
"vietnam_copy = vietnam.copy()\n",
"replace_column('name', vietnam_copy, '\u00d0ong Th\u00e1p', '\u0110\u1ed3ng Th\u00e1p')\n",
"replace_column('name', vietnam_copy, 'Son La', 'S\u01a1n La')\n",
"replace_column('name', vietnam_copy, 'Ha Tinh', 'H\u00e0 T\u0129nh')\n",
"replace_column('name', vietnam_copy, 'Qu\u00e0ng Nam', 'Qu\u1ea3ng Nam')\n",
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Ch\u00e2u')\n",
"replace_column('name', vietnam_copy, 'H\u1ed3 Ch\u00ed Minh city', 'Th\u00e0nh ph\u1ed1 H\u1ed3 Ch\u00ed Minh')\n",
"replace_column('name', vietnam_copy, 'Hau Giang', 'H\u1eadu Giang')\n",
"replace_column('name', vietnam_copy, 'Ha Noi', 'H\u00e0 N\u1ed9i')\n",
"replace_column('name', vietnam_copy, 'Can Tho', 'C\u1ea7n Th\u01a1')\n",
"replace_column('name', vietnam_copy, '\u0110\u00f4ng Nam B\u1ed9', '\u0110\u1ed3ng Nai')\n",
"replace_column('name', vietnam_copy, '\u0110\u00f4ng B\u1eafc', 'B\u1eafc K\u1ea1n')\n",
"replace_column('name', vietnam_copy, '\u0110\u1ed3ng B\u1eb1ng S\u00f4ng H\u1ed3ng', 'H\u01b0ng Y\u00ean')\n",
"replace_column('name', vietnam_copy, 'Ðong Tháp', 'Đồng Tháp')\n",
"replace_column('name', vietnam_copy, 'Son La', 'Sơn La')\n",
"replace_column('name', vietnam_copy, 'Ha Tinh', 'Hà Tĩnh')\n",
"replace_column('name', vietnam_copy, 'Quàng Nam', 'Qung Nam')\n",
"replace_column('name', vietnam_copy, 'Lai Chau', 'Lai Châu')\n",
"replace_column('name', vietnam_copy, 'Hồ Chí Minh city', 'Thành phố Hồ Chí Minh')\n",
"replace_column('name', vietnam_copy, 'Hau Giang', 'Hu Giang')\n",
"replace_column('name', vietnam_copy, 'Ha Noi', 'Hà Nội')\n",
"replace_column('name', vietnam_copy, 'Can Tho', 'Cần Thơ')\n",
"replace_column('name', vietnam_copy, 'Đông Nam Bộ', 'Đồng Nai')\n",
"replace_column('name', vietnam_copy, 'Đông Bắc', 'Bắc Kạn')\n",
"replace_column('name', vietnam_copy, 'Đồng Bằng Sông Hồng', 'Hưng Yên')\n",
"for i in vietnam_copy['name']:\n",
" print(i)"
]
@@ -4499,7 +4454,6 @@
" \"turkey\": turkey_copy,\n",
" \"turkey_regions\": turkey_regions,\n",
" \"italy_regions\": italy_regions,\n",
" \"italy_regions_and_autonomous_provinces\": italy_regions_and_autonomous_provinces,\n",
" \"philippines_regions\": philippines_regions,\n",
" \"latvia\": latvia_copy,\n",
" \"netherlands\": netherlands_copy,\n",
@@ -4538,7 +4492,7 @@
"aruba has only one subdivision - removing from countries array\n",
"british indian ocean territory has only one subdivision - removing from countries array\n",
"cayman islands has only one subdivision - removing from countries array\n",
"cura\u00e7ao has only one subdivision - removing from countries array\n",
"curaçao has only one subdivision - removing from countries array\n",
"falkland islands has only one subdivision - removing from countries array\n",
"faroe islands has only one subdivision - removing from countries array\n",
"gibraltar has only one subdivision - removing from countries array\n",
@@ -4574,7 +4528,7 @@
"name": "stdout",
"output_type": "stream",
"text": [
"cura\u00e7ao has only one subdivision - removing from countries array\n",
"curaçao has only one subdivision - removing from countries array\n",
"falkland islands has only one subdivision - removing from countries array\n",
"faroe islands has only one subdivision - removing from countries array\n"
]
@@ -103,7 +103,6 @@ import iran from './countries/iran.geojson';
import israel from './countries/israel.geojson';
import italy from './countries/italy.geojson';
import italy_regions from './countries/italy_regions.geojson';
import italy_regions_and_autonomous_provinces from './countries/italy_regions_and_autonomous_provinces.geojson';
import ivory_coast from './countries/ivory_coast.geojson';
import japan from './countries/japan.geojson';
import jordan from './countries/jordan.geojson';
@@ -307,7 +306,6 @@ export const countries = {
israel,
italy,
italy_regions,
italy_regions_and_autonomous_provinces,
ivory_coast,
japan,
jordan,
@@ -432,9 +430,6 @@ export const countryOptions = Object.keys(countries).map(x => {
if (x === 'italy_regions') {
return [x, 'Italy (regions)'];
}
if (x === 'italy_regions_and_autonomous_provinces') {
return [x, 'Italy (regions and autonomous provinces)'];
}
if (x === 'france_regions') {
return [x, 'France (regions)'];
}
@@ -33,6 +33,6 @@
{ "type": "Feature", "properties": { "ISO": "IR-25", "NAME_1": "Yazd" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 53.650710076708663, 32.61286754000497 ], [ 54.70904341032508, 32.920083929729572 ], [ 54.814670038291581, 32.970700994954939 ], [ 54.908204380227914, 33.088161526533213 ], [ 54.986339146034709, 33.329955553206219 ], [ 55.040082635105477, 33.385688585459832 ], [ 55.139404737638529, 33.430362861356969 ], [ 55.217229445082808, 33.437261664194409 ], [ 55.274590285313366, 33.470644639738339 ], [ 55.359856397954388, 33.594642238948154 ], [ 55.386418085026548, 33.665464788968791 ], [ 55.375255974983247, 34.344363918859756 ], [ 55.484706658785228, 34.365163682857656 ], [ 55.741125116131172, 34.360331935770205 ], [ 55.850885858295669, 34.407254137268581 ], [ 56.210450474010145, 34.906060898691521 ], [ 56.619211053447316, 34.993678289721288 ], [ 57.016602818165495, 35.148294175835531 ], [ 57.268370395577278, 35.201314195393763 ], [ 57.37957807766611, 35.177336330707078 ], [ 57.671239861630283, 34.993936672139682 ], [ 57.705553013161023, 34.930994777786736 ], [ 57.692013788105783, 34.86686432502853 ], [ 57.595172154271495, 34.788807075386217 ], [ 57.198917271115135, 34.557735908285508 ], [ 57.13080773289056, 34.456295071360444 ], [ 57.004820591397163, 34.142567449728006 ], [ 56.9966557148893, 33.973482164370353 ], [ 57.05887413883039, 33.685256863513416 ], [ 57.105796340328823, 33.623581041032196 ], [ 57.304440545394812, 33.603737291442826 ], [ 57.559928826753946, 33.653243313528094 ], [ 57.602716913155007, 33.607716376009932 ], [ 57.642301060445561, 33.54405101214445 ], [ 57.71702518066752, 33.121699530808655 ], [ 57.782240838144162, 32.996797594033694 ], [ 58.04062300025123, 32.871068834059429 ], [ 58.147696568142067, 32.748595689139734 ], [ 58.152554151852598, 32.671882025734874 ], [ 58.10036095639299, 32.568141588163769 ], [ 58.224384393125206, 32.352547512457704 ], [ 58.222213982789071, 32.297563788138291 ], [ 58.173948195053129, 32.146048489246368 ], [ 58.040726353038735, 31.994533189455126 ], [ 58.003932732809346, 31.907200019265474 ], [ 57.901199985590836, 31.771549384496495 ], [ 57.834744093764868, 31.637268175286067 ], [ 56.761631300743886, 32.03008657533519 ], [ 56.634093865639386, 32.049181016990303 ], [ 56.566397738564774, 31.978926906851257 ], [ 56.358451776328366, 31.879036363537352 ], [ 56.287758416617578, 31.815086777932436 ], [ 55.756834750623227, 31.576264146373262 ], [ 55.712289666834636, 31.495183823874356 ], [ 55.684487746312413, 31.110917873960716 ], [ 55.514989048006157, 31.046787421202509 ], [ 55.32637006962301, 31.024773261276948 ], [ 55.116460401726158, 31.043247586207144 ], [ 54.554427525110157, 30.957774767091792 ], [ 54.466474237296211, 30.873361314273211 ], [ 54.420275506209634, 30.797913722740077 ], [ 54.398054640709063, 30.725075792014195 ], [ 54.400638462195275, 30.675466417141422 ], [ 54.515566848231458, 30.450441393055826 ], [ 54.539441359231319, 30.350550848842602 ], [ 54.603830194407919, 30.297349962131022 ], [ 54.591221144440226, 29.973106187400617 ], [ 54.616749301838809, 29.847971707528245 ], [ 54.430197381004291, 29.793220527205563 ], [ 54.227212355265976, 29.882879137362295 ], [ 54.071769646851692, 29.984268297443975 ], [ 54.043140904029485, 30.044161282317305 ], [ 54.007587518149421, 30.263191840231229 ], [ 53.964075962235825, 30.329983628841376 ], [ 53.80460249241105, 30.499068915098348 ], [ 53.638411086002804, 30.755151476559377 ], [ 53.404936964569231, 31.261502996865772 ], [ 53.276469354377184, 31.395086574985442 ], [ 53.125160760160952, 31.51394236874529 ], [ 52.870706007576189, 31.597451483998782 ], [ 52.827091098875087, 31.744419257542688 ], [ 52.824093866238911, 31.813846544482431 ], [ 52.904915806319366, 32.164600328542292 ], [ 52.883728468693846, 32.505199692911503 ], [ 53.060358513834331, 32.576694037399875 ], [ 53.164641554664001, 32.640333563742956 ], [ 53.261069777348325, 32.672011216944099 ], [ 53.334036900182753, 32.67412995043685 ], [ 53.650710076708663, 32.61286754000497 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-26", "NAME_1": "Qom" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 51.788911574109818, 34.54197459605075 ], [ 51.451671176683419, 34.469730942728802 ], [ 51.158355747219957, 34.452522691469028 ], [ 51.063064406097453, 34.418235379259329 ], [ 51.013765089587196, 34.357903143914939 ], [ 50.983689405941277, 34.161610216338374 ], [ 51.004670037991843, 34.11838288036563 ], [ 51.032988723350854, 34.105799668819657 ], [ 50.802201776190998, 34.157889513290399 ], [ 50.699262323397477, 34.20488922825524 ], [ 50.441190219652867, 34.224784653788731 ], [ 50.32874230321471, 34.317879747052643 ], [ 50.30445438016551, 34.367463284403016 ], [ 50.30869184805033, 34.408520209140306 ], [ 50.264146763362419, 34.466423651730111 ], [ 50.158313429820907, 34.492106838759582 ], [ 50.060024855162908, 34.577062893038089 ], [ 50.069429966020095, 34.628739325459492 ], [ 50.162757603280738, 34.671992498954637 ], [ 50.173402947587931, 34.692042955018337 ], [ 50.152629022011752, 34.716124172492528 ], [ 50.153972609148582, 34.781494858700739 ], [ 50.21112674290481, 34.819373684547884 ], [ 50.301147088267498, 34.809374295387386 ], [ 50.388170200094635, 34.829915676067571 ], [ 50.447598097873879, 34.862058417262119 ], [ 50.57151818181859, 34.878129787859393 ], [ 50.693681268375826, 34.915956935963777 ], [ 50.723033481609889, 35.107883206244935 ], [ 50.784735141613453, 35.218419093866089 ], [ 51.072159457692806, 35.213251450893722 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.893194614040169, 34.754002997440352 ], [ 51.866116163929803, 34.66646312077637 ], [ 51.788911574109818, 34.54197459605075 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-07", "NAME_1": "Tehran" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.784735141613453, 35.218419093866089 ], [ 50.8712414886038, 35.4471906603207 ], [ 50.870828078353099, 35.517031358410463 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.970321006209417, 35.799679657490174 ], [ 51.059069657138934, 35.803713687037032 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.465003697062969, 36.064698188570503 ], [ 51.618275995141062, 36.054052843363991 ], [ 51.754391717004125, 36.010980536122815 ], [ 51.856504348396925, 35.921554469962814 ], [ 51.950245395908269, 35.799804796354238 ], [ 52.029206984015048, 35.77091767021426 ], [ 52.108168573021203, 35.767171128744565 ], [ 52.177001580758315, 35.789960435925366 ], [ 52.306812778986512, 35.917394518242418 ], [ 52.398486768949226, 35.976202298397311 ], [ 52.625863072322886, 35.931347154447622 ], [ 52.740274692622961, 35.881014309162993 ], [ 52.816342400881069, 35.86825023046373 ], [ 52.901401807947025, 35.889695950507701 ], [ 52.944810011972436, 35.881556912421559 ], [ 53.0347270036483, 35.831094875927761 ], [ 53.066249628117816, 35.718905341008679 ], [ 53.079375441123659, 35.618136298551292 ], [ 53.047232699929111, 35.528374334707735 ], [ 52.888275994941182, 35.410190335415621 ], [ 52.674955682358814, 35.336189683806822 ], [ 52.594133742278359, 35.338721829348913 ], [ 52.21968631437187, 35.414221095926791 ], [ 51.982078077840981, 35.54431651499516 ], [ 51.922443475386046, 35.54684866053725 ], [ 51.870146926239613, 35.569818833971965 ], [ 51.853403762073924, 35.555452785717478 ], [ 51.821054315304366, 35.403420721988709 ], [ 51.822501255228701, 35.315260727700377 ], [ 51.980631137916646, 35.125246487135655 ], [ 51.968022087948953, 35.063725694285324 ], [ 51.916862420364396, 34.998587551174523 ], [ 51.882342564157966, 34.875494290429117 ], [ 51.31235151570985, 35.153952745222966 ], [ 51.072159457692806, 35.213251450893722 ], [ 50.784735141613453, 35.218419093866089 ] ] ] } },
{ "type": "Feature", "properties": { "ISO": "IR-32", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
{ "type": "Feature", "properties": { "ISO": "IR-30", "NAME_1": "Alborz" }, "geometry": { "type": "Polygon", "coordinates": [ [ [ 50.696471795436992, 35.550543525163562 ], [ 50.689753858853464, 35.639246120911707 ], [ 50.650996534762271, 35.676478989813518 ], [ 50.594772576992796, 35.677047431493747 ], [ 50.581956822349468, 35.650072333271567 ], [ 50.620714146440662, 35.60635407178296 ], [ 50.610378858697345, 35.574831448212763 ], [ 50.527696568441854, 35.62911754054204 ], [ 50.285540805663629, 35.666014513558935 ], [ 50.238618605064573, 35.741022853721688 ], [ 50.2302470229817, 35.771925361466231 ], [ 50.242752720161889, 35.81083771428905 ], [ 50.297736443581982, 35.853961697474347 ], [ 50.500204706282148, 35.934266872717956 ], [ 50.534517856913567, 35.960802721368452 ], [ 50.625881789413029, 36.164769599037754 ], [ 50.47188602092308, 36.239648748890602 ], [ 50.420829706126028, 36.296389472396186 ], [ 50.449665155422565, 36.331684474958536 ], [ 50.563146599735774, 36.339177557897983 ], [ 50.966222772263109, 36.292203681354806 ], [ 51.029888137027854, 36.27179149008515 ], [ 51.086008742009824, 36.222259630477538 ], [ 51.127970005211523, 36.20745433175199 ], [ 51.291991001283634, 36.178102118517927 ], [ 51.356896600397761, 36.116271267305137 ], [ 51.353553818553848, 36.017517255716768 ], [ 51.297077403998969, 35.989279047989669 ], [ 51.216396812163111, 35.997347107083328 ], [ 51.135716220327254, 35.97314292980235 ], [ 51.075205776225573, 35.916666515247471 ], [ 51.059069657138934, 35.803713687037032 ], [ 50.970321006209417, 35.799679657490174 ], [ 50.942082799381637, 35.739169213388493 ], [ 50.889640414373616, 35.686726828380415 ], [ 50.825095940725078, 35.654454591106457 ], [ 50.798687778717976, 35.604777939750079 ], [ 50.75269575410573, 35.601341458441539 ], [ 50.724893832684188, 35.555168564877363 ], [ 50.696471795436992, 35.550543525163562 ] ] ] } }
]
}
@@ -1,58 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fs from 'fs';
import path from 'path';
import { countryOptions } from '../src/countries';
type ItalyFeature = { properties: { ISO: string; NAME_1: string } };
test('countryOptions includes labeled entries for the Italy region variants', () => {
expect(countryOptions).toContainEqual(['italy_regions', 'Italy (regions)']);
expect(countryOptions).toContainEqual([
'italy_regions_and_autonomous_provinces',
'Italy (regions and autonomous provinces)',
]);
});
test('italy_regions_and_autonomous_provinces geojson has the expected shape', () => {
// jest maps `.geojson` imports to an empty object mock, so the file is
// read from disk directly to verify its actual shape.
const geojsonPath = path.join(
__dirname,
'../src/countries/italy_regions_and_autonomous_provinces.geojson',
);
const geojson = JSON.parse(fs.readFileSync(geojsonPath, 'utf-8'));
const features: ItalyFeature[] = geojson.features;
expect(features).toHaveLength(21);
features.forEach(feature => {
expect(feature.properties).toEqual(
expect.objectContaining({
ISO: expect.any(String),
NAME_1: expect.any(String),
}),
);
});
const isoCodes = features.map(feature => feature.properties.ISO);
expect(new Set(isoCodes).size).toBe(isoCodes.length);
expect(isoCodes).toContain('IT-BZ');
expect(isoCodes).toContain('IT-TN');
expect(isoCodes).not.toContain('IT-32');
});
@@ -1,69 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fs from 'fs';
import path from 'path';
type Feature = {
properties: {
ISO: string;
NAME_1: string;
};
};
// `.geojson` imports are mocked out to an empty object by the Jest module
// mapper (see jest.config.js), so the file is read from disk directly to
// exercise the real, committed data.
function loadIranGeoJson(): { features: Feature[] } {
const filePath = path.join(__dirname, '../src/countries/iran.geojson');
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
}
test('every Iranian province has its own distinct ISO 3166-2 code', () => {
const { features } = loadIranGeoJson();
// Pin the feature count too, so dropping a province other than
// Tehran/Alborz (which would still leave every remaining ISO code
// distinct) doesn't slip past the checks below.
expect(features.length).toBe(31);
// Sanity check: every province name in this file is unique, so a
// duplicate ISO code below can only mean two different provinces were
// mistakenly assigned the same code (as opposed to one province being
// split across multiple polygon features).
const names = features.map(feature => feature.properties.NAME_1);
expect(new Set(names).size).toBe(names.length);
const isoByName = new Map(
features.map(feature => [
feature.properties.NAME_1,
feature.properties.ISO,
]),
);
const isoCodes = features.map(feature => feature.properties.ISO);
expect(new Set(isoCodes).size).toBe(isoCodes.length);
// Tehran and Alborz were split into separate provinces in 2010, but the
// GeoJSON still assigned both the same ISO code (IR-07), which used to
// make it impossible to distinguish them on the Country Map chart. Alborz
// now uses its pre-2020 ISO 3166-2 code, IR-32.
expect(isoByName.get('Tehran')).toBe('IR-07');
expect(isoByName.get('Alborz')).toBe('IR-32');
});
@@ -38,7 +38,7 @@ export default function transformProps(chartProps: ChartProps) {
includeSeries,
isDarkMode: isThemeDark(theme),
linearColorScheme,
metrics: (metrics ?? []).map((m: { label?: string } | string) =>
metrics: metrics.map((m: { label?: string } | string) =>
typeof m === 'string' ? m : m.label || m,
),
colorMetric: secondaryMetric?.label || secondaryMetric,
@@ -1,41 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { ChartProps } from '@superset-ui/core';
import transformProps from '../src/transformProps';
const createProps = () =>
({
width: 800,
height: 600,
formData: {
includeSeries: false,
linearColorScheme: 'superset_seq_1',
metrics: undefined,
secondaryMetric: 'sum__SP_POP_TOTL',
series: 'country_name',
showDatatable: false,
},
queriesData: [{ data: [{ country_id: 'FRA', metric: 10 }] }],
theme: {},
}) as unknown as ChartProps;
test('do not crash on undefined metrics', () => {
expect(() => transformProps(createProps())).not.toThrow();
});
@@ -1069,10 +1069,10 @@ export default function TableChart<D extends DataRecord = DataRecord>(
const originKey = column.key.substring(column.label.length).trim();
if (!hasColumnColorFormatters && hasBasicColorFormatters) {
backgroundColor =
basicColorFormatters[row.index]?.[originKey]?.backgroundColor;
basicColorFormatters[row.index][originKey]?.backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorFormatters[row.index]?.[originKey]?.mainArrow
? basicColorFormatters[row.index][originKey]?.mainArrow
: '';
}
@@ -1134,11 +1134,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
basicColorColumnFormatters?.length > 0
) {
backgroundColor =
basicColorColumnFormatters[row.index]?.[column.key]
basicColorColumnFormatters[row.index][column.key]
?.backgroundColor || backgroundColor;
arrow =
column.label === comparisonLabels[0]
? basicColorColumnFormatters[row.index]?.[column.key]?.mainArrow
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
: '';
}
const rowSurfaceColor =
@@ -1197,7 +1197,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
let arrowStyles = css`
color: ${
basicColorFormatters &&
basicColorFormatters[row.index]?.[originKey]?.arrowColor ===
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
@@ -1211,7 +1211,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
) {
arrowStyles = css`
color: ${
basicColorColumnFormatters[row.index]?.[column.key]
basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
@@ -34,7 +34,7 @@ import {
getTotalsMetrics,
isTimeComparison,
timeCompareOperator,
toTotalsAggregate,
TotalsAggregate,
} from '@superset-ui/chart-controls';
import { isEmpty } from 'lodash-es';
import { TableChartFormData } from './types';
@@ -349,7 +349,8 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData.show_totals &&
queryMode === QueryMode.Aggregate
) {
const totalsAggregate = toTotalsAggregate(formData.totals_aggregate);
const totalsAggregate: TotalsAggregate =
formData.totals_aggregate === 'AVG' ? 'AVG' : 'SUM';
extraQueries.push({
...queryObject,
columns: [],
@@ -475,18 +475,14 @@ const config: ControlPanelConfig = {
type: 'SelectControl',
label: t('Summary aggregation'),
description: t(
'Aggregation used for the summary row. By default each metric ' +
'keeps its own aggregation; Sum and Average override it for ' +
'the summary row only. The override applies to simple ' +
'metrics (a metric built from custom SQL always keeps its ' +
'own aggregation). Overriding a count or a distinct count ' +
'sums the counted column instead, which fails outright on a ' +
'non-numeric column.',
'Aggregation used for the summary row, independent of each ' +
"metric's own aggregation. Only applies to simple metrics " +
'(a metric built from custom SQL keeps its own aggregation ' +
'in the summary row).',
),
default: 'ORIGINAL',
default: 'SUM',
clearable: false,
choices: [
['ORIGINAL', t("Each metric's own")],
['SUM', t('Sum')],
['AVG', t('Average')],
],
@@ -20,7 +20,6 @@ import '@testing-library/jest-dom';
import {
getTextColorForBackground,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/theme';
import {
@@ -2076,59 +2075,6 @@ describe('plugin-chart-table', () => {
});
});
test('does not crash when a comparison-color-formatter array has no entry for a rendered row', () => {
// Regression test: the per-cell comparison-color lookups in the Cell
// renderer (`basicColorFormatters`/`basicColorColumnFormatters`,
// indexed by `row.index`) must stay safe even if those arrays ever
// end up with fewer entries than the number of rendered rows -- e.g.
// when "Show summary" is combined with time comparison and a
// comparison-based conditional color scheme ("Green for increase,
// red for decrease") applied to a Time Comparison column. Without
// the `?.` guard on the array-index lookup, this throws
// `TypeError: Cannot read properties of undefined (reading 'Main
// metric_1')`.
const propsInput = {
...testData.comparison,
rawFormData: {
...testData.comparison.rawFormData,
conditional_formatting: [
{ column: 'Main metric_1', colorScheme: ColorSchemeEnum.Green },
],
},
};
const transformedProps = transformProps(propsInput);
expect(transformedProps.data).toHaveLength(2);
expect(transformedProps.basicColorColumnFormatters).toHaveLength(2);
// Simulate the row-count mismatch: the formatter array has an entry
// for only the first row, matching the shape of the bug (an entry
// missing for one of the rendered rows).
const propsWithMissingFormatterEntry = {
...transformedProps,
basicColorColumnFormatters:
transformedProps.basicColorColumnFormatters!.slice(0, 1),
};
expect(() =>
render(
<TableChart {...propsWithMissingFormatterEntry} sticky={false} />,
),
).not.toThrow();
// the row that still has a formatter entry keeps its comparison
// background color and arrow: the "Main metric_1" cell for the
// first row (value 100) renders before the derived "△ metric_1"
// cell that happens to share the same value and aria label.
const [styledCell] = screen.getAllByTitle('100');
expect(styledCell).toHaveTextContent('↑100');
expect(getComputedStyle(styledCell).background).toContain(
'rgba(0, 150, 0, 0.2)',
);
// the row missing a formatter entry still renders its raw value
expect(screen.getAllByTitle('110').length).toBeGreaterThan(0);
});
test('preserves client-side search text across temporal table rerenders', async () => {
const formDataWithSearch = {
...testData.basic.formData,
@@ -340,7 +340,7 @@ describe('plugin-chart-table', () => {
label: 'sum_sales',
};
test("defaults to each metric's own aggregate", () => {
test('defaults the totals query metric aggregate to SUM', () => {
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
@@ -350,28 +350,9 @@ describe('plugin-chart-table', () => {
});
expect(queries).toHaveLength(2);
expect(queries[1].metrics).toEqual([simpleMetric]);
});
test('keeps COUNT_DISTINCT in the summary row by default', () => {
// Overriding this to SUM sums the counted column instead of counting
// it, which is meaningless on a numeric id and is rejected outright by
// the database on a non-numeric one (e.g. a uuid).
const countDistinctMetric = {
expressionType: 'SIMPLE' as const,
column: { column_name: 'contract_id' },
aggregate: 'COUNT_DISTINCT' as const,
label: 'contracts',
};
const { queries } = buildQueryCached({
...basicFormData,
query_mode: QueryMode.Aggregate,
metrics: [countDistinctMetric],
groupby: ['category'],
show_totals: true,
});
expect(queries[1].metrics).toEqual([countDistinctMetric]);
expect(queries[1].metrics).toEqual([
{ ...simpleMetric, aggregate: 'SUM' },
]);
});
test('overrides simple metric aggregate with totals_aggregate for the summary query only', () => {
@@ -196,54 +196,6 @@ interface DatasourceObject {
folders?: DatasourceFolder[];
}
/**
* Lift the certification and warning fields a metric keeps inside its `extra`
* JSON blob onto the metric itself, which is the shape the editor's fields bind
* to.
*
* Two entry points feed the editor two different metric shapes: the dataset
* list hands over the API payload, where `extra` is still a JSON string, while
* Explore hands over its bootstrap payload, where `SqlMetric.data` has already
* flattened `extra` into `warning_markdown` and dropped the raw string. The
* parsed blob is therefore only authoritative when `extra` is actually present;
* otherwise the already-flattened value stands, instead of being reset to an
* empty field.
*
* A malformed `extra` string is treated the same as an absent one (falls
* through to the already-flattened value) rather than throwing, mirroring
* the backend's own tolerance for bad `extra` JSON in
* `CertificationMixin.get_extra_dict()`.
*/
export function hydrateMetricExtra(metric: Metric): Metric {
const {
certified_by: certifiedByMetric,
certification_details: certificationDetails,
} = metric;
let parsedExtra;
if (metric.extra) {
try {
parsedExtra = JSON.parse(metric.extra) || {};
} catch {
parsedExtra = undefined;
}
}
const {
certification: {
details = undefined,
certified_by: certifiedBy = undefined,
} = {},
} = parsedExtra || {};
const warningMarkdown = parsedExtra
? parsedExtra.warning_markdown
: metric.warning_markdown;
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}
interface DatasourceEditorOwnProps {
datasource: DatasourceObject;
onChange?: (datasource: DatasourceObject, errors: string[]) => void;
@@ -900,7 +852,25 @@ function DatasourceEditor({
const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
...propsDatasource,
editors: normalizeSubjectsToPickerValues(propsDatasource.editors || []),
metrics: propsDatasource.metrics?.map(hydrateMetricExtra),
metrics: propsDatasource.metrics?.map(metric => {
const {
certified_by: certifiedByMetric,
certification_details: certificationDetails,
} = metric;
const {
certification: {
details = undefined,
certified_by: certifiedBy = undefined,
} = {},
warning_markdown: warningMarkdown,
} = JSON.parse(metric.extra || '{}') || {};
return {
...metric,
certification_details: certificationDetails || details,
warning_markdown: warningMarkdown || metric.warning_markdown || '',
certified_by: certifiedBy || certifiedByMetric,
};
}),
}));
const [errors, setErrors] = useState<string[]>([]);
@@ -1,96 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { hydrateMetricExtra } from '../DatasourceEditor';
const metric = { metric_name: 'sum__num', expression: 'SUM(num)' };
test('lifts the warning and certification out of the extra JSON string', () => {
expect(
hydrateMetricExtra({
...metric,
extra: JSON.stringify({
warning_markdown: 'Handle with care',
certification: { certified_by: 'Data team', details: 'Reviewed' },
}),
}),
).toMatchObject({
warning_markdown: 'Handle with care',
certified_by: 'Data team',
certification_details: 'Reviewed',
});
});
test('keeps an already-flattened warning when the metric carries no extra (#42704)', () => {
// Explore's bootstrap payload flattens `extra` into `warning_markdown` and
// drops the raw string, so the flattened value is all there is to go on.
expect(
hydrateMetricExtra({ ...metric, warning_markdown: 'Handle with care' })
.warning_markdown,
).toBe('Handle with care');
});
test('lets an empty warning in extra clear the flattened value', () => {
expect(
hydrateMetricExtra({
...metric,
warning_markdown: 'stale',
extra: '{}',
}).warning_markdown,
).toBe('');
});
test('normalizes a missing warning to an empty string', () => {
expect(hydrateMetricExtra(metric).warning_markdown).toBe('');
});
test('resolves certification conflicts between the metric and its extra blob', () => {
expect(
hydrateMetricExtra({
...metric,
certified_by: 'Analytics',
certification_details: 'Owned by Analytics',
extra: JSON.stringify({
certification: { certified_by: 'Data team', details: 'Reviewed' },
}),
}),
).toMatchObject({
// extra wins for the certifier, while the metric's own details field wins
// for the description — the certification form writes both back into extra
// on save, so the two settle on the same source afterwards
certified_by: 'Data team',
certification_details: 'Owned by Analytics',
});
});
test('does not throw on malformed extra, falling back like an absent extra', () => {
expect(() =>
hydrateMetricExtra({
...metric,
warning_markdown: 'Handle with care',
extra: '{not valid json',
}),
).not.toThrow();
expect(
hydrateMetricExtra({
...metric,
warning_markdown: 'Handle with care',
extra: '{not valid json',
}).warning_markdown,
).toBe('Handle with care');
});
@@ -60,7 +60,7 @@ export default function DndAdhocFilterOption({
<OptionWrapper
key={index}
index={index}
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
clickClose={onClickClose}
onShiftOptions={onShiftOptions}
@@ -43,7 +43,7 @@ import {
DndFilterSelectProps,
} from 'src/explore/components/controls/DndColumnSelectControl/DndFilterSelect';
import { PLACEHOLDER_DATASOURCE } from 'src/dashboard/constants';
import { Clauses, ExpressionTypes } from '../FilterControl/types';
import { ExpressionTypes } from '../FilterControl/types';
import { DndItemType } from '../../DndItemType';
import { Datasource } from '../../../types';
import {
@@ -137,35 +137,6 @@ test('renders with value', async () => {
expect(await screen.findByText('COUNT(*)')).toBeInTheDocument();
});
test('renders the pill using the column verbose_name when one is set', async () => {
const value = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
render(
setup({
value,
columns: [
{
id: 1,
type: 'BIGINT',
type_generic: GenericDataType.Numeric,
column_name: 'num',
verbose_name: 'total_count',
},
],
}),
{
useDndKit: true,
store,
},
);
expect(await screen.findByText('total_count > 500')).toBeInTheDocument();
});
test('renders options with saved metric', async () => {
render(
setup({
@@ -370,32 +370,4 @@ describe('AdhocFilter', () => {
});
expect(adhocFilter.getDefaultLabel()).toBe('');
});
test('uses the column verbose_name in the label when one is given', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([
{ column_name: 'num', verbose_name: 'total_count' },
]),
).toBe('total_count > 500');
});
test('falls back to the column_name when no verbose_name is set', () => {
const adhocFilter = new AdhocFilter({
expressionType: ExpressionTypes.Simple,
subject: 'num',
operator: '>',
comparator: '500',
clause: Clauses.Where,
});
expect(
adhocFilter.getDefaultLabel([{ column_name: 'num', verbose_name: '' }]),
).toBe('num > 500');
expect(adhocFilter.getDefaultLabel([])).toBe('num > 500');
expect(adhocFilter.getDefaultLabel()).toBe('num > 500');
});
});
@@ -23,7 +23,7 @@ import {
OPERATOR_ENUM_TO_OPERATOR_TYPE,
Operators,
} from 'src/explore/constants';
import { translateToSql, VerboseColumn } from '../utils/translateToSQL';
import { translateToSql } from '../utils/translateToSQL';
import { Clauses, ExpressionTypes } from '../types';
const CUSTOM_OPERATIONS = [...CUSTOM_OPERATORS].map(
@@ -193,8 +193,8 @@ export default class AdhocFilter {
);
}
getDefaultLabel(columns?: VerboseColumn[]): string {
const label = this.translateToSql({ columns });
getDefaultLabel(): string {
const label = this.translateToSql();
return label.length < 43 ? label : `${label.substring(0, 40)}...`;
}
@@ -202,8 +202,8 @@ export default class AdhocFilter {
return this.translateToSql();
}
translateToSql(params: { columns?: VerboseColumn[] } = {}): string {
return translateToSql(this as unknown as CoreAdhocFilter, params);
translateToSql(): string {
return translateToSql(this as unknown as CoreAdhocFilter);
}
}
@@ -23,7 +23,6 @@ import {
screen,
userEvent,
waitFor,
within,
} from 'spec/helpers/testing-library';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
@@ -915,39 +914,3 @@ test('dropdown should remain open when clicked after filter is configured', asyn
expect(operatorDropdown).toHaveAttribute('aria-expanded', 'true');
});
test('filters the subject select by column verbose_name as well as column_name', async () => {
setup({
options: [
{
type: 'BIGINT',
column_name: 'num',
verbose_name: 'total_count',
id: 1,
},
{
type: 'VARCHAR(255)',
column_name: 'name',
verbose_name: 'Full Name',
id: 2,
},
],
});
const combobox = screen.getByRole('combobox', { name: 'Select subject' });
userEvent.click(combobox);
await userEvent.type(combobox, 'total');
const dropdown = document.querySelector(
'.ant-select-dropdown-list',
) as HTMLElement;
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
await userEvent.clear(combobox);
await userEvent.type(combobox, 'num');
expect(within(dropdown).getByText('total_count')).toBeInTheDocument();
expect(within(dropdown).queryByText('Full Name')).not.toBeInTheDocument();
});
@@ -639,11 +639,7 @@ const AdhocFilterEditPopoverSimpleTabContent: FC<Props> = props => {
('optionName' in column && column.optionName) ||
undefined,
label: renderSubjectOptionLabel(column),
column_name: 'column_name' in column ? column.column_name : undefined,
verbose_name:
'verbose_name' in column ? column.verbose_name : undefined,
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...subjectSelectProps}
/>
);
@@ -71,24 +71,6 @@ test('should render the control label', async () => {
expect(await screen.findByText('value > 10')).toBeInTheDocument();
});
test('should render the control label using the column verbose_name when one is set', async () => {
render(
setup({
...mockedProps,
options: [
{
type: 'DOUBLE',
column_name: 'value',
verbose_name: 'total_count',
id: 3,
},
],
}),
{ useDnd: true, useRedux: true },
);
expect(await screen.findByText('total_count > 10')).toBeInTheDocument();
});
test('should render the remove button', async () => {
render(setup(mockedProps), { useDnd: true, useRedux: true });
const removeBtn = await screen.findByTestId('remove-control-button');
@@ -65,7 +65,7 @@ export default function AdhocFilterOption({
partitionColumn={partitionColumn ?? undefined}
>
<OptionControlLabel
label={actualTimeRange ?? adhocFilter.getDefaultLabel(options)}
label={actualTimeRange ?? adhocFilter.getDefaultLabel()}
tooltipTitle={title ?? adhocFilter.getTooltipTitle()}
onRemove={() =>
onRemoveFilter({
@@ -63,35 +63,9 @@ export const OPERATORS_TO_SQL = {
`= '{{ presto.latest_partition('${datasource.schema}.${datasource.datasource_name}') }}'`,
};
export interface VerboseColumn {
column_name?: string;
verbose_name?: string | null;
}
// Resolves the display label for a filter's subject: the verbose_name of the
// matching column when one is supplied, falling back to the technical
// subject used for SQL generation.
const getDisplaySubject = (
subject: string | { column_name?: string } | null | undefined,
columns?: VerboseColumn[],
) => {
if (!columns) {
return subject ?? undefined;
}
const columnName =
typeof subject === 'object' ? subject?.column_name : subject;
const verboseName = columns.find(
column => column.column_name === columnName,
)?.verbose_name;
return verboseName || (subject ?? undefined);
};
export const translateToSql = (
adhocFilter: AdhocFilter,
{
useSimple,
columns,
}: { useSimple?: boolean; columns?: VerboseColumn[] } = {},
{ useSimple }: { useSimple: boolean } = { useSimple: false },
) => {
if (isSimpleAdhocFilter(adhocFilter) || useSimple) {
const { subject, operator } = adhocFilter as SimpleAdhocFilter;
@@ -107,11 +81,7 @@ export const translateToSql = (
OPERATORS_TO_SQL[operator](adhocFilter)
: // @ts-expect-error TODO: fix missing operator type `NOT LIKE` and `TEMPORAL RANGE`.
OPERATORS_TO_SQL[operator];
return getSimpleSQLExpression(
getDisplaySubject(subject, columns),
op,
comparator,
);
return getSimpleSQLExpression(subject, op, comparator);
}
if (isFreeFormAdhocFilter(adhocFilter)) {
return adhocFilter.sqlExpression;
@@ -22,11 +22,10 @@ import FixedOrMetricControl from '.';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const createProps = () => ({
@@ -17,11 +17,9 @@
* under the License.
*/
import { QueryFormData } from '@superset-ui/core';
import { sections, CustomControlItem } from '@superset-ui/chart-controls';
import { getControlStateFromControlConfig } from 'src/explore/controlUtils';
import exploreReducer, { ExploreState } from './exploreReducer';
import { setControlValue, setStashFormData } from '../actions/exploreActions';
import { setStashFormData } from '../actions/exploreActions';
import { QueryFormData } from '@superset-ui/core';
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
const initialState: ExploreState = {
@@ -54,72 +52,3 @@ test('skips updates when the field is already updated on SET_STASH_FORM_DATA', (
const newState = exploreReducer(initialState, restoreAction);
expect(newState).toBe(initialState);
});
// Regression guard for the shared Time Comparison section (used by the Table
// chart, among others): selecting "Custom date" for Time shift and then
// clearing "Shift start date" raises a required-date validation error. When the
// user then switches Time shift to a non-custom preset the error must clear.
// Because `start_date_offset` did not declare `validationDependencies` on
// `time_compare`, SET_FIELD_VALUE never re-ran its mapStateToProps and the stale
// error survived in Redux, blocking further chart updates until a page refresh.
test('SET_FIELD_VALUE clears the custom-shift date error when time_compare leaves "custom"', () => {
const REQUIRED_DATE_ERROR = 'A date is required when using custom date shift';
const timeComparisonSection = sections.timeComparisonControls({
multi: false,
showCalculationType: false,
showFullChoices: false,
});
const timeCompareConfig = (
timeComparisonSection.controlSetRows[0][0] as CustomControlItem
).config;
const startDateOffsetConfig = (
timeComparisonSection.controlSetRows[1][0] as CustomControlItem
).config;
const form_data = {
time_compare: 'custom',
start_date_offset: '2021-01-01',
} as unknown as QueryFormData;
// Build the control states the way the explore store does so they carry the
// real mapStateToProps / validationDependencies from the control config.
const controlPanelState = { controls: {}, form_data };
const initialState: ExploreState = {
form_data,
controls: {
time_compare: getControlStateFromControlConfig(
timeCompareConfig,
controlPanelState,
'custom',
)!,
start_date_offset: getControlStateFromControlConfig(
startDateOffsetConfig,
controlPanelState,
'2021-01-01',
)!,
},
};
// A valid custom date starts without a validation error.
expect(initialState.controls.start_date_offset.validationErrors).toEqual([]);
// 1) Clearing "Shift start date" raises the required-date error (expected).
const afterClear = exploreReducer(
initialState,
setControlValue('start_date_offset', '') as Parameters<
typeof exploreReducer
>[1],
);
expect(afterClear.controls.start_date_offset.validationErrors).toEqual([
REQUIRED_DATE_ERROR,
]);
// 2) Switching Time shift to a non-custom preset must clear the stale error.
const afterSwitch = exploreReducer(
afterClear,
setControlValue('time_compare', '1 week ago') as Parameters<
typeof exploreReducer
>[1],
);
expect(afterSwitch.controls.start_date_offset.validationErrors).toEqual([]);
});
@@ -18,15 +18,9 @@
*/
import { createMemoryHistory, type Update } from 'history';
import { Router } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import {
render,
screen,
fireEvent,
within,
} from 'spec/helpers/testing-library';
import { isFeatureEnabled } from '@superset-ui/core';
import { render, screen, fireEvent } from 'spec/helpers/testing-library';
import type Chart from 'src/types/Chart';
import type { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
import ChartCard from './ChartCard';
jest.mock('@superset-ui/core', () => ({
@@ -43,18 +37,7 @@ const mockChart = {
thumbnail_url: '/thumbnail.png',
} as Chart;
// Admin qualifies as editor, so the card's delete entry is enabled.
const adminUser = {
userId: 1,
username: 'admin',
roles: { Admin: [] },
permissions: {},
} as unknown as UserWithPermissionsAndRoles;
const renderCard = (
history: ReturnType<typeof createMemoryHistory>,
props: Partial<React.ComponentProps<typeof ChartCard>> = {},
) =>
const renderCard = (history: ReturnType<typeof createMemoryHistory>) =>
render(
<Router history={history}>
<ChartCard
@@ -69,7 +52,6 @@ const renderCard = (
favoriteStatus={false}
showThumbnails
handleBulkChartExport={jest.fn()}
{...props}
/>
</Router>,
);
@@ -124,44 +106,3 @@ test('clicking the card outside the thumbnail navigates to the chart', () => {
expect(navigations).toEqual(['PUSH /explore/?slice_id=1']);
});
test('with soft delete on, the card delete flow shows the archive dialog', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
flag => flag === FeatureFlag.SoftDelete,
);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Archive'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Archive Sample Chart?')).toBeInTheDocument();
// The body comes from the shared soft-delete copy module; its exact
// wording evolves there (location hint, retention clause), so pin the
// stable prefix rather than a full sentence.
expect(
within(dialog).getByText(/This chart will be moved to Recently Archived/),
).toBeInTheDocument();
expect(
within(dialog).getByRole('button', { name: 'Archive' }),
).toBeInTheDocument();
// Recoverable deletes drop the type-DELETE friction.
expect(
within(dialog).queryByTestId('delete-modal-input'),
).not.toBeInTheDocument();
});
test('with soft delete off, the card delete dialog is the permanent-delete one', async () => {
(isFeatureEnabled as jest.Mock).mockReturnValue(false);
renderCard(createMemoryHistory(), { user: adminUser });
fireEvent.click(screen.getByTestId('chart-card-menu'));
fireEvent.click(await screen.findByText('Delete'));
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('Please confirm')).toBeInTheDocument();
expect(
within(dialog).getByText(/Are you sure you want to delete/),
).toBeInTheDocument();
expect(within(dialog).getByTestId('delete-modal-input')).toBeInTheDocument();
});
@@ -38,10 +38,6 @@ import {
isNavigationHandledByLink,
} from 'src/views/CRUD/utils';
import { assetUrl } from 'src/utils/assetUrl';
import {
archiveConfirmDescription,
deleteActionLabel,
} from 'src/utils/softDeleteCopy';
import type { ListViewFetchDataConfig as FetchDataConfig } from 'src/components';
import { TableTab } from 'src/views/CRUD/types';
import { isUserEditorOrAdmin } from 'src/dashboard/util/permissionUtils';
@@ -163,29 +159,15 @@ export default function ChartCard({
}
if (canDelete) {
// With soft delete on, deleting archives the chart (recoverable), so the
// confirmation drops the type-DELETE friction and uses the shared archive
// copy -- matching the list view's dialog for the same action.
const softDelete = isFeatureEnabled(FeatureFlag.SoftDelete);
menuItems.push({
key: 'delete',
label: (
<ConfirmStatusChange
recoverable={softDelete}
title={
softDelete
? t('Archive %(name)s?', { name: chart.slice_name })
: t('Please confirm')
}
title={t('Please confirm')}
description={
softDelete ? (
<p>{archiveConfirmDescription(t('chart'))}</p>
) : (
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
?
</>
)
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>?
</>
}
onConfirm={() =>
handleChartDelete(
@@ -222,7 +204,7 @@ export default function ChartCard({
vertical-align: text-top;
`}
/>{' '}
{deleteActionLabel()}
{t('Delete')}
</button>
</Tooltip>
)}
@@ -522,7 +522,7 @@ const ExtraOptions = ({
onChange={onInputChange}
>
{t(
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, Snowflake and Google Sheets)',
'Impersonate logged in user (Presto, Trino, Drill, Hive, Databricks, and Google Sheets)',
)}
</Checkbox>
<InfoTooltip
@@ -532,10 +532,7 @@ const ExtraOptions = ({
'and hive.server2.enable.doAs is enabled, will run the queries as ' +
'service account, but impersonate the currently logged on user via ' +
'hive.server2.proxy.user property. If Databricks, uses OAuth2 to ' +
'authenticate as the currently logged on user. If Snowflake or Google ' +
'Sheets, and OAuth authentication is configured for the database, will ' +
'run the queries as the currently logged on user via their own OAuth ' +
'credentials.',
'authenticate as the currently logged on user.',
)}
/>
</div>
@@ -669,11 +669,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -689,11 +689,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -709,11 +709,11 @@ describe('UploadDataModal Collapse Tabs', () => {
useRedux: true,
});
const generalInfoTab = screen.getByRole('tab', {
name: /General information/i,
name: /expanded General information/i,
});
expect(generalInfoTab).toHaveAttribute('aria-expanded', 'true');
const fileSettingsTab = screen.getByRole('tab', {
name: /File settings/i,
name: /collapsed File settings/i,
});
await userEvent.click(fileSettingsTab);
await waitFor(() => {
@@ -38,11 +38,10 @@ import {
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
@@ -29,11 +29,10 @@ import DatasetPanelWrapper from 'src/features/datasets/AddDataset/DatasetPanel';
jest.mock(
'@superset-ui/core/components/Icons/AsyncIcon',
() =>
({ fileName }: { fileName: string }) =>
(
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
({ fileName }: { fileName: string }) => (
// eslint-disable-next-line jsx-a11y/prefer-tag-over-role -- mirrors AsyncIcon's real span+role="img" shape
<span role="img" aria-label={fileName.replace('_', '-')} />
),
);
const errorMessageRegistry = getErrorMessageComponentRegistry();
@@ -72,12 +72,6 @@ export const PermissionsField = ({
.replace(/_/g, ' ')
.includes(input.toLowerCase().replace(/_/g, ' '))
}
// Permission labels are long ("all datasource access on all_datasource_access",
// "can write on DashboardFilterStateRestApi"), and the dropdown otherwise
// inherits the trigger's width inside the modal, so every option was truncated
// to the point of being indistinguishable. Let the popup size to its content
// instead. See #40430.
popupMatchSelectWidth={false}
getPopupContainer={trigger => trigger.closest('.ant-modal-container')}
data-test="permissions-select"
/>
@@ -25,10 +25,8 @@ import {
fireEvent,
userEvent,
waitFor,
within,
selectOption,
} from 'spec/helpers/testing-library';
import { FeatureFlag, isFeatureEnabled } from '@superset-ui/core';
import { MemoryRouter } from 'react-router-dom';
import { QueryParamProvider } from 'use-query-params';
import { ReactRouter5Adapter } from 'use-query-params/adapters/react-router-5';
@@ -90,15 +88,6 @@ const mockCharts = [
// list so `_info` requests resolve to it rather than the broader list glob.
// withToasts injects the toast callbacks as props; the harness renders no
// toast container, so the spy is the only way to pin what the user is told.
// The type label for the dataset concept is flag-aware (SEMANTIC_LAYERS →
// "Datasource"); mock the flag reader so tests can exercise both states. The
// default (false for every flag) matches the real test environment, where no
// bootstrap flags are set.
jest.mock('@superset-ui/core', () => ({
...jest.requireActual('@superset-ui/core'),
isFeatureEnabled: jest.fn(() => false),
}));
const mockAddDangerToast = jest.fn();
jest.mock('src/components/MessageToasts/withToasts', () => ({
__esModule: true,
@@ -155,13 +144,6 @@ beforeEach(() => {
mockAddDangerToast.mockClear();
});
afterEach(() => {
// The flag mock is shared module state; restore the environment default so a
// flag-flipping test that dies mid-body (e.g. by Jest timeout) cannot leak
// SEMANTIC_LAYERS into whichever test runs next.
(isFeatureEnabled as jest.Mock).mockImplementation(() => false);
});
test('renders archived rows with Name and Type columns', async () => {
mockRoutes();
renderArchivedList();
@@ -591,57 +573,3 @@ test('a viewer who can read none of the types gets an empty state, not three 403
// No list fetch was ever issued.
expect(fetchMock.callHistory.calls(/chart\/\?q/)).toHaveLength(0);
});
test('labels the dataset type "Datasource" when semantic layers is enabled', async () => {
(isFeatureEnabled as jest.Mock).mockImplementation(
(flag: FeatureFlag) => flag === FeatureFlag.SemanticLayers,
);
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Datasource' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Dataset' }),
).not.toBeInTheDocument();
// Selecting the renamed option still drives the dataset resource —
// the underlying type value is flag-independent.
await selectOption('Datasource', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
// Pin the Type COLUMN cell, not just the Select's own rendered value.
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Datasource'),
).toBeInTheDocument();
});
test('labels the dataset type "Dataset" when semantic layers is disabled', async () => {
mockRoutes();
renderArchivedList();
await screen.findByText('Deleted Chart One');
userEvent.click(screen.getByRole('combobox', { name: 'Type' }));
expect(
await screen.findByRole('option', { name: 'Dataset' }),
).toBeInTheDocument();
expect(
screen.queryByRole('option', { name: 'Datasource' }),
).not.toBeInTheDocument();
await selectOption('Dataset', 'Type');
await screen.findByText('deleted_table_one');
expect(
fetchMock.callHistory.calls(datasetListEndpoint).length,
).toBeGreaterThan(0);
const datasetRow = screen.getByText('deleted_table_one').closest('tr');
expect(
within(datasetRow as HTMLElement).getByText('Dataset'),
).toBeInTheDocument();
});
@@ -37,7 +37,6 @@ import {
type ListViewFilters,
} from 'src/components';
import SubMenu from 'src/features/home/SubMenu';
import { datasetLabel } from 'src/features/semanticLayers/label';
import withToasts from 'src/components/MessageToasts/withToasts';
import { recoveredToast } from 'src/utils/softDeleteCopy';
import { findPermission } from 'src/utils/findPermission';
@@ -83,12 +82,10 @@ const EmptyStateRow = styled.div`
`}
`;
// Getters, not strings: the dataset label follows the SEMANTIC_LAYERS flag
// ("Dataset" / "Datasource"), read at render time via the shared naming module.
const TYPE_LABELS: Record<ArchivedType, () => string> = {
chart: () => t('Chart'),
dashboard: () => t('Dashboard'),
dataset: datasetLabel,
const TYPE_LABELS: Record<ArchivedType, string> = {
chart: t('Chart'),
dashboard: t('Dashboard'),
dataset: t('Dataset'),
};
interface ToastProps {
@@ -169,7 +166,7 @@ function ArchivedListBody({
refreshData,
} = useListViewResource<ArchivedItem>(
config.resource,
TYPE_LABELS[type](),
TYPE_LABELS[type],
addDangerToast,
true,
[],
@@ -250,7 +247,7 @@ function ArchivedListBody({
name => {
const { text, options } = recoveredToast(
name,
TYPE_LABELS[type](),
TYPE_LABELS[type],
item.url ?? item.explore_url,
);
addSuccessToast(text, options);
@@ -309,7 +306,7 @@ function ArchivedListBody({
id: config.nameField,
},
{
Cell: () => TYPE_LABELS[type](),
Cell: () => TYPE_LABELS[type],
Header: t('Type'),
id: 'type',
disableSortBy: true,
@@ -542,7 +539,7 @@ function ArchivedList({ addDangerToast, addSuccessToast }: ToastProps) {
onChange={handleTypeChange}
options={availableTypes.map(option => ({
value: option,
label: TYPE_LABELS[option](),
label: TYPE_LABELS[option],
}))}
/>
</TypeSelectRow>
@@ -223,7 +223,7 @@ describe('ChartPage', () => {
window.history.pushState(
{},
'',
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
);
const { getByTestId } = render(<ChartPage />, {
useRouter: true,
@@ -261,13 +261,13 @@ describe('ChartPage', () => {
window.history.pushState(
{},
'',
`/explore/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
`/?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
);
const { getByTestId } = render(
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.dashboardPageId.name}=${dashboardPageId}`,
state: { saveAction: 'overwrite' },
}}
@@ -324,7 +324,7 @@ describe('ChartPage', () => {
});
render(
<>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{
@@ -382,7 +382,7 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
state: toChartStateHistoryState({
...formData,
@@ -392,7 +392,7 @@ describe('ChartPage', () => {
>
Change the chart
</Link>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
@@ -433,14 +433,14 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=99`,
state: toChartStateHistoryState({ ...formData, slice_id: 99 }),
}}
>
Another chart
</Link>
<Link to="/explore/?slice_id=100">Navigate away</Link>
<Link to="/?slice_id=100">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
@@ -477,14 +477,14 @@ describe('ChartPage', () => {
<>
<Link
to={{
pathname: '/explore/',
pathname: '/',
search: `?${URL_PARAMS.sliceId.name}=${formData.slice_id}`,
state: toChartStateHistoryState(formData),
}}
>
Change the chart
</Link>
<Link to="/explore/?slice_id=99">Navigate away</Link>
<Link to="/?slice_id=99">Navigate away</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true, store },
@@ -507,32 +507,6 @@ describe('ChartPage', () => {
window.history.back();
await waitFor(() => expect(loads()).toBe(1));
});
test('does not re-fetch explore data when navigating to a dashboard', async () => {
const exploreApiRoute = 'glob:*/api/v1/explore/*';
const exploreFormData = getExploreFormData({
viz_type: VizType.Table,
show_cell_bars: true,
});
fetchMock.get(exploreApiRoute, {
result: { dataset: { id: 1 }, form_data: exploreFormData },
});
render(
<>
<Link to="/dashboard/5/">Go to dashboard</Link>
<ChartPage />
</>,
{ useRouter: true, useRedux: true, useDnd: true },
);
await waitFor(() =>
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1),
);
fireEvent.click(screen.getByText('Go to dashboard'));
await new Promise(resolve => setTimeout(resolve, 0));
expect(fetchMock.callHistory.calls(exploreApiRoute).length).toBe(1);
});
});
test('does not show error toast when request is aborted on unmount', async () => {
@@ -585,7 +559,7 @@ describe('ChartPage', () => {
render(
<>
<Link to="/explore/?slice_id=99">Navigate</Link>
<Link to="/?slice_id=99">Navigate</Link>
<ChartPage />
</>,
{
@@ -56,11 +56,6 @@ const isValidResult = (rv: JsonObject): boolean =>
const hasDatasetId = (rv: JsonObject): boolean =>
isDefined(rv?.result?.dataset?.id);
const EXPLORE_ROUTE_PREFIX = '/explore/';
const isExploreRoute = (pathname: string): boolean =>
pathname.startsWith(EXPLORE_ROUTE_PREFIX);
const fetchExploreData = async (
exploreUrlParams: URLSearchParams,
signal?: AbortSignal,
@@ -317,8 +312,6 @@ export default function ExplorePage() {
// Other REPLACE: ignored (URL sync from updateHistory).
// Entries holding a chart state of the loaded chart are skipped: Explore
// pushed them itself, and ExploreViewContainer restores a popped one in place.
// Navigations that leave Explore must not trigger a re-fetch while the page
// is unmounting, as the destination's URL params are not chart params.
useEffect(() => {
const unlisten = history.listen((loc: Location, action: Action) => {
const saveAction = (loc.state as Record<string, unknown>)?.saveAction as
@@ -333,9 +326,6 @@ export default function ExplorePage() {
return;
}
}
if (!isExploreRoute(loc.pathname)) {
return;
}
if (action === 'PUSH' || action === 'POP') {
setIsLoaded(false);
loadExploreData(loc, saveAction);
@@ -1,425 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import rison from 'rison';
import { configureStore } from '@reduxjs/toolkit';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'spec/helpers/testing-library';
import userEvent from '@testing-library/user-event';
import DatabaseList from 'src/pages/DatabaseList';
/**
* Deleting a semantic layer cascade-deletes its semantic views (SC-108418).
* These tests pin the delete confirmation's cascade warning: the dependent
* views are counted and named before the user confirms, and a failed lookup
* still opens the modal with an uncounted warning rather than blocking.
*/
const SL_UUID = '6a000000-0000-4000-8000-000000000001';
const SL_UUID_B = '6b000000-0000-4000-8000-000000000002';
const semanticLayerRow = {
source_type: 'semantic_layer',
uuid: SL_UUID,
database_name: 'Demo Semantic Layer',
backend: 'Demo',
sl_type: 'demo',
description: null,
allow_run_async: null,
allow_dml: null,
allow_file_upload: null,
expose_in_sqllab: null,
changed_on_delta_humanized: 'a day ago',
changed_by: null,
};
const semanticLayerRowB = {
...semanticLayerRow,
uuid: SL_UUID_B,
database_name: 'Second Semantic Layer',
};
const CONNECTIONS_ROUTE = 'glob:*/api/v1/semantic_layer/connections/*';
const DATASOURCE_ROUTE = 'glob:*/api/v1/datasource/?*';
const DELETE_ROUTE = `glob:*/api/v1/semantic_layer/${SL_UUID}`;
const mockUser = {
userId: 1,
firstName: 'Admin',
lastName: 'User',
roles: { Admin: [['can_write', 'Database']] },
permissions: {},
isActive: true,
email: 'admin@example.com',
createdOn: '2026-01-01T00:00:00',
};
const dependentView = (id: number, name: string) => ({
id,
table_name: name,
kind: 'semantic_view',
source_type: 'semantic_layer',
});
const setupMocks = ({
dependents,
dependentsError = false,
rows = [semanticLayerRow],
}: {
dependents: { id: number; table_name: string }[];
dependentsError?: boolean;
rows?: (typeof semanticLayerRow)[];
}) => {
fetchMock.clearHistory().removeRoutes();
fetchMock.get('glob:*/api/v1/database/_info*', {
permissions: ['can_read', 'can_write', 'can_export'],
});
fetchMock.get('glob:*/api/v1/database/?q=*', { result: [], count: 0 });
fetchMock.get('glob:*/api/v1/database/related/*', { result: [], count: 0 });
fetchMock.get(CONNECTIONS_ROUTE, {
result: rows,
count: rows.length,
});
if (dependentsError) {
fetchMock.get(DATASOURCE_ROUTE, 500, { name: DATASOURCE_ROUTE });
} else {
fetchMock.get(
DATASOURCE_ROUTE,
{ result: dependents, count: dependents.length },
{ name: DATASOURCE_ROUTE },
);
}
fetchMock.delete(DELETE_ROUTE, {});
};
const renderDatabaseList = () => {
const store = configureStore({
reducer: {
user: (state = mockUser) => state,
common: (
state = {
conf: {
CSV_EXTENSIONS: ['csv'],
EXCEL_EXTENSIONS: ['xls'],
COLUMNAR_EXTENSIONS: ['parquet'],
ALLOWED_EXTENSIONS: ['csv', 'xls', 'parquet'],
SYNC_DB_PERMISSIONS_IN_ASYNC_MODE: false,
},
},
) => state,
},
middleware: getDefaultMiddleware =>
getDefaultMiddleware({ serializableCheck: false, immutableCheck: false }),
});
return render(<DatabaseList user={mockUser} />, {
store,
useQueryParams: true,
useRouter: true,
});
};
const openDeleteModal = async () => {
const deleteButton = await screen.findByTestId('Delete');
await userEvent.click(deleteButton);
return screen.findByRole('dialog');
};
beforeEach(() => {
window.featureFlags = { SEMANTIC_LAYERS: true } as never;
});
afterEach(() => {
window.featureFlags = {} as never;
fetchMock.clearHistory();
fetchMock.removeRoutes();
});
test('delete confirmation warns about cascade-deleting dependent views by count and name', async () => {
setupMocks({
dependents: [
dependentView(1, 'marketing'),
dependentView(2, 'sales'),
dependentView(3, 'orders'),
],
});
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This will also permanently delete its 3 semantic views. Charts built on those views will stop working.',
),
).toBeInTheDocument();
expect(
within(dialog).getByText('Affected semantic views'),
).toBeInTheDocument();
expect(within(dialog).getByText('marketing')).toBeInTheDocument();
expect(within(dialog).getByText('sales')).toBeInTheDocument();
expect(within(dialog).getByText('orders')).toBeInTheDocument();
// The dependent lookup must target this layer's views.
const lookupCalls = fetchMock.callHistory.calls(DATASOURCE_ROUTE);
expect(lookupCalls).toHaveLength(1);
const q = new URL(lookupCalls[0].url).searchParams.get('q') as string;
expect(rison.decode(q)).toMatchObject({
filters: [{ col: 'semantic_layer_uuid', opr: 'eq', value: SL_UUID }],
});
});
test('a single dependent view is announced in the singular', async () => {
setupMocks({ dependents: [dependentView(1, 'marketing')] });
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This will also permanently delete its 1 semantic view. Charts built on that view will stop working.',
),
).toBeInTheDocument();
});
test('a genuinely empty layer says so instead of warning about nonexistent views', async () => {
// A successful count === 0 is always genuinely empty, never
// access-filtering: a layer is only reachable when its perm is granted,
// and the dependent-view count ORs on that same perm.
setupMocks({ dependents: [] });
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This semantic layer has no dependent semantic views.',
),
).toBeInTheDocument();
expect(
within(dialog).queryByText(
/charts built on those views will stop working/i,
),
).not.toBeInTheDocument();
expect(
within(dialog).queryByText('Affected semantic views'),
).not.toBeInTheDocument();
});
test('a counted response with an empty name page keeps the count but omits the list', async () => {
setupMocks({ dependents: [] });
fetchMock.removeRoutes({ names: [DATASOURCE_ROUTE] });
fetchMock.get(
DATASOURCE_ROUTE,
{ result: [], count: 3 },
{ name: DATASOURCE_ROUTE },
);
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This will also permanently delete its 3 semantic views. Charts built on those views will stop working.',
),
).toBeInTheDocument();
expect(
within(dialog).queryByText('Affected semantic views'),
).not.toBeInTheDocument();
});
test('a failed dependent lookup still opens the modal with an uncounted warning', async () => {
setupMocks({ dependents: [], dependentsError: true });
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'Deleting this semantic layer also permanently deletes any semantic views it contains, and charts built on those views will stop working. The affected views could not be listed.',
),
).toBeInTheDocument();
});
test('the overflow footer reports dependent views beyond the listed page', async () => {
// The lookup pages at 10 names; the count is the full total.
setupMocks({ dependents: [] });
fetchMock.removeRoutes({ names: [DATASOURCE_ROUTE] });
fetchMock.get(
DATASOURCE_ROUTE,
{
result: Array.from({ length: 10 }, (_, i) =>
dependentView(i + 1, `view_${i + 1}`),
),
count: 12,
},
{ name: DATASOURCE_ROUTE },
);
renderDatabaseList();
const dialog = await openDeleteModal();
expect(
within(dialog).getByText(
'This will also permanently delete its 12 semantic views. Charts built on those views will stop working.',
),
).toBeInTheDocument();
expect(within(dialog).getByText('view_10')).toBeInTheDocument();
expect(within(dialog).getByText('... and 2 others')).toBeInTheDocument();
});
test('the overflow footer uses the singular for one unlisted view', async () => {
setupMocks({ dependents: [] });
fetchMock.removeRoutes({ names: [DATASOURCE_ROUTE] });
fetchMock.get(
DATASOURCE_ROUTE,
{
result: Array.from({ length: 10 }, (_, i) =>
dependentView(i + 1, `view_${i + 1}`),
),
count: 11,
},
{ name: DATASOURCE_ROUTE },
);
renderDatabaseList();
const dialog = await openDeleteModal();
expect(within(dialog).getByText('... and 1 other')).toBeInTheDocument();
});
test('a pending lookup disables repeated delete requests and shows progress', async () => {
setupMocks({ dependents: [dependentView(1, 'marketing')] });
fetchMock.removeRoutes({ names: [DATASOURCE_ROUTE] });
let releaseLookup: () => void = () => {};
const lookupGate = new Promise<void>(resolve => {
releaseLookup = resolve;
});
fetchMock.get(
DATASOURCE_ROUTE,
async () => {
await lookupGate;
return { result: [dependentView(1, 'marketing')], count: 1 };
},
{ name: DATASOURCE_ROUTE },
);
renderDatabaseList();
const deleteButton = await screen.findByTestId('Delete');
await userEvent.click(deleteButton);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
await waitFor(() => {
expect(screen.getByTestId('Delete')).toHaveAttribute(
'aria-disabled',
'true',
);
});
expect(screen.getByTestId('Delete')).toHaveAccessibleName(
'Loading dependent semantic views',
);
await userEvent.click(screen.getByTestId('Delete'));
expect(fetchMock.callHistory.calls(DATASOURCE_ROUTE)).toHaveLength(1);
releaseLookup();
expect(await screen.findByRole('dialog')).toBeInTheDocument();
});
test("a stale lookup resolving late cannot replace a newer row's modal", async () => {
// Click Delete on layer A (its lookup hangs), then on layer B (resolves
// immediately). When A's lookup finally resolves, the generation guard must
// drop it: the modal keeps showing B's preview.
setupMocks({
dependents: [],
rows: [semanticLayerRow, semanticLayerRowB],
});
fetchMock.removeRoutes({ names: [DATASOURCE_ROUTE] });
let releaseFirstLookup: () => void = () => {};
const firstLookupGate = new Promise<void>(resolve => {
releaseFirstLookup = resolve;
});
fetchMock.get(
DATASOURCE_ROUTE,
async ({ url }) => {
// The layer uuid rides in the rison-encoded `q` filter and its
// characters survive URL encoding, so a substring check is enough to
// tell the two lookups apart.
if (url.includes(SL_UUID)) {
await firstLookupGate;
return { result: [dependentView(1, 'stale_view')], count: 1 };
}
return { result: [dependentView(2, 'fresh_view')], count: 1 };
},
{ name: DATASOURCE_ROUTE },
);
renderDatabaseList();
const deleteButtons = await screen.findAllByTestId('Delete');
expect(deleteButtons).toHaveLength(2);
// fireEvent, not userEvent: userEvent's hover step re-renders the row
// (tooltip) and detaches the pressed node mid-sequence when the table has
// multiple rows, so its click never reaches the handler.
fireEvent.click(deleteButtons[0]);
await waitFor(() => {
expect(screen.getAllByTestId('Delete')[0]).toHaveAttribute(
'aria-disabled',
'true',
);
});
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
fireEvent.click(screen.getAllByTestId('Delete')[1]);
const dialog = await screen.findByRole('dialog');
expect(within(dialog).getByText('fresh_view')).toBeInTheDocument();
await act(async () => {
releaseFirstLookup();
await new Promise(resolve => {
setTimeout(resolve, 0);
});
});
expect(
within(screen.getByRole('dialog')).getByText('fresh_view'),
).toBeInTheDocument();
expect(screen.queryByText('stale_view')).not.toBeInTheDocument();
});
test('confirming the modal deletes the semantic layer', async () => {
setupMocks({ dependents: [dependentView(1, 'marketing')] });
renderDatabaseList();
const dialog = await openDeleteModal();
await userEvent.type(
within(dialog).getByTestId('delete-modal-input'),
'DELETE',
);
await userEvent.click(within(dialog).getByRole('button', { name: 'Delete' }));
await waitFor(() => {
expect(fetchMock.callHistory.calls(DELETE_ROUTE)).toHaveLength(1);
});
});
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import {
getExtensionsRegistry,
SupersetClient,
@@ -24,7 +24,7 @@ import {
FeatureFlag,
} from '@superset-ui/core';
import { css, useTheme } from '@apache-superset/core/theme';
import { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import type { CellProps } from 'react-table';
import rison from 'rison';
import { useSelector } from 'react-redux';
@@ -101,91 +101,6 @@ interface DatabaseDeleteObject extends DatabaseObject {
dashboards: any;
sqllab_tab_count: number;
}
/** How many dependent semantic views the delete confirmation lists by name. */
const MAX_DEPENDENT_VIEWS_LISTED = 10;
type SemanticLayerDeletePreview =
| { status: 'loading'; item: ConnectionItem }
| {
status: 'loaded';
item: ConnectionItem;
dependentViewCount: number;
dependentViewNames: string[];
}
| { status: 'failed'; item: ConnectionItem };
type ResolvedSemanticLayerDeletePreview = Exclude<
SemanticLayerDeletePreview,
{ status: 'loading' }
>;
function SemanticLayerCascadeWarning({
preview,
}: {
preview: ResolvedSemanticLayerDeletePreview;
}) {
if (preview.status === 'failed') {
return (
<p>
{t(
'Deleting this semantic layer also permanently deletes any semantic views it contains, and charts built on those views will stop working. The affected views could not be listed.',
)}
</p>
);
}
// A reachable layer always has all of its views counted (the layer's perm
// and its views' perms travel together), so zero means genuinely empty —
// never access-filtered. An honest empty message keeps the destructive
// warning credible for the layers where it matters.
if (preview.dependentViewCount === 0) {
return <p>{t('This semantic layer has no dependent semantic views.')}</p>;
}
const listedViewCount = preview.dependentViewNames.length;
const overflowViewCount = preview.dependentViewCount - listedViewCount;
return (
<>
<p>
{tn(
'This will also permanently delete its %s semantic view. Charts built on that view will stop working.',
'This will also permanently delete its %s semantic views. Charts built on those views will stop working.',
preview.dependentViewCount,
preview.dependentViewCount,
)}
</p>
{listedViewCount > 0 && (
<>
<h4>{t('Affected semantic views')}</h4>
<List
split={false}
size="small"
dataSource={preview.dependentViewNames}
renderItem={(name: string, index: number) => (
<List.Item key={`${index}-${name}`} compact>
<List.Item.Meta avatar={<span></span>} title={name} />
</List.Item>
)}
footer={
overflowViewCount > 0 && (
<div>
{tn(
'... and %s other',
'... and %s others',
overflowViewCount,
overflowViewCount,
)}
</div>
)
}
/>
</>
)}
</>
);
}
interface DatabaseListProps {
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
@@ -342,8 +257,8 @@ function DatabaseList({
const [slCurrentlyEditing, setSlCurrentlyEditing] = useState<string | null>(
null,
);
const [slDeletePreview, setSlDeletePreview] =
useState<SemanticLayerDeletePreview | null>(null);
const [slCurrentlyDeleting, setSlCurrentlyDeleting] =
useState<ConnectionItem | null>(null);
const [allowUploads, setAllowUploads] = useState<boolean>(false);
const isAdmin = isUserAdmin(fullUser);
@@ -389,43 +304,6 @@ function DatabaseList({
[],
);
// Deleting a semantic layer cascade-deletes its semantic views, so the
// confirmation must say what else is about to be destroyed. If the lookup
// fails the modal still opens, with an uncounted warning: the count is an
// aid, not a gate on deleting. The generation counter drops stale
// resolutions -- without it a slow lookup could reopen a modal the user
// already dismissed, or replace a newer row's modal with an older one.
const slDeleteLookupRef = useRef(0);
const openSemanticLayerDeleteModal = useCallback((item: ConnectionItem) => {
slDeleteLookupRef.current += 1;
const lookupId = slDeleteLookupRef.current;
setSlDeletePreview({ status: 'loading', item });
return SupersetClient.get({
endpoint: `/api/v1/datasource/?q=${rison.encode_uri({
filters: [{ col: 'semantic_layer_uuid', opr: 'eq', value: item.uuid }],
order_column: 'table_name',
order_direction: 'asc',
page: 0,
page_size: MAX_DEPENDENT_VIEWS_LISTED,
})}`,
})
.then(({ json = {} }) => {
if (slDeleteLookupRef.current !== lookupId) return;
setSlDeletePreview({
status: 'loaded',
item,
dependentViewCount: json.count ?? 0,
dependentViewNames: (json.result ?? []).map(
(view: { table_name: string }) => view.table_name,
),
});
})
.catch(() => {
if (slDeleteLookupRef.current !== lookupId) return;
setSlDeletePreview({ status: 'failed', item });
});
}, []);
function handleDatabaseDelete(database: DatabaseObject) {
const { id, database_name: dbName } = database;
SupersetClient.delete({
@@ -688,7 +566,7 @@ function DatabaseList({
() => {
refreshData();
addSuccessToast(t('Deleted: %s', item.database_name));
setSlDeletePreview(null);
setSlCurrentlyDeleting(null);
},
createErrorHandler(errMsg =>
addDangerToast(
@@ -799,29 +677,15 @@ function DatabaseList({
if (isSemanticLayer) {
if (!canEdit && !canDelete) return null;
const isLoadingDependents =
slDeletePreview?.status === 'loading' &&
slDeletePreview.item.uuid === original.uuid;
return (
<div className="actions">
{canDelete && (
<ActionButton
label={t('Delete')}
tooltip={
isLoadingDependents
? t('Loading dependent semantic views')
: t('Delete')
}
tooltip={t('Delete')}
placement="bottom"
icon={
isLoadingDependents ? (
<Icons.LoadingOutlined iconSize="l" spin />
) : (
<Icons.DeleteOutlined iconSize="l" />
)
}
disabled={isLoadingDependents}
onClick={() => openSemanticLayerDeleteModal(original)}
icon={<Icons.DeleteOutlined iconSize="l" />}
onClick={() => setSlCurrentlyDeleting(original)}
/>
)}
{canEdit && (
@@ -917,8 +781,6 @@ function DatabaseList({
handleDatabaseExport,
handleDatabasePermSync,
openDatabaseDeleteModal,
openSemanticLayerDeleteModal,
slDeletePreview,
],
);
@@ -1070,21 +932,20 @@ function DatabaseList({
addSuccessToast={addSuccessToast}
semanticLayerUuid={slCurrentlyEditing ?? undefined}
/>
{slDeletePreview && slDeletePreview.status !== 'loading' && (
{slCurrentlyDeleting && (
<DeleteModal
description={
<>
<p>
{t('Are you sure you want to delete')}{' '}
<b>{slDeletePreview.item.database_name}</b>?
</p>
<SemanticLayerCascadeWarning preview={slDeletePreview} />
</>
<p>
{t('Are you sure you want to delete')}{' '}
<b>{slCurrentlyDeleting.database_name}</b>?
</p>
}
onConfirm={() => {
handleSemanticLayerDelete(slDeletePreview.item);
if (slCurrentlyDeleting) {
handleSemanticLayerDelete(slCurrentlyDeleting);
}
}}
onHide={() => setSlDeletePreview(null)}
onHide={() => setSlCurrentlyDeleting(null)}
open
title={
<ModalTitleWithIcon
@@ -182,7 +182,7 @@ test('With sql role - renders all panels on the page on page load', async () =>
test('With sql role - renders distinct recent activities', async () => {
await renderWelcome();
const recentPanel = screen.getByRole('button', { name: 'Recents' });
const recentPanel = screen.getByRole('button', { name: 'collapsed Recents' });
userEvent.click(recentPanel);
await waitFor(() =>
expect(
@@ -17,7 +17,6 @@
* under the License.
*/
import domToImage from 'dom-to-image-more';
import { getInstanceByDom } from 'echarts/core';
import { addWarningToast } from 'src/components/MessageToasts/actions';
import downloadAsImageOptimized, {
waitForStableScrollHeight,
@@ -28,11 +27,6 @@ jest.mock('dom-to-image-more', () => ({
default: { toJpeg: jest.fn(), toPng: jest.fn() },
}));
jest.mock('echarts/core', () => ({
__esModule: true,
getInstanceByDom: jest.fn(),
}));
jest.mock('src/components/MessageToasts/actions', () => ({
addWarningToast: jest.fn(),
}));
@@ -44,7 +38,6 @@ jest.mock('@apache-superset/core/translation', () => ({
const mockToJpeg = domToImage.toJpeg as jest.Mock;
const mockToPng = domToImage.toPng as jest.Mock;
const mockAddWarningToast = addWarningToast as jest.Mock;
const mockGetInstanceByDom = getInstanceByDom as jest.Mock;
// document.fonts.ready is not implemented in jsdom; provide a resolved promise
Object.defineProperty(document, 'fonts', {
@@ -88,9 +81,6 @@ function attachMockApi(
beforeEach(() => {
jest.clearAllMocks();
// clearAllMocks does not clear a mockReturnValue, so reset the instance lookup explicitly to
// stop a return value leaking into any clone-path test added after the ECharts ones below.
mockGetInstanceByDom.mockReset();
mockToJpeg.mockResolvedValue('data:image/jpeg;base64,test');
mockToPng.mockResolvedValue('data:image/png;base64,test');
});
@@ -744,227 +734,3 @@ test('clone path falls back to white background when theme is absent', async ()
document.body.removeChild(container);
});
// jsdom does not implement HTMLCanvasElement.getContext, so stub a minimal 2d context.
function stubCanvasContext() {
const drawImage = jest.fn();
const spy = jest
.spyOn(HTMLCanvasElement.prototype, 'getContext')
.mockReturnValue({ drawImage } as unknown as CanvasRenderingContext2D);
return { drawImage, restore: () => spy.mockRestore() };
}
test('re-renders an ECharts canvas at PNG_SCALE pixel ratio so the export is crisp', async () => {
const { restore } = stubCanvasContext();
const container = document.createElement('div');
const host = document.createElement('div');
host.className = 'echarts-host';
const canvas = document.createElement('canvas');
// on-screen backing store: CSS 400×300 at devicePixelRatio 1
canvas.width = 400;
canvas.height = 300;
host.appendChild(canvas);
container.appendChild(host);
document.body.appendChild(container);
// Fake ECharts instance whose renderToCanvas returns a 2× (high-res) canvas
const hiRes = document.createElement('canvas');
hiRes.width = 800;
hiRes.height = 600;
let renderOpts: Record<string, unknown> | undefined;
const renderToCanvas = jest.fn((opts?: Record<string, unknown>) => {
renderOpts = opts;
return hiRes;
});
mockGetInstanceByDom.mockReturnValue({ renderToCanvas });
// Capture the cloned canvas backing store handed to dom-to-image
let clonedCanvasWidth: number | undefined;
let clonedCanvasHeight: number | undefined;
mockToPng.mockImplementation((cloneRoot: HTMLElement) => {
const c = cloneRoot.querySelector('canvas');
clonedCanvasWidth = c?.width;
clonedCanvasHeight = c?.height;
return Promise.resolve('data:image/png;base64,test');
});
const handler = downloadAsImageOptimized(
'div',
'Sunburst',
false,
undefined,
{ format: 'png' },
);
await handler(syntheticEventFor(container));
// Instance recovered from the canvas's echarts-host ancestor...
expect(mockGetInstanceByDom).toHaveBeenCalledWith(host);
// ...and re-rendered at PNG_SCALE (2). No backgroundColor is forced, so the chart keeps its
// own configured background (matching the on-screen canvas).
expect(renderToCanvas).toHaveBeenCalled();
expect(renderOpts).toEqual(expect.objectContaining({ pixelRatio: 2 }));
expect(renderOpts).not.toHaveProperty('backgroundColor');
// The cloned canvas dom-to-image serializes is the 2× high-res source
expect(clonedCanvasWidth).toBe(800);
expect(clonedCanvasHeight).toBe(600);
expect(mockToPng).toHaveBeenCalled();
restore();
document.body.removeChild(container);
});
test('preserves a non-ECharts canvas at its on-screen resolution (no re-render)', async () => {
const { drawImage, restore } = stubCanvasContext();
const container = document.createElement('div');
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
container.appendChild(canvas);
document.body.appendChild(container);
let clonedCanvasWidth: number | undefined;
mockToPng.mockImplementation((cloneRoot: HTMLElement) => {
clonedCanvasWidth = cloneRoot.querySelector('canvas')?.width;
return Promise.resolve('data:image/png;base64,test');
});
const handler = downloadAsImageOptimized(
'div',
'Deck Chart',
false,
undefined,
{ format: 'png' },
);
await handler(syntheticEventFor(container));
// No echarts-host ancestor → echarts is never imported/consulted and the on-screen bitmap is
// copied 1:1.
expect(mockGetInstanceByDom).not.toHaveBeenCalled();
expect(clonedCanvasWidth).toBe(400);
expect(drawImage).toHaveBeenCalled();
restore();
document.body.removeChild(container);
});
test('falls back to a 1:1 copy when the ECharts instance is gone (getInstanceByDom returns undefined)', async () => {
const { drawImage, restore } = stubCanvasContext();
// Disposed / not-yet-initialised chart: the host is in the DOM but has no live instance.
mockGetInstanceByDom.mockReturnValue(undefined);
const container = document.createElement('div');
const host = document.createElement('div');
host.className = 'echarts-host';
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
host.appendChild(canvas);
container.appendChild(host);
document.body.appendChild(container);
let clonedCanvasWidth: number | undefined;
mockToPng.mockImplementation((cloneRoot: HTMLElement) => {
clonedCanvasWidth = cloneRoot.querySelector('canvas')?.width;
return Promise.resolve('data:image/png;base64,test');
});
const handler = downloadAsImageOptimized(
'div',
'Sunburst',
false,
undefined,
{ format: 'png' },
);
await handler(syntheticEventFor(container));
expect(mockGetInstanceByDom).toHaveBeenCalledWith(host);
// No instance → the on-screen bitmap is copied 1:1 and the export still completes
expect(clonedCanvasWidth).toBe(400);
expect(drawImage).toHaveBeenCalled();
expect(mockToPng).toHaveBeenCalled();
expect(mockAddWarningToast).not.toHaveBeenCalled();
restore();
document.body.removeChild(container);
});
test('falls back to a 1:1 copy (and still exports) when renderToCanvas throws', async () => {
const { drawImage, restore } = stubCanvasContext();
// A valid but unhealthy instance (mid-dispose, errored chart) whose re-render throws.
const renderToCanvas = jest.fn(() => {
throw new Error('chart is disposing');
});
mockGetInstanceByDom.mockReturnValue({ renderToCanvas });
const container = document.createElement('div');
const host = document.createElement('div');
host.className = 'echarts-host';
const canvas = document.createElement('canvas');
canvas.width = 400;
canvas.height = 300;
host.appendChild(canvas);
container.appendChild(host);
document.body.appendChild(container);
let clonedCanvasWidth: number | undefined;
mockToPng.mockImplementation((cloneRoot: HTMLElement) => {
clonedCanvasWidth = cloneRoot.querySelector('canvas')?.width;
return Promise.resolve('data:image/png;base64,test');
});
const handler = downloadAsImageOptimized(
'div',
'Sunburst',
false,
undefined,
{ format: 'png' },
);
await handler(syntheticEventFor(container));
// The throw is swallowed per-canvas: the export completes via the on-screen 1:1 copy rather
// than aborting the whole capture.
expect(renderToCanvas).toHaveBeenCalled();
expect(clonedCanvasWidth).toBe(400);
expect(drawImage).toHaveBeenCalled();
expect(mockToPng).toHaveBeenCalled();
expect(mockAddWarningToast).not.toHaveBeenCalled();
restore();
document.body.removeChild(container);
});
test('re-renders an ECharts host only once when it owns multiple canvas layers', async () => {
const { restore } = stubCanvasContext();
const container = document.createElement('div');
const host = document.createElement('div');
host.className = 'echarts-host';
// ECharts may add a second <canvas> for a hover/progressive layer
host.appendChild(document.createElement('canvas'));
host.appendChild(document.createElement('canvas'));
container.appendChild(host);
document.body.appendChild(container);
const hiRes = document.createElement('canvas');
hiRes.width = 800;
hiRes.height = 600;
const renderToCanvas = jest.fn(() => hiRes);
mockGetInstanceByDom.mockReturnValue({ renderToCanvas });
const handler = downloadAsImageOptimized(
'div',
'Sunburst',
false,
undefined,
{ format: 'png' },
);
await handler(syntheticEventFor(container));
// Both canvases resolve to the same instance; the flattened render happens once
expect(renderToCanvas).toHaveBeenCalledTimes(1);
restore();
document.body.removeChild(container);
});
@@ -27,23 +27,10 @@ import { forceLoadAllCharts, restoreVirtualization } from './downloadUtils';
const IMAGE_DOWNLOAD_QUALITY = 0.95;
const PNG_SCALE = 2; // Higher quality for PNG
// ECharts canvas charts (e.g. sunburst) bake their pixel detail into the on-screen backing
// store (CSS size × devicePixelRatio). Copying that 1:1 and then letting the PNG path upscale
// it via transform: scale(PNG_SCALE) only stretches the bitmap, producing a blurry export. To
// keep the export crisp, these charts are re-rendered at this pixel ratio at capture time; it is
// tied to PNG_SCALE so the re-render matches the scaled output box.
const EXPORT_CANVAS_PIXEL_RATIO = PNG_SCALE;
// The div passed to ECharts `init()` carries this class (source of truth:
// plugins/plugin-chart-echarts/src/components/Echart.tsx `ECHARTS_HOST_CLASS`). It lets the
// exporter recover the live ECharts instance for a canvas via `getInstanceByDom`.
const ECHARTS_HOST_CLASS = 'echarts-host';
export type BackgroundType = 'transparent' | 'solid';
const TRANSPARENT_RGBA = 'transparent';
const POLL_INTERVAL_MS = 100;
// Resolved lazily via a dynamic import so echarts stays out of the core bundle.
type EChartsGetInstanceByDom = typeof import('echarts/core').getInstanceByDom;
// Tracks original cell styles to restore after capture
type CellFixup = { el: HTMLElement; minHeight: string; overflow: string };
@@ -236,73 +223,30 @@ const processCloneForVisibility = (clone: HTMLElement) => {
});
};
const preserveCanvasContent = (
original: Element,
clone: Element,
getInstanceByDom?: EChartsGetInstanceByDom,
) => {
const preserveCanvasContent = (original: Element, clone: Element) => {
const originalCanvases = original.querySelectorAll('canvas');
const clonedCanvases = clone.querySelectorAll('canvas');
// `renderToCanvas` flattens all of an ECharts instance's zrender layers into a single canvas,
// so once a host is re-rendered its other <canvas> layers (e.g. a hover layer) are skipped;
// if the re-render throws, the host is marked 'failed' so each layer falls back to a 1:1 copy.
const hostRenderState = new Map<Element, 'rendered' | 'failed'>();
originalCanvases.forEach((originalCanvas, i) => {
const clonedCanvas = clonedCanvases[i] as HTMLCanvasElement | undefined;
if (!clonedCanvas) return;
const ctx = clonedCanvas.getContext('2d');
if (!ctx) return;
// For ECharts (canvas renderer) charts such as sunburst, re-render the chart at a higher
// pixel ratio instead of copying the on-screen bitmap, so the PNG path upscales a matching
// high-resolution source rather than stretching a low-resolution one. `getInstanceByDom` is
// only supplied on the PNG path, so JPEG (and non-ECharts canvases) keep the 1:1 copy below.
const host = originalCanvas.closest(`.${ECHARTS_HOST_CLASS}`);
const hostState = host ? hostRenderState.get(host) : undefined;
// Sibling layer of a host already re-rendered: the flattened render covers it.
if (hostState === 'rendered') return;
const instance =
host && hostState !== 'failed'
? getInstanceByDom?.(host as HTMLElement)
: undefined;
if (host && instance) {
try {
// No `backgroundColor` is passed, so renderToCanvas inherits the chart's own configured
// background (transparent when unset) — matching the on-screen canvas. The overall export
// background is applied separately via the dom-to-image `bgcolor` option.
const hiResCanvas = instance.renderToCanvas({
pixelRatio: EXPORT_CANVAS_PIXEL_RATIO,
});
clonedCanvas.width = hiResCanvas.width;
clonedCanvas.height = hiResCanvas.height;
ctx.drawImage(hiResCanvas, 0, 0);
hostRenderState.set(host, 'rendered');
return;
} catch {
// A valid but unhealthy instance (mid-dispose, errored chart) can throw. Mark the host
// 'failed' and fall back to the on-screen 1:1 copy below so a single bad chart can never
// abort the whole export (e.g. a 20-chart dashboard capture).
hostRenderState.set(host, 'failed');
if (originalCanvases[i] && clonedCanvases[i]) {
const clonedCanvas = clonedCanvases[i] as HTMLCanvasElement;
const ctx = clonedCanvas.getContext('2d');
if (ctx) {
clonedCanvas.width = originalCanvas.width;
clonedCanvas.height = originalCanvas.height;
ctx.drawImage(originalCanvas, 0, 0);
}
}
// Non-ECharts canvases (deck.gl/WebGL and friends), and the fallback when a re-render is
// unavailable or threw: preserve the on-screen bitmap as-is.
clonedCanvas.width = originalCanvas.width;
clonedCanvas.height = originalCanvas.height;
ctx.drawImage(originalCanvas, 0, 0);
});
};
const createEnhancedClone = (
originalElement: Element,
theme?: SupersetTheme,
getInstanceByDom?: EChartsGetInstanceByDom,
): { clone: HTMLElement; cleanup: () => void } => {
const clone = originalElement.cloneNode(true) as HTMLElement;
copyAllComputedStyles(originalElement, clone, theme);
preserveCanvasContent(originalElement, clone, getInstanceByDom);
preserveCanvasContent(originalElement, clone);
const tempContainer = document.createElement('div');
tempContainer.style.cssText = `
@@ -560,26 +504,10 @@ export default function downloadAsImageOptimized(
// All other chart types: use the clone-based approach
let cleanup: (() => void) | null = null;
// Only the PNG path upscales the layout (transform: scale(PNG_SCALE)), so only there does a
// higher-resolution canvas help; JPEG keeps the throw-free 1:1 copy. Re-render ECharts
// (canvas renderer) charts, e.g. sunburst, at a higher pixel ratio so the upscale samples a
// matching source instead of stretching the on-screen bitmap. echarts is pulled in lazily —
// only when a chart is actually present — so it stays out of the core bundle; if the import
// fails the canvases fall back to a 1:1 copy.
let getInstanceByDom: EChartsGetInstanceByDom | undefined;
if (isPng && elementToPrint.querySelector(`.${ECHARTS_HOST_CLASS}`)) {
try {
({ getInstanceByDom } = await import('echarts/core'));
} catch {
// echarts not available in this context; canvases keep their on-screen resolution.
}
}
try {
const { clone, cleanup: cleanupFn } = createEnhancedClone(
elementToPrint,
theme,
getInstanceByDom,
);
cleanup = cleanupFn;
@@ -96,29 +96,19 @@ test('a malformed window does not leak into the copy', () => {
test('the confirm copy quotes the window when there is one', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 30 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 30 days.',
'This chart will be moved to Recently Archived. You can recover it there within 30 days.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 30 days.',
);
});
test('a one-day window is quoted in the singular', () => {
withConf({ SOFT_DELETE_RETENTION_DAYS: 1 });
expect(archiveConfirmDescription('chart')).toBe(
'This chart will be moved to Recently Archived in the Settings menu. You can recover it there within 1 day.',
);
expect(archiveConfirmDescription('charts', true)).toBe(
'These charts will be moved to Recently Archived in the Settings menu. You can recover them there within 1 day.',
'These charts will be moved to Recently Archived. You can recover them there within 30 days.',
);
});
test('the confirm copy omits the clause when there is no window', () => {
withConf({});
expect(archiveConfirmDescription('dashboard')).toBe(
'This dashboard will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This dashboard will be moved to Recently Archived. You can recover it there.',
);
expect(archiveConfirmDescription('dashboards', true)).toBe(
'These dashboards will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These dashboards will be moved to Recently Archived. You can recover them there.',
);
});
+7 -14
View File
@@ -17,7 +17,7 @@
* under the License.
*/
import { escape } from 'lodash-es';
import { t, tn } from '@apache-superset/core/translation';
import { t } from '@apache-superset/core/translation';
import { isFeatureEnabled, FeatureFlag } from '@superset-ui/core';
import getBootstrapData from 'src/utils/getBootstrapData';
@@ -62,32 +62,25 @@ export function archiveConfirmDescription(
// Each case is a single, complete translation unit (rather than two joined
// fragments) so translators control the whole sentence; only the noun and the
// day count are interpolated, matching Superset's existing `%(...)s` usage.
// The timed variants pluralize on the day count (`tn`) because the retention
// window accepts 1: "within 1 days" is exactly the copy defect this module
// exists to prevent.
const days = getSoftDeleteRetentionDays();
if (days) {
return plural
? tn(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s day.',
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there within %(days)s days.',
days,
? t(
'These %(type)s will be moved to Recently Archived. You can recover them there within %(days)s days.',
{ type: typeLabel, days },
)
: tn(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s day.',
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there within %(days)s days.',
days,
: t(
'This %(type)s will be moved to Recently Archived. You can recover it there within %(days)s days.',
{ type: typeLabel, days },
);
}
return plural
? t(
'These %(type)s will be moved to Recently Archived in the Settings menu. You can recover them there.',
'These %(type)s will be moved to Recently Archived. You can recover them there.',
{ type: typeLabel },
)
: t(
'This %(type)s will be moved to Recently Archived in the Settings menu. You can recover it there.',
'This %(type)s will be moved to Recently Archived. You can recover it there.',
{ type: typeLabel },
);
}
@@ -43,21 +43,6 @@ function findEndpoint(spy: jest.SpyInstance, substring: string): string {
return (match[0] as Record<string, string>).endpoint;
}
function deferredJsonResponse() {
let resolveResponse: ((value: JsonResponse) => void) | undefined;
let rejectResponse: ((reason?: unknown) => void) | undefined;
const promise = new Promise<JsonResponse>((resolve, reject) => {
resolveResponse = resolve;
rejectResponse = reject;
});
if (!resolveResponse || !rejectResponse) {
throw new Error('Deferred response handlers were not initialized');
}
return { promise, resolve: resolveResponse, reject: rejectResponse };
}
beforeEach(() => {
jest.restoreAllMocks();
});
@@ -297,147 +282,6 @@ test('useListViewResource: fetchData sets loading to true then false', async ()
});
});
test('useListViewResource: ignores an older response that resolves last', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const toISOString = jest
.spyOn(Date.prototype, 'toISOString')
.mockReturnValueOnce('newer-response-time')
.mockReturnValueOnce('older-response-time');
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
newer.resolve({
json: { result: [], count: 0 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }, { id: 2 }], count: 2 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.resourceCount).toBe(0);
expect(result.current.state.lastFetched).toBe('newer-response-time');
expect(toISOString).toHaveBeenCalledTimes(1);
});
test('useListViewResource: stale completion keeps the latest request loading', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', jest.fn(), false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.resolve({
json: { result: [{ id: 1 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([]);
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.resolve({
json: { result: [{ id: 2 }], count: 1 },
} as unknown as JsonResponse);
});
expect(result.current.state.resourceCollection).toEqual([{ id: 2 }]);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: only the latest request reports an error', async () => {
const older = deferredJsonResponse();
const newer = deferredJsonResponse();
const handleErrorMsg = jest.fn();
jest
.spyOn(SupersetClient, 'get')
.mockReturnValueOnce(older.promise)
.mockReturnValueOnce(newer.promise);
const { result } = renderHook(() =>
useListViewResource('chart', 'Charts', handleErrorMsg, false),
);
act(() => {
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [],
});
result.current.fetchData({
pageIndex: 0,
pageSize: 25,
sortBy: [{ id: 'name' }],
filters: [{ id: 'name', operator: 'ct', value: 'newer' }],
});
});
await act(async () => {
older.reject('older request failed');
});
expect(handleErrorMsg).not.toHaveBeenCalled();
expect(result.current.state.loading).toBe(true);
await act(async () => {
newer.reject('newer request failed');
});
expect(handleErrorMsg).toHaveBeenCalledTimes(1);
expect(result.current.state.loading).toBe(false);
});
test('useListViewResource: refreshData re-fetches with last config', async () => {
const getSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: { result: [], count: 0 },
+10 -21
View File
@@ -148,7 +148,6 @@ export function useListViewResource<D extends object = any>(
);
const lastFetchDataConfigRef = useRef<FetchDataConfig | null>(null);
const latestRequestIdRef = useRef(0);
const fetchData = useCallback(
({
@@ -157,9 +156,6 @@ export function useListViewResource<D extends object = any>(
sortBy,
filters: filterValues,
}: FetchDataConfig) => {
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
const isLatest = () => latestRequestIdRef.current === requestId;
const config: FetchDataConfig = {
filters: filterValues,
pageIndex,
@@ -200,31 +196,24 @@ export function useListViewResource<D extends object = any>(
})
.then(
({ json = {} }) => {
if (!isLatest()) {
return;
}
updateState({
collection: json.result,
count: json.count,
lastFetched: new Date().toISOString(),
});
},
createErrorHandler(errMsg => {
if (isLatest()) {
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
);
}
}),
createErrorHandler(errMsg =>
handleErrorMsg(
t(
'An error occurred while fetching %ss: %s',
resourceLabel,
errMsg,
),
),
),
)
.finally(() => {
if (isLatest()) {
updateState({ loading: false });
}
updateState({ loading: false });
});
},
[
+344 -282
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -37,11 +37,11 @@
"eslint": "^10.8.1",
"eslint-config-prettier": "^10.1.8",
"globals": "^17.11.0",
"oxfmt": "^0.64.0",
"oxfmt": "^0.63.0",
"tscw-config": "^1.1.2",
"typescript": "^6.0.3",
"typescript-eslint": "^8.67.0",
"vitest": "^4.1.11"
"vitest": "^4.1.10"
},
"engines": {
"node": "^24.16.0",
+10
View File
@@ -28,6 +28,16 @@ from werkzeug.local import LocalProxy
# form.
flask_appbuilder.Model.__allow_unmapped__ = True
# pandas >= 2.2 advertises a minimum SQLAlchemy of 2.0 and silently ignores
# older installations, breaking DataFrame.to_sql / read_sql with SQLAlchemy
# 1.4 engines. Its SQL layer still works with 1.4, so restore support until
# Superset itself requires SQLAlchemy >= 2. Must run before any pandas SQL IO.
from superset.utils.pandas_sqlalchemy_compat import ( # noqa: E402
restore_pandas_sqlalchemy_support,
)
restore_pandas_sqlalchemy_support()
from superset.app import create_app # noqa: E402, F401
from superset.extensions import ( # noqa: E402
appbuilder, # noqa: F401
+36
View File
@@ -52,6 +52,42 @@ class DatabaseRequiredFieldValidationError(ValidationError):
)
class DatabaseExtraJSONValidationError(ValidationError):
"""
Marshmallow validation error for database encrypted extra must be a valid JSON
"""
def __init__(self, json_error: str = "") -> None:
super().__init__(
[
_(
"Field cannot be decoded by JSON. %(json_error)s",
json_error=json_error,
)
],
field_name="extra",
)
class DatabaseExtraValidationError(ValidationError):
"""
Marshmallow validation error for database encrypted extra must be a valid JSON
"""
def __init__(self, key: str = "") -> None:
super().__init__(
[
_(
"The metadata_params in Extra field "
"is not configured correctly. The key "
"%{key}s is invalid.",
key=key,
)
],
field_name="extra",
)
class DatabaseConnectionSyncPermissionsError(CommandException):
status = 500
message = _("Unable to sync permissions for this database connection.")
+23 -99
View File
@@ -24,10 +24,8 @@ 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. Completed records are immutable. Consecutive scheduled evaluations
that remain blocked for the same reason may discard only their current
redundant provisional row a reason change retains one new row carrying the
new code; force-purge and other meaningful outcomes are retained
independently.
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
@@ -89,10 +87,6 @@ class _AuditRecoverySnapshot:
entity_type: str
entity_uuid: str | None
created_on: datetime
# Sourced from the finalization call's reason argument, never from the
# row: pending rows are reason-less by design, so reading the row here
# would silently record NULL.
reason: str | None
def _utc_now() -> datetime:
@@ -142,18 +136,8 @@ def write_ahead(
session.close()
def finalize(
record_id: UUID | None,
status: str,
*,
reason: str | None = None,
**details: Any,
) -> None:
"""Finalize a pending attempt on the dedicated audit session.
``reason`` is persisted only for blocked outcomes; the audit records a
cause for a purge that did not happen, never for one that did.
"""
def finalize(record_id: UUID | None, status: str, **details: Any) -> None:
"""Finalize a pending attempt on the dedicated audit session."""
if record_id is None:
return
session = _dedicated_session()
@@ -164,8 +148,6 @@ def finalize(
referrers = details.get("affected_referrers")
if referrers:
values["affected_referrers"] = ",".join(referrers)
if reason is not None and status == STATUS_BLOCKED:
values["reason"] = reason
removed_dashboard_slices = details.get("removed_dashboard_slices")
if removed_dashboard_slices is not None:
values["removed_dashboard_slices"] = removed_dashboard_slices
@@ -210,21 +192,12 @@ def fail(record_id: UUID | None) -> None:
finalize(record_id, STATUS_FAILED)
def block(record_id: UUID | None, reason: str | None) -> None:
"""Mark an attempt blocked by ordinary deletion policy.
``reason`` is a stable machine code from the closed ``REASON_*``
vocabulary in :mod:`superset.commands.deletion_retention.purge_policy`.
It is required by signature (every blocked outcome has a classified
cause); ``None`` is tolerated defensively so a threading gap can never
block a purge, and leaves the persisted reason NULL.
"""
finalize(record_id, STATUS_BLOCKED, reason=reason)
def block(record_id: UUID | None) -> None:
"""Mark an attempt blocked by ordinary deletion policy."""
finalize(record_id, STATUS_BLOCKED)
def _capture_recovery_snapshot(
record: PurgeAuditLog, reason: str | None
) -> _AuditRecoverySnapshot:
def _capture_recovery_snapshot(record: PurgeAuditLog) -> _AuditRecoverySnapshot:
"""Capture the content-free fields needed for fail-safe recovery."""
return _AuditRecoverySnapshot(
id=cast(UUID, record.id),
@@ -232,37 +205,26 @@ def _capture_recovery_snapshot(
entity_type=str(record.entity_type),
entity_uuid=record.entity_uuid,
created_on=cast(datetime, record.created_on),
reason=reason,
)
def _retention_predecessor(
session: Session, current: PurgeAuditLog
) -> PurgeAuditLog | None:
"""Return the latest row that could unambiguously precede ``current``.
The latest same-entity retention row by ``created_on`` deliberately not
bounded by ``current.created_on``. If another visible row has a later
timestamp, it surfaces here so the caller's strictly-older check retains
the current row. Timestamps provide database ordering for this predicate,
not causal ordering across workers.
"""
"""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
# A timestamp-tied row differing in status OR reason makes the
# predecessor ambiguous. ``is_distinct_from`` keeps the reason
# comparison NULL-safe on all supported dialects (reason is nullable;
# status is not).
tied_mixed_exists: bool = session.execute(
tied_mixed_status_exists: bool = session.execute(
sa.select(
sa.exists().where(
PurgeAuditLog.entity_uuid == current.entity_uuid,
@@ -270,43 +232,23 @@ def _retention_predecessor(
PurgeAuditLog.trigger == TRIGGER_RETENTION,
PurgeAuditLog.created_on == predecessor.created_on,
PurgeAuditLog.id != current.id,
sa.or_(
PurgeAuditLog.status != predecessor.status,
PurgeAuditLog.reason.is_distinct_from(predecessor.reason),
),
PurgeAuditLog.status != predecessor.status,
)
)
).scalar_one()
if tied_mixed_exists:
if tied_mixed_status_exists:
return None
return predecessor
def _suppress_redundant_block(
session: Session,
current: PurgeAuditLog,
predecessor: PurgeAuditLog | None,
reason: str | None,
session: Session, current: PurgeAuditLog, predecessor: PurgeAuditLog | None
) -> bool:
"""Delete a pending row only against a strictly older same-reason block."""
if not reason:
# Fail safe on a threading gap: a missing current code must retain
# the row (and be visible), never silently revive the status-only
# predicate.
logger.warning(
"deletion_retention: blocked audit row %s has no reason code; "
"refusing suppression",
current.id,
)
return False
"""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
# A reason-less (pre-feature) predecessor never matches: the first
# post-upgrade block of a long-blocked entity is retained once and
# becomes the new suppression anchor.
or predecessor.reason != reason
):
return False
deleted_rows: int | None = session.execute(
@@ -322,7 +264,7 @@ def _suppress_redundant_block(
return deleted_rows == 1
def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> None:
def _retain_blocked(session: Session, record_id: UUID) -> None:
"""Conditionally retain the current provisional row as blocked."""
session.execute(
sa.update(PurgeAuditLog.__table__)
@@ -330,7 +272,7 @@ def _retain_blocked(session: Session, record_id: UUID, reason: str | None) -> No
PurgeAuditLog.__table__.c.id == record_id,
PurgeAuditLog.__table__.c.status == STATUS_PENDING,
)
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0, reason=reason)
.values(status=STATUS_BLOCKED, removed_dashboard_slices=0)
)
@@ -343,11 +285,7 @@ def _recover_retention_blocked(
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,
snapshot.reason if snapshot else None,
)
_retain_blocked(recovery_session, record_id)
recovery_session.commit()
return "fallback"
if snapshot is None:
@@ -362,7 +300,6 @@ def _recover_retention_blocked(
entity_uuid=snapshot.entity_uuid,
removed_dashboard_slices=0,
created_on=snapshot.created_on,
reason=snapshot.reason,
)
)
recovery_session.commit()
@@ -382,14 +319,9 @@ def _recover_retention_blocked(
def finalize_retention_blocked(
record_id: UUID | None, reason: str | None
record_id: UUID | None,
) -> RetentionBlockedDisposition:
"""Finalize a scheduled blocker, suppressing only proven redundant evidence.
``reason`` is the stable machine code for the block (see
:func:`block`); it is persisted on retained rows and captured in the
snapshot used by the crash-recovery path.
"""
"""Finalize a scheduled blocker, suppressing only proven redundant evidence."""
if record_id is None:
return "fallback"
session: Session = _dedicated_session()
@@ -398,17 +330,15 @@ def finalize_retention_blocked(
current: PurgeAuditLog | None = session.get(PurgeAuditLog, record_id)
if current is None:
return "fallback"
snapshot = _capture_recovery_snapshot(current, reason)
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, reason
)
suppressed: bool = _suppress_redundant_block(session, current, predecessor)
if not suppressed:
_retain_blocked(session, record_id, reason)
_retain_blocked(session, record_id)
session.commit()
return "suppressed" if suppressed else "retained"
except SQLAlchemyError:
@@ -453,12 +383,6 @@ def reconcile_pending(stale_before: datetime | None = None) -> dict[str, int]:
``confirmed``. A surviving or unresolvable entity means the attempt did
not durably purge it and is finalized as failed; normal selection may
retry.
An attempt that had already decided ``blocked`` when its worker died is
indistinguishable here from any other stalled attempt, so it reconciles
as failed with no reason: pending rows carry no reason by design, and
inventing one would assert evidence this process never witnessed. The
next scheduled attempt re-anchors the entity with its real code.
"""
cutoff = stale_before or _utc_now() - _PENDING_STALE_AFTER
reconciled = absent = failed = 0
@@ -183,7 +183,7 @@ class ForcePurgeCommand:
removed_dashboard_slices=result.removed_dashboard_slices,
)
elif result.blocked_reason is not None:
audit.block(record_id, result.blocker.code if result.blocker else None)
audit.block(record_id)
else:
audit.fail(record_id)
if result.purged:
@@ -53,11 +53,9 @@ from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from superset.commands.deletion_retention.purge_policy import (
BlockerReason,
get_purge_policy,
PurgeBlockedError,
PurgeEntityPolicy,
REASON_CASCADE_INTEGRITY_FAILURE,
)
logger: logging.Logger = logging.getLogger(__name__)
@@ -146,17 +144,7 @@ class CascadeResult:
dangling_chart_uuids: list[str] = field(default_factory=list)
removed_dashboard_slices: int = 0
version_rows_removed: int = 0
blocker: BlockerReason | None = None
@property
def blocked_reason(self) -> str | None:
"""Return the operator-facing blocker phrase, if the purge was blocked."""
return self.blocker.phrase if self.blocker else None
@property
def blocked_reason_code(self) -> str | None:
"""Return the stable audit code, if the purge was blocked."""
return self.blocker.code if self.blocker else None
blocked_reason: str | None = None
class PurgeRaceLostError(Exception):
@@ -279,21 +267,20 @@ def cascade_hard_delete(
purged=False,
entity_type=entity_type,
entity_uuid=uuid,
blocker=ex.reason,
blocked_reason=str(ex),
)
except IntegrityError as ex:
# Not a policy decision: a database integrity constraint failed.
# Not a policy decision: a restrictive FK the cascade did not handle.
# Two audiences, two messages. The curated reason goes to the caller
# (and from there into a user toast), because raw driver text carries
# the failing SQL and bind parameters. The constraint detail goes to
# the log at WARNING, because an entity permanently unpurgeable after
# an integrity failure represents a cascade defect someone has to be
# able to diagnose. The stable audit code identifies this as an unexpected
# cascade failure rather than intended policy behavior without
# claiming which kind of constraint the database reported.
# the log at WARNING, because an entity permanently unpurgeable via an
# unknown FK is a cascade-coverage bug someone has to be able to
# diagnose -- reported at INFO as a policy block, it read as intended
# behaviour.
logger.warning(
"deletion_retention: %s id=%s purge failed on a database "
"integrity constraint: %s",
"deletion_retention: %s id=%s purge failed on a restrictive "
"foreign key the cascade does not handle: %s",
entity_type,
entity_id,
ex,
@@ -302,10 +289,7 @@ def cascade_hard_delete(
purged=False,
entity_type=entity_type,
entity_uuid=uuid,
blocker=BlockerReason(
REASON_CASCADE_INTEGRITY_FAILURE,
"cascade blocked by a database integrity constraint",
),
blocked_reason="blocked by database references",
)
return CascadeResult(
@@ -24,7 +24,7 @@ from dataclasses import dataclass
from enum import Enum
from functools import lru_cache
from types import MappingProxyType
from typing import Any, cast, NamedTuple
from typing import Any, cast
import sqlalchemy as sa
from sqlalchemy.orm import Mapper, Session
@@ -36,44 +36,10 @@ from superset.utils.sqlalchemy_events import (
logger: logging.Logger = logging.getLogger(__name__)
# Stable machine-readable reason codes persisted on purge audit records.
# The values are frozen identifiers pinned by a golden-set test: they equal
# the related-table names at introduction by coincidence, never by derivation,
# so a physical table rename changes only the blocker mapping's key and
# leaves the persisted code untouched — audit history and the suppression
# predicate compare these literals.
REASON_REPORT_SCHEDULE: str = "report_schedule"
REASON_USER_ATTRIBUTE: str = "user_attribute"
REASON_CASCADE_INTEGRITY_FAILURE: str = "cascade_integrity_failure"
ALL_REASON_CODES: frozenset[str] = frozenset(
{
REASON_REPORT_SCHEDULE,
REASON_USER_ATTRIBUTE,
REASON_CASCADE_INTEGRITY_FAILURE,
}
)
class BlockerReason(NamedTuple):
"""One blocker's persisted audit code paired with its operator phrase."""
code: str
phrase: str
class PurgeBlockedError(Exception):
"""Raised when ordinary deletion policy forbids purging an entity."""
def __init__(self, reason: BlockerReason) -> None:
super().__init__(reason.phrase)
self.reason: BlockerReason = reason
@property
def reason_code(self) -> str:
"""Return the stable machine-readable blocker code."""
return self.reason.code
class DependencyClassification(str, Enum):
"""Describe how purge treats a persistence dependency."""
@@ -140,21 +106,11 @@ class DependencyPolicy:
key: DependencyKey
classification: DependencyClassification
phase: ExecutionPhase | None = None
blocker: BlockerReason | None = None
blocked_reason: str | None = None
optional_listener: bool = False
listener_action: ListenerAction | None = None
version_column: str | None = None
@property
def blocked_reason(self) -> str | None:
"""Return the operator-facing blocker phrase, if this policy blocks."""
return self.blocker.phrase if self.blocker else None
@property
def blocked_reason_code(self) -> str | None:
"""Return the stable audit code, if this policy blocks."""
return self.blocker.code if self.blocker else None
@dataclass(frozen=True)
class PurgeEntityPolicy:
@@ -460,7 +416,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
keys: tuple[DependencyKey, ...],
classifications: tuple[DependencyClassification, ...],
synthetic: tuple[DependencyPolicy, ...],
blocked_reasons: Mapping[str, BlockerReason] = MappingProxyType({}),
blocked_reasons: Mapping[str, str] = MappingProxyType({}),
version_columns: Mapping[str, str] = MappingProxyType({}),
) -> tuple[DependencyPolicy, ...]:
phases: dict[DependencyClassification, ExecutionPhase | None] = {
@@ -472,22 +428,15 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
}
if len(keys) != len(classifications):
raise ValueError("Every dependency key requires one classification")
def declare(
key: DependencyKey, classification: DependencyClassification
) -> DependencyPolicy:
blocker: BlockerReason | None = blocked_reasons.get(key.related_table)
return DependencyPolicy(
key,
classification,
phases[classification],
blocker=blocker,
version_column=version_columns.get(key.related_table),
)
return (
tuple(
declare(key, classification)
DependencyPolicy(
key,
classification,
phases[classification],
blocked_reason=blocked_reasons.get(key.related_table),
version_column=version_columns.get(key.related_table),
)
for key, classification in zip(keys, classifications, strict=True)
)
+ synthetic
@@ -590,12 +539,7 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
),
(tag_cleanup, chart_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
{
"report_schedule": BlockerReason(
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
)
},
{"report_schedule": "associated alerts or reports exist"},
{"slices_version": "id"},
),
validate=validate_deletion_allowed,
@@ -743,16 +687,10 @@ def purge_policy_registry() -> Mapping[type[Any], PurgeEntityPolicy]:
DependencyClassification.PRESERVE,
),
(tag_cleanup, dashboard_membership_versions),
# Keyed by related table; the audit code is declared, not derived.
# Declaration order is part of the audit contract: the first
# matching blocker's code is the one recorded.
{
"report_schedule": BlockerReason(
REASON_REPORT_SCHEDULE, "associated alerts or reports exist"
),
"user_attribute": BlockerReason(
REASON_USER_ATTRIBUTE,
"a user has this dashboard set as their welcome page",
"report_schedule": "associated alerts or reports exist",
"user_attribute": (
"a user has this dashboard set as their welcome page"
),
},
{"dashboards_version": "id"},
@@ -955,9 +893,9 @@ def validate_deletion_allowed(
if session.execute(
sa.select(sa.literal(1)).select_from(table).where(*predicates).limit(1)
).first():
if dependency.blocker is None:
if dependency.blocked_reason is None:
raise RuntimeError(f"Missing blocker reason for {key.describe()}")
raise PurgeBlockedError(dependency.blocker)
raise PurgeBlockedError(dependency.blocked_reason)
def count_dashboard_slices(
+2 -2
View File
@@ -138,8 +138,8 @@ class BaseRestoreVersionCommand(BaseCommand):
# With capture off, Continuum's write listeners are detached: a
# revert would mutate the live entity with NO new version row —
# a destructive, untracked write. The whole restore surface is
# therefore inert under the kill-switch (404, indistinguishable from
# "no such version"). Existing history remains readable.
# therefore inert under the kill-switch, matching the read-side
# convention (404, indistinguishable from "no such version").
if not capture_enabled():
raise self.not_found_exc()
entity = find_active_by_uuid(self.model_cls, self._uuid)
+6 -19
View File
@@ -196,26 +196,13 @@ class QueryObject: # pylint: disable=too-many-instance-attributes
# 1. 'metric_name' - name of predefined metric
# 2. { label: 'label_name' } - legacy format for a predefined metric
# 3. { expressionType: 'SIMPLE' | 'SQL', ... } - adhoc metric
# Keys that only ever appear on an ad-hoc metric definition. A dict
# carrying one of these but missing `expressionType` is a malformed
# ad-hoc metric, not a legacy predefined-metric reference, and must
# not be silently collapsed to its label, which would later be
# misread as a request for a saved metric of that name.
adhoc_metric_keys = {"sqlExpression", "aggregate", "column"}
def is_str_or_adhoc(metric: Metric) -> bool:
return isinstance(metric, str) or is_adhoc_metric(metric)
def normalize_metric(metric: Metric) -> Metric:
if isinstance(metric, str) or is_adhoc_metric(metric):
return metric
if adhoc_metric_keys & metric.keys():
raise QueryObjectValidationError(
_(
"Invalid ad-hoc metric %(label)s: `expressionType` is missing",
label=metric.get("label"),
)
)
return metric["label"] # type: ignore
self.metrics = metrics and [normalize_metric(x) for x in metrics]
self.metrics = metrics and [
x if is_str_or_adhoc(x) else x["label"] # type: ignore
for x in metrics
]
def _set_post_processing(
self, post_processing: list[dict[str, Any] | None] | None
+15 -7
View File
@@ -708,7 +708,7 @@ DEFAULT_FEATURE_FLAGS: dict[str, bool] = {
# the move-back lever; removed (along with its two gate points —
# BaseDAO.delete routing and the do_orm_execute visibility listener) once
# post-flip confidence is established.
# @lifecycle: testing
# @lifecycle: development
"SOFT_DELETE": True,
# Enable semantic layers and show semantic views alongside datasets
# @lifecycle: development
@@ -742,9 +742,9 @@ 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 empty or stale history, so the two
# ship with matching defaults and should be changed together.
# @lifecycle: testing
# 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": True,
# =================================================================
# IN TESTING
@@ -1694,9 +1694,17 @@ 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. A falsy value disables
# version writes while keeping existing history available read-only through the
# ``/versions/`` endpoints; Restore is unavailable while capture is disabled.
# 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", "true")
)
-65
View File
@@ -99,7 +99,6 @@ from superset.models.helpers import (
AuditMixinNullable,
CertificationMixin,
ExploreMixin,
get_effective_hours_offset,
ImportExportMixin,
QueryResult,
SoftDeleteMixin,
@@ -1248,8 +1247,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
time_grain: str | None,
label: str | None = None,
template_processor: BaseTemplateProcessor | None = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> TimestampExpression | Label:
"""
Return a SQLAlchemy Core element representation of self to be used in a query.
@@ -1257,8 +1254,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
:param time_grain: Optional time grain, e.g. P1Y
:param label: alias/label that column is expected to have
:param template_processor: template processor
:param apply_dataset_offset: shift the selected axis before truncation
:param sql_shifted_temporal_labels: labels shifted before truncation
:return: A TimeExpression object wrapped in a Label if supported by db
"""
label = label or utils.DTTM_ALIAS
@@ -1296,27 +1291,6 @@ class TableColumn(AuditMixinNullable, ImportExportMixin, CertificationMixin, Mod
col = literal_column(expression, type_=type_)
else:
col = column(self.column_name, type_=type_)
if (
apply_dataset_offset
and time_grain
and self.table
and self.db_engine_spec.supports_temporal_column_shift
and (offset_hours := self.table.offset or 0)
and not self.table.get_dataset_timezone()
):
effective_offset_hours = get_effective_hours_offset(
self.db_engine_spec,
self.type,
offset_hours,
db_extra=self.db_extra,
)
if effective_offset_hours:
col = self.db_engine_spec.get_temporal_column_shift_expr(
col,
effective_offset_hours,
)
if sql_shifted_temporal_labels is not None:
sql_shifted_temporal_labels.add(label)
time_expr = self.db_engine_spec.get_timestamp_expr(col, pdf, time_grain)
return self.database.make_sqla_column_compatible(time_expr, label)
@@ -2001,26 +1975,11 @@ class SqlaTable(
)
) from ex
def _shift_temporal_column_if_needed(
self,
sqla_column: ColumnClause,
effective_offset_hours: int,
) -> ColumnClause:
"""Apply a nonzero effective dataset offset to a temporal expression."""
if not effective_offset_hours:
return sqla_column
return self.db_engine_spec.get_temporal_column_shift_expr(
sqla_column,
effective_offset_hours,
)
def adhoc_column_to_sqla( # pylint: disable=too-many-locals
self,
col: AdhocColumn,
force_type_check: bool = False,
template_processor: BaseTemplateProcessor | None = None,
apply_dataset_offset: bool = False,
sql_shifted_temporal_labels: set[str] | None = None,
) -> tuple[ColumnElement, utils.GenericDataType | None]:
"""
Turn an adhoc column into a sqlalchemy column.
@@ -2030,8 +1989,6 @@ class SqlaTable(
This is needed to validate if a filter with an adhoc column
is applicable.
:param template_processor: template_processor instance
:param apply_dataset_offset: shift the selected axis before truncation
:param sql_shifted_temporal_labels: labels shifted before truncation
:returns: A tuple of (SQLAlchemy column, generic column type). The
generic type is populated when the column type is resolved
(either because the adhoc column matches a physical column, or
@@ -2048,7 +2005,6 @@ class SqlaTable(
pdf = None
is_column_reference = col.get("isColumnReference", False)
generic_type: utils.GenericDataType | None = None
native_type: str | None = None
metadata_lookup_key = self._render_adhoc_expression_for_metadata_lookup(
sql_expression, template_processor
@@ -2063,7 +2019,6 @@ class SqlaTable(
is_dttm = col_in_metadata.is_temporal
pdf = col_in_metadata.python_date_format
generic_type = col_in_metadata.type_generic
native_type = col_in_metadata.type
else:
# Column doesn't exist in metadata or is not a reference - treat as ad-hoc
# expression Note: If isColumnReference=true but column not found, we still
@@ -2126,28 +2081,8 @@ class SqlaTable(
# stay unquoted for numeric adhoc expressions like
# CAST(... AS BIGINT)).
generic_type = col_desc[0].get("type_generic")
probed_type = col_desc[0].get("type")
native_type = str(probed_type) if probed_type is not None else None
if is_dttm and has_timegrain:
if (
apply_dataset_offset
and self.db_engine_spec.supports_temporal_column_shift
and (offset_hours := self.offset or 0)
and not self.get_dataset_timezone()
):
effective_offset_hours = get_effective_hours_offset(
self.db_engine_spec,
native_type,
offset_hours,
db_extra=self.db_extra,
)
sqla_column = self._shift_temporal_column_if_needed(
sqla_column,
effective_offset_hours,
)
if sql_shifted_temporal_labels is not None:
sql_shifted_temporal_labels.add(label)
sqla_column = self.db_engine_spec.get_timestamp_expr(
col=sqla_column,
pdf=pdf,
+5 -5
View File
@@ -21,7 +21,11 @@ from flask import g
from sqlalchemy.exc import NoResultFound
from superset.commands.tag.exceptions import TagNotFoundError
from superset.commands.tag.utils import to_object_model, to_object_type
from superset.commands.tag.utils import (
current_user_can_modify_object,
to_object_model,
to_object_type,
)
from superset.daos.base import BaseDAO
from superset.daos.chart import ChartDAO
from superset.daos.dashboard import DashboardDAO
@@ -345,10 +349,6 @@ class TagDAO(BaseDAO[Tag]):
Returns:
None.
"""
# Imported lazily: superset.commands.utils itself imports TagDAO from
# this module, so a top-level import here would be circular.
from superset.commands.utils import current_user_can_modify_object
tagged_objects = []
if not tag:
raise TagNotFoundError()
-4
View File
@@ -1316,10 +1316,6 @@ class DatabaseRestApi(BaseSupersetModelRestApi):
try:
TestConnectionDatabaseCommand(item).run()
return self.response(200, message="OK")
except OAuth2RedirectError:
# OAuth2 connections pass, so they can be saved. A user later
# can then store an OAuth2 token.
return self.response(200, message="OK")
except (
SSHTunnelingNotEnabledError,
SSHTunnelDatabasePortError,
+8 -22
View File
@@ -539,7 +539,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
# the ``array_*`` capability methods below must be implemented. Defaults to
# False so engines that have not opted in keep treating arrays as strings.
supports_multivalue_columns = False
supports_temporal_column_shift: bool = False
allows_joins = True
allows_subqueries = True
allows_alias_in_select = True
@@ -1243,19 +1242,6 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return TimestampExpression(time_expr, col, type_=col.type)
@classmethod
def get_temporal_column_shift_expr(
cls,
col: ColumnClause,
offset_hours: int,
) -> TimestampExpression:
"""Shift a temporal SQL expression by a bounded number of hours."""
return TimestampExpression(
f"{{col}} + INTERVAL '{offset_hours}' HOUR",
col,
type_=col.type,
)
@classmethod
def _apply_year_to_dttm(cls, time_expr: str) -> str:
"""
@@ -1392,12 +1378,12 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
return cursor.fetchmany(limit)
data = cursor.fetchall()
description = cursor.description or []
# Create a mapping between column index and a mutator function to normalize
# values with. The first two items in the description row are the column
# name and type.
# Create a mapping between column name and a mutator function to normalize
# values with. The first two items in the description row are
# the column name and type.
column_mutators = {
index: func
for index, row in enumerate(description)
row[0]: func
for row in description
if (
func := cls.column_type_mutators.get(
type(cls.get_sqla_column_type(cls.get_datatype(row[1])))
@@ -1405,11 +1391,11 @@ class BaseEngineSpec: # pylint: disable=too-many-public-methods
)
}
if column_mutators:
if not isinstance(data, list):
data = list(data)
indexes = {row[0]: idx for idx, row in enumerate(description)}
for row_idx, row in enumerate(data):
new_row = list(row)
for col_idx, func in column_mutators.items():
for col, func in column_mutators.items():
col_idx = indexes[col]
new_row[col_idx] = func(row[col_idx])
data[row_idx] = tuple(new_row)
+1 -23
View File
@@ -19,7 +19,6 @@ from __future__ import annotations
import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, TYPE_CHECKING, TypedDict
@@ -33,7 +32,7 @@ from marshmallow.exceptions import ValidationError
from requests import Session
from shillelagh.adapters.api.gsheets.lib import SCOPES
from shillelagh.exceptions import UnauthenticatedError
from sqlalchemy import text, types
from sqlalchemy import text
from sqlalchemy.engine import create_engine
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
@@ -156,27 +155,6 @@ class GSheetsEngineSpec(ShillelaghEngineSpec):
oauth2_token_request_uri = "https://oauth2.googleapis.com/token" # noqa: S105
oauth2_exception = (UnauthenticatedError, OAuth2TokenRefreshError)
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
) -> str | None:
"""
Convert a datetime to a SQL literal understood by shillelagh's GSheets
adapter.
``SqliteEngineSpec.convert_dttm`` (inherited via ``ShillelaghEngineSpec``)
has no case for ``types.Date`` and returns ``None``, which makes Superset
fall back to a literal that still carries a time-of-day component. The
GSheets adapter's virtual table layer parses that literal with
``datetime.date.fromisoformat``, which rejects the trailing time and
silently drops the filter value, producing an invalid query against the
Google Sheets API. A bare ``YYYY-MM-DD`` literal is required instead.
"""
sqla_type = cls.get_sqla_column_type(target_type)
if isinstance(sqla_type, types.Date):
return f"'{dttm.date().isoformat()}'"
return super().convert_dttm(target_type, dttm, db_extra=db_extra)
@classmethod
def get_oauth2_authorization_uri(
cls,
+14 -56
View File
@@ -267,43 +267,6 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
types.VARCHAR(),
GenericDataType.STRING,
),
# wire-protocol FIELD_TYPE names emitted by `get_datatype`, seen on
# SQL Lab and virtual dataset columns instead of DDL type names
(
re.compile(r"^newdecimal", re.IGNORECASE),
DECIMAL(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^tiny$", re.IGNORECASE),
TINYINT(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^short$", re.IGNORECASE),
types.SmallInteger(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^(blob|text)$", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r"^year$", re.IGNORECASE),
types.Integer(),
GenericDataType.NUMERIC,
),
(
re.compile(r"^enum\b", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
(
re.compile(r"^set\b", re.IGNORECASE),
types.String(),
GenericDataType.STRING,
),
)
column_type_mutators: dict[types.TypeEngine, Callable[[Any], Any]] = {
DECIMAL: lambda val: Decimal(val) if isinstance(val, str) else val
@@ -462,27 +425,22 @@ class MySQLEngineSpec(BasicParametersMixin, BaseEngineSpec):
@classmethod
def get_datatype(cls, type_code: Any) -> Optional[str]:
if not cls.type_code_map:
# only import and store if needed at least once
# pylint: disable=import-outside-toplevel
try:
import MySQLdb
mysql_module = MySQLdb
except ImportError:
mysql_module = __import__("pymysql")
ft = mysql_module.constants.FIELD_TYPE
cls.type_code_map = {
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
}
datatype = type_code
if isinstance(type_code, int):
if not cls.type_code_map:
# only import and store if needed at least once
# pylint: disable=import-outside-toplevel
try:
import MySQLdb
ft = MySQLdb.constants.FIELD_TYPE
except ImportError:
try:
import pymysql # type: ignore[import-untyped]
ft = pymysql.constants.FIELD_TYPE
except ImportError:
from mysql.connector.constants import FieldType
ft = FieldType
cls.type_code_map = {
getattr(ft, k): k for k in dir(ft) if not k.startswith("_")
}
datatype = cls.type_code_map.get(type_code)
if datatype and isinstance(datatype, str) and datatype:
return datatype
-1
View File
@@ -360,7 +360,6 @@ class PostgresEngineSpec(BasicParametersMixin, PostgresBaseEngineSpec):
supports_catalog = True
supports_dynamic_catalog = True
supports_grouping_sets = True
supports_temporal_column_shift = True
default_driver = "psycopg2"
parameters_schema = PostgresParametersSchema()
+2 -187
View File
@@ -20,23 +20,21 @@ import logging
import re
from datetime import datetime
from re import Pattern
from typing import Any, Callable, cast, Optional, TYPE_CHECKING, TypedDict
from typing import Any, Callable, Optional, TYPE_CHECKING, TypedDict
from urllib import parse
from apispec import APISpec
from apispec.ext.marshmallow import MarshmallowPlugin
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
from flask import current_app as app, has_request_context
from flask import current_app as app
from flask_babel import gettext as __
from marshmallow import fields, Schema
from sqlalchemy import text, types
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.engine.url import URL
from sqlalchemy.exc import DatabaseError as SqlalchemyDatabaseError
from sqlalchemy.sql.elements import ColumnElement
from superset import is_feature_enabled, security_manager
from superset.constants import TimeGrain
from superset.databases.utils import make_url_safe
from superset.db_engine_specs.base import (
@@ -46,63 +44,13 @@ from superset.db_engine_specs.base import (
)
from superset.db_engine_specs.postgres import PostgresBaseEngineSpec
from superset.errors import ErrorLevel, SupersetError, SupersetErrorType
from superset.exceptions import OAuth2TokenRefreshError
from superset.models.sql_lab import Query
from superset.superset_typing import (
OAuth2ClientConfig,
OAuth2State,
)
from superset.utils import json
from superset.utils.core import get_user_agent, QuerySource
from superset.utils.oauth2 import encode_oauth2_state, generate_code_challenge
if TYPE_CHECKING:
from superset.models.core import Database
try:
from snowflake.connector.errors import DatabaseError
except ImportError:
# Use a distinct sentinel type when snowflake is not installed to avoid
# matching unrelated exception types (using `Exception` would be too broad).
class _SnowflakeDatabaseError(Exception):
"""Sentinel type to stand in for snowflake.connector.errors.DatabaseError."""
pass
DatabaseError = _SnowflakeDatabaseError
class CustomSnowflakeAuthErrorMeta(type):
"""
Metaclass whose ``__instancecheck__`` matches Snowflake's invalid/expired
OAuth access-token error, so ``CustomSnowflakeAuthError`` can be used as the
``oauth2_exception`` that triggers the OAuth2 re-auth dance.
This is only honored via ``isinstance()`` (the path used by
``BaseEngineSpec.needs_oauth2()``); ``except`` clauses do not call
``__instancecheck__``, so it must not be relied on for exception catching.
"""
def __instancecheck__(cls, instance: object) -> bool:
"""
Match Snowflake's invalid/expired OAuth token error, whether it arrives
wrapped by SQLAlchemy (e.g. ``Engine``-based execution) or as the raw
DBAPI exception ``BaseEngineSpec.execute()`` runs against a bare
cursor and never wraps it, so both shapes must be handled here.
"""
orig: object = instance
if isinstance(instance, SqlalchemyDatabaseError):
orig = cast(SqlalchemyDatabaseError, instance).orig
return isinstance(orig, DatabaseError) and "Invalid OAuth access token" in str(
orig
)
class CustomSnowflakeAuthError(DatabaseError, metaclass=CustomSnowflakeAuthErrorMeta):
"""Snowflake OAuth error type matched via the metaclass above (see note there)."""
# Regular expressions to catch custom errors
OBJECT_DOES_NOT_EXIST_REGEX = re.compile(
r"Object (?P<object>.*?) does not exist or not authorized."
@@ -212,7 +160,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
encrypted_extra_sensitive_fields = {
"$.auth_params.privatekey_body": "Private Key Body",
"$.auth_params.privatekey_pass": "Private Key Password",
"$.oauth2_client_info.secret": "OAuth2 Client Secret",
}
_time_grain_expressions = {
@@ -251,126 +198,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
),
}
# OAuth 2.0 support
supports_oauth2: bool = True
# `CustomSnowflakeAuthError` is only matched via `isinstance()` (see the
# metaclass docstring above), so it's paired with `OAuth2TokenRefreshError`
# (a real subclass) to keep `refresh_oauth2_token`'s `except` clause working.
oauth2_exception: type[Exception] | tuple[type[Exception], ...] = (
CustomSnowflakeAuthError,
OAuth2TokenRefreshError,
)
@classmethod
def is_oauth2_enabled(cls) -> bool:
"""
Return whether OAuth2 authentication is enabled.
"""
# When alerts or reports connect to the database in the background,
# OAuth2 authentication fails; therefore, OAuth2 authentication is disabled
# for background execution.
if not has_request_context():
return False
return (
cls.supports_oauth2
and cls.engine_name in app.config["DATABASE_OAUTH2_CLIENTS"]
)
@classmethod
def get_oauth2_config(cls) -> OAuth2ClientConfig | None:
"""
Build the DB engine spec level OAuth2 client config.
"""
if not cls.is_oauth2_enabled():
return None
return super().get_oauth2_config()
@classmethod
def impersonate_user(
cls,
database: Database,
username: str | None,
user_token: str | None,
url: URL,
engine_kwargs: dict[str, Any],
) -> tuple[URL, dict[str, Any]]:
"""
Modify URL and/or engine kwargs to impersonate a different user.
"""
connect_args: dict[str, Any] = engine_kwargs.setdefault("connect_args", {})
# When test_connection is executed (i.e., when validate_default_parameters is
# set to True in connect_args), authentication via OAuth is not performed.
#
# ``database.is_oauth2_enabled()`` returns True for a database-level OAuth2
# client (``encrypted_extra.oauth2_client_info``) regardless of request
# context, unlike the app-config-based check in ``is_oauth2_enabled()``
# above. Background executions (alerts/reports) have no per-user token, so
# ``has_request_context()`` must be checked explicitly here too, or OAuth
# gets switched on with no token to send.
if (
not connect_args.get("validate_default_parameters", False)
and has_request_context()
and database.is_oauth2_enabled()
):
url = url.update_query_dict({"authenticator": "oauth"})
connect_args["authenticator"] = "oauth"
if user_token:
if username is not None:
if is_feature_enabled("IMPERSONATE_WITH_EMAIL_PREFIX"):
# ``Database._get_sqla_engine()`` has already looked
# up the login and substituted the email prefix into
# ``username`` before calling this method when this
# flag is on. Looking it up again here as if it were
# still the login would fail whenever the two differ,
# leaving the default/service-account username paired
# with this user's OAuth token. Use it as given.
url = url.set(username=username)
else:
user = security_manager.find_user(username=username)
if user and user.email:
url = url.set(username=user.email)
url = url.update_query_dict({"token": user_token})
return url, engine_kwargs
@classmethod
def get_oauth2_authorization_uri(
cls,
config: OAuth2ClientConfig,
state: OAuth2State,
code_verifier: str | None = None, # pylint: disable=unused-argument
) -> str:
"""
Return URI for initial OAuth2 request.
"""
uri = config["authorization_request_uri"]
# When calling the Snowflake OAuth authorization endpoint for a custom client,
# specify only the query parameters documented in the URL below.
# Adding unsupported parameters
# (e.g., `prompt` as used in BaseEngineSpec.get_oauth2_authorization_uri)
# will cause an error.
# https://docs.snowflake.com/user-guide/oauth-custom#query-parameters
params: dict[str, str] = {
"scope": config["scope"],
"response_type": "code",
"state": encode_oauth2_state(state),
"redirect_uri": config["redirect_uri"],
"client_id": config["id"],
}
# Add PKCE parameters (RFC 7636) if code_verifier is provided
if code_verifier:
params["code_challenge"] = generate_code_challenge(code_verifier)
params["code_challenge_method"] = "S256"
return parse.urljoin(uri, "?" + parse.urlencode(params))
@staticmethod
def get_extra_params(
database: Database, source: QuerySource | None = None
@@ -621,18 +448,6 @@ class SnowflakeEngineSpec(PostgresBaseEngineSpec):
database: "Database",
params: dict[str, Any],
) -> None:
# To use OAuth authentication, a database connection must first be created using
# another authenticator (typically key-pair authentication)
# with “Impersonate logged in user” enabled.
# Key-pair authentication is used for connection tests,
# while OAuth authentication is used when executing actual queries,
# such as in SQL Lab or dashboards.
# Therefore, when using OAuth authentication, the key-pair authentication
# settings are not loaded, and the connection is established using OAuth only.
connect_args: dict[str, Any] = params.get("connect_args") or {}
if connect_args.get("authenticator") == "oauth":
return
if not database.encrypted_extra:
return
try:
+1 -21
View File
@@ -25,14 +25,9 @@ from typing import Any, TYPE_CHECKING
from flask_babel import gettext as __
from sqlalchemy import types
from sqlalchemy.engine.reflection import Inspector
from sqlalchemy.sql.elements import ColumnClause
from superset.constants import TimeGrain
from superset.db_engine_specs.base import (
BaseEngineSpec,
DatabaseCategory,
TimestampExpression,
)
from superset.db_engine_specs.base import BaseEngineSpec, DatabaseCategory
from superset.errors import SupersetErrorType
if TYPE_CHECKING:
@@ -48,7 +43,6 @@ class SqliteEngineSpec(BaseEngineSpec):
disable_ssh_tunneling = True
supports_multivalues_insert = True
supports_temporal_column_shift = True
metadata = {
"description": "SQLite is a self-contained, serverless SQL database engine.",
@@ -146,20 +140,6 @@ class SqliteEngineSpec(BaseEngineSpec):
"ELSE printf('%04d-01-01', CAST({col} AS INTEGER)) END)"
)
@classmethod
def get_temporal_column_shift_expr(
cls,
col: ColumnClause,
offset_hours: int,
) -> TimestampExpression:
"""Shift a temporal expression with SQLite's datetime modifier syntax."""
modifier = f"{offset_hours:+d} hours"
return TimestampExpression(
f"DATETIME({{col}}, '{modifier}')",
col,
type_=col.type,
)
@classmethod
def convert_dttm(
cls, target_type: str, dttm: datetime, db_extra: dict[str, Any] | None = None
+9
View File
@@ -30,6 +30,7 @@ from superset.commands.temporary_cache.exceptions import (
TemporaryCacheResourceNotFoundError,
)
from superset.constants import MODEL_API_RW_METHOD_PERMISSION_MAP
from superset.exceptions import SupersetTemplateException
from superset.explore.form_data.schemas import FormDataPostSchema, FormDataPutSchema
from superset.extensions import event_logger
from superset.views.base_api import BaseSupersetApi, requires_json, statsd_metrics
@@ -110,6 +111,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("PUT",))
@protect()
@@ -183,6 +186,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("GET",))
@protect()
@@ -234,6 +239,8 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))
@expose("/form_data/<string:key>", methods=("DELETE",))
@protect()
@@ -286,3 +293,5 @@ class ExploreFormDataRestApi(BaseSupersetApi):
return self.response(403, message=str(ex))
except TemporaryCacheResourceNotFoundError as ex:
return self.response(404, message=str(ex))
except SupersetTemplateException as ex:
return self.response(ex.status, message=str(ex))

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