Compare commits

..

61 Commits

Author SHA1 Message Date
goingforstudying-ctrl
8f4c411765 fix(mcp): coerce Role ORM objects to role names in UserInfo schema (#40746)
Signed-off-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
Co-authored-by: goingforstudying-ctrl <goingforstudying@users.noreply.github.com>
Co-authored-by: goingforstudying-ctrl <goingforstudying-ctrl@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 15:35:27 -07:00
dependabot[bot]
0936160633 chore(deps-dev): bump eslint-plugin-cypress from 3.6.0 to 6.4.2 in /superset-frontend (#41849)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 15:14:33 -07:00
Elizabeth Thompson
d9e1f34527 fix(a11y): add aria-label to notification method remove button (#41850) 2026-07-07 15:01:30 -07:00
dependabot[bot]
4038c9d1e6 chore(deps-dev): bump google-cloud-bigquery from 3.27.0 to 3.42.1 (#41807)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 14:13:28 -07:00
dependabot[bot]
8108496e2a chore(deps-dev): bump @types/d3-scale from 2.2.10 to 4.0.9 in /superset-frontend/packages/superset-ui-core (#41318)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
2026-07-07 11:36:45 -07:00
yousoph
098b95560d fix(oauth2): retrigger queries for unsaved charts and schema loading after OAuth2 redirect (#41101) 2026-07-07 11:33:11 -07:00
dependabot[bot]
6edbf425f7 chore(deps-dev): update sqlalchemy-ocient requirement from >=1.0.0 to >=3.0.0 (#41806)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:28:13 -07:00
dependabot[bot]
8e98ae701b chore(deps): bump cryptography from 48.0.1 to 49.0.0 (#41811)
Signed-off-by: dependabot[bot] <support@github.com>
Signed-off-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-07-07 11:07:52 -07:00
dependabot[bot]
054be4d45e chore(deps-dev): bump eslint-plugin-storybook from 10.4.5 to 10.4.6 in /superset-frontend (#41360)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 10:24:46 -07:00
Amin Ghadersohi
fbc24959ab feat(mcp): add semantic layer MCP tools (list_metrics, get_table, get_compatible_dimensions, get_compatible_metrics) (#41611) 2026-07-07 12:51:35 -04:00
Jean Massucatto
fe7d9b4724 fix(sqllab): reflect query history deletion without page refresh (#41019)
Co-authored-by: Evan Rusackas <evan@rusackas.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 09:40:33 -07:00
Evan Rusackas
5f27f88e73 test(preset-chart-deckgl): prove legend clicks no longer scroll to top (#41831)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 09:34:14 -07:00
Mike Bridge
04f8b700d7 feat(datasets): soft-delete and restore (#40130)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-07 08:57:08 -07:00
Evan Rusackas
a01a4ae511 test(superset-ui-core): prove angle-bracketed non-HTML values render as text (#34082) (#41829)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:55:39 -07:00
Evan Rusackas
0d9b3f3b05 test(dashboard): prove filters with defaults apply without touching every filter (#41841)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:55:20 -07:00
Evan Rusackas
51a171f821 test(dashboard): prove BigInt column values no longer break native filter config (#41840)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:55:00 -07:00
Evan Rusackas
19c982d8d3 test(async-queries): prove OAuth2RedirectError survives async chart queries (#41839)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:54:40 -07:00
Evan Rusackas
973366d30b test(models): prove owner association tables reject duplicate rows (#41837)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:54:08 -07:00
Evan Rusackas
7b0969131f test(sql): prove ClickHouse parametric aggregates parse cleanly (#37285) (#41836)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:53:49 -07:00
Evan Rusackas
00a9546de9 test(sql): prove multi-space column names no longer trip subquery validation (#41835)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:53:20 -07:00
Evan Rusackas
de5a31a2cf test(sql): prove Oracle GROUP BY is no longer rewritten to ordinals (#35414) (#41834)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:53:05 -07:00
Evan Rusackas
af058ccecb test(plugin-chart-echarts): prove user-selected plain legend type is honored (#39540) (#41833)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:52:49 -07:00
Evan Rusackas
d0b42d8c19 test(dashboard): prove activeTabs from permalinks selects tab content (#41832)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:52:33 -07:00
Evan Rusackas
f0e25fb978 test(preset-chart-deckgl): prove ctrl+click on deck.gl legend toggles the layer without opening a new tab (#41830)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:51:53 -07:00
dependabot[bot]
77b98a5d67 chore(deps-dev): update databend-sqlalchemy requirement from <1.0,>=0.3.2 to >=0.5.5,<1.0 (#41479)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 08:42:20 -07:00
Rafael Benitez
dd0277e1e3 feat(mcp): support embedded guest-token authentication (#41003)
Co-authored-by: Gabriel Torres Ruiz <gabo2595@gmail.com>
2026-07-07 12:24:02 -03:00
amaannawab923
9b508ebe0d fix(ag-grid): honor dataset hour offset in time range filters (#41391)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:12:01 +02:00
dependabot[bot]
53c3b1ceb1 chore(deps): bump fs-extra from 11.3.5 to 11.3.6 in /superset-frontend (#41847)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:38:21 -07:00
dependabot[bot]
0dcb09b163 chore(deps-dev): bump @typescript-eslint/eslint-plugin from 8.62.0 to 8.62.1 in /superset-frontend (#41822)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:36:55 -07:00
dependabot[bot]
3a514dc8eb chore(deps-dev): update sqlalchemy-singlestoredb requirement from <2,>=1.1.1 to >=1.2.1,<2 (#41809)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:36:51 -07:00
dependabot[bot]
aa330422f1 chore(deps-dev): bump prettier from 3.9.3 to 3.9.4 in /superset-websocket (#41844)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:36:47 -07:00
dependabot[bot]
7bdde4a36a chore(deps-dev): bump prettier from 3.9.3 to 3.9.4 in /superset-frontend (#41846)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:36:43 -07:00
dependabot[bot]
5e16c4eaaf chore(deps-dev): bump oxlint from 1.71.0 to 1.72.0 in /superset-frontend (#41848)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 06:36:37 -07:00
amaannawab923
ffd0982942 fix(ag-grid): keep basic conditional formatting aligned after sort (#41390)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 15:12:44 +02:00
Beto Dealmeida
4dc45aa958 fix(semantic layers): apply post-processing (#41601) 2026-07-07 08:52:44 -04:00
Beto Dealmeida
4167a21f7d fix(semantic layers): guards for zero rows (#41600) 2026-07-07 08:51:45 -04:00
amaannawab923
5e384f54d3 fix(ag-grid): select cell on click instead of its text (#41392)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
2026-07-07 10:35:13 +02:00
anamitraadhikari
ec4e5deb01 feat(theming): add opt-in SQL Lab grid tokens and fix modal border-radius clipping (#41031)
Co-authored-by: aadhikari <aadhikari@apple.com>
2026-07-07 09:54:58 +02:00
Mehmet Salih Yavuz
de5a233ccf fix(sqllab): preserve saved query description when editing (#41685) 2026-07-07 09:19:25 +02:00
MouhibKhammassi
c846da8dfb fix(frontend): resolve image download formatting and cleanup issues (#38927)
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:54:26 -07:00
Evan Rusackas
1c3b070d34 feat(i18n): do-not-translate registry + '#. do-not-translate' marker standard (#41651)
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:31:41 -07:00
dependabot[bot]
a8a1b220fd chore(deps): update @ant-design/icons requirement from ^6.2.5 to ^6.3.2 in /superset-frontend/packages/superset-ui-core (#41790)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Evan <evan@preset.io>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:31:19 -07:00
Evan Rusackas
e52240b9a6 test(datasets): prove column metadata persists for zero-row queries (#41838)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-07 08:49:18 +07:00
dependabot[bot]
87fa7e5e64 chore(deps-dev): bump prettier from 3.9.1 to 3.9.3 in /superset-websocket (#41810)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 08:45:20 +07:00
dependabot[bot]
2b74e140f3 chore(deps-dev): update sqlalchemy-parseable requirement from <0.2.0,>=0.1.3 to >=0.1.6,<0.2.0 (#41814)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 08:44:49 +07:00
dependabot[bot]
847bdad960 chore(deps-dev): bump @typescript-eslint/parser from 8.62.0 to 8.62.1 in /superset-frontend (#41820)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 08:42:44 +07:00
dependabot[bot]
4b78758fd2 chore(deps-dev): bump typescript-eslint from 8.62.0 to 8.62.1 in /superset-websocket (#41815)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 08:42:15 +07:00
Evan Rusackas
101c1e8827 fix(ace-editor): carry token classes onto emoji boxes (#41821)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 18:06:01 -07:00
Evan Rusackas
85eaf15c5e test(databases): stabilize flaky SQLAlchemy-form visibility assertion (#41823)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-06 18:05:38 -07:00
Evan Rusackas
2aa43f6f0f fix(sql): stop sanitize_clause from rewriting user SQL semantics (#36113) (#41125)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-06 18:03:26 -07:00
Elizabeth Thompson
ee524b3471 fix(dashboard): let CSV exports use query cache instead of always force-querying (#41469)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-06 16:50:06 -07:00
luca-vercelli
9e50d9eca6 fix: Italian translation (#36114)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 14:21:20 -07:00
yousoph
033b7bc385 fix(databases): update broken database documentation links to /user-docs namespace (#41557)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Đỗ Trọng Hải <41283691+hainenber@users.noreply.github.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-06 13:02:02 -07:00
yousoph
c0b0a2fdaf feat(tags): add favorites filter to Tags list view (#41461)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 12:55:01 -07:00
Ville Brofeldt
44c1ad5c71 fix: delint frontend files (#41802) 2026-07-06 21:54:33 +02:00
amaannawab923
fb496e158a fix(ag-grid): persist "None" value aggregation selection (#41386)
Co-authored-by: Enzo Martellucci <52219496+EnxDev@users.noreply.github.com>
Co-authored-by: Enzo Martellucci <enzomartellucci@gmail.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:51:26 +02:00
Đỗ Trọng Hải
69ce27f173 feat(sec): resolve all vuln alerts in superset-embedded-sdk, superset-frontend and docs (#41776)
Signed-off-by: hainenber <dotronghai96@gmail.com>
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-06 12:14:34 -07:00
Richard Fogaca Nienkotter
51e708da6b fix(mcp): accept Superset vocabulary in chart configs and clarify query_dataset metric errors (#40972) 2026-07-06 16:01:14 -03:00
Evan Rusackas
21b4185819 fix(ace-editor): caret drift from late font loads and emoji widths (#41697)
Co-authored-by: Claude Code <noreply@anthropic.com>
Co-authored-by: Amin Ghadersohi <amin.ghadersohi@gmail.com>
2026-07-06 11:54:11 -07:00
Đỗ Trọng Hải
689b4f623e docs(admin/config): resolve misconfigured aws-iam Markdown file (#41748)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-06 11:51:15 -07:00
Đỗ Trọng Hải
ad8e6d4140 build(deps): upgrade react-markdown to v9 and remark-gfm to v4 (#41778)
Signed-off-by: hainenber <dotronghai96@gmail.com>
2026-07-06 11:50:09 -07:00
308 changed files with 21518 additions and 14955 deletions

View File

@@ -10,11 +10,7 @@ updates:
- package-ecosystem: "npm"
ignore:
- dependency-name: "react-error-boundary"
- dependency-name: "@rjsf/*"
# remark-gfm v4+ requires react-markdown v9+, which needs React 18
- dependency-name: "remark-gfm"
- dependency-name: "react-markdown"
# TODO: remove below entries until React >= 19.0.0
- dependency-name: "react-icons"
# JSDOM v30 doesn't play well with Jest v30
@@ -78,8 +74,6 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
- dependency-name: "react-error-boundary"
schedule:
interval: "daily"
open-pull-requests-limit: 10
@@ -376,11 +370,6 @@ updates:
- package-ecosystem: "npm"
directory: "/superset-frontend/packages/superset-ui-core/"
ignore:
# not until React >= 18.0.0
- dependency-name: "react-markdown"
- dependency-name: "remark-gfm"
- dependency-name: "react-error-boundary"
schedule:
interval: "daily"
labels:

View File

@@ -42,6 +42,8 @@ assists people when migrating to a new version.
- **New config flag `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE` (default `False`).** Share/permalink URLs now substitute `window.location.origin` for the backend-supplied origin so a proxied or subdirectory-deployed Superset never hands the user an unreachable internal hostname. Operators whose reverse proxy correctly forwards `X-Forwarded-Host` *and* who want permalinks to carry the backend's literal origin can opt out by setting `EMBEDDED_DISABLE_PERMALINK_ORIGIN_REWRITE = True` in `superset_config.py`. Default `False` (rewrite is on); flipping the default would regress the dominant proxied/subdir deployment to an unreachable host.
- [41651](https://github.com/apache/superset/pull/41651): **New do-not-translate standard for translation catalogs.** Strings that must stay identical to the source — icon names (e.g. `bolt`), enum/option values (`step-after`), SQL keywords, API field names (`error_message`), code constants, and example placeholders — are now marked with a `#. do-not-translate` extracted comment. The list lives in the `superset/translations/do-not-translate.txt` registry; `scripts/translations/apply_do_not_translate.py` stamps the marker onto `messages.pot` during `babel_update.sh`, and `pybabel update` propagates it to every `.po`, so the status is consistent across all languages. The AI backfill (`backfill_po.py`) and translators leave these entries untranslated (source fallback). The legacy per-catalog convention (a `# Не переводить` translator comment in the `ru` catalog) is still honored for back-compat but is superseded by this standard; contributors adding new machine-read strings should add the msgid to the registry rather than annotating individual catalogs.
### SQL Lab denies large-object and information_schema access by default
`DISALLOWED_SQL_FUNCTIONS` and `DISALLOWED_SQL_TABLES` now ship with additional default entries, so SQL Lab and chart-data queries that reference them are rejected where they were previously allowed:
@@ -302,6 +304,36 @@ Schedule the cutover in a quiet window. Runtime reads use only the single config
The migration is transactional (all-or-nothing) and idempotent — it can be safely re-run or resumed. Note that AES-GCM, unlike AES-CBC, does not support querying directly over encrypted columns; audit any code that filters on an encrypted column before switching. See the SIP at `docs/sip/authenticated-encryption-at-rest.md` for details.
### 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 `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.
**Flag-independent parts of this work** (active even with `SOFT_DELETE` off): the restore endpoint and deleted-state filter (above); the database-deletion guard counting soft-deleted datasets; the `get_or_create_dataset` soft-deleted-twin pre-check; the combined datasource listing (`GET /api/v1/datasource/...`) always excluding soft-deleted datasets; and the two uniqueness-validation changes documented at the end of this section. Everything else — the soft DELETE itself and the visibility filtering — is flag-gated.
With the flag enabled: `DELETE /api/v1/dataset/<id>` no longer hard-deletes the dataset (the bulk-delete endpoint behaves the same way). The row is marked with a `deleted_at` timestamp and hidden from all list, detail, and lookup endpoints. Datasets in this state are excluded from default queries and from relationship loads (e.g. `database.tables`).
**No cascade in v1.** Soft-delete does not propagate to dependent charts or dashboards: they remain visible. Loading a chart whose dataset is soft-deleted surfaces a "datasource not found" error at chart-load time. Restore the dataset to recover.
**Database deletion is blocked by soft-deleted datasets.** Superset already refuses to delete a database that still has datasets (`DatabaseDeleteDatasetsExistFailedError`); that check now explicitly counts soft-deleted datasets too (it bypasses the visibility filter), since the soft-deleted `tables` rows still reference the database via `database_id` and must not be orphaned. Consequence: because dataset `DELETE` is soft and v1 ships no hard-delete/purge, **a database that has ever had datasets cannot be deleted through the API once those datasets are soft-deleted** — the rows remain and keep blocking the delete. Until a purge capability lands, operators who must remove such a database have to hard-delete the underlying `tables` rows out-of-band first. This is a deliberate trade-off (no orphaned rows / restorable datasets) and is expected to be resolved by the planned purge work.
**Side-effect change for operators.** Because the row is no longer physically deleted, FAB `ab_view_menu` / permission-view rows tied to the dataset are also preserved. Downstream automation that relied on `DELETE /api/v1/dataset/<id>` cleaning up those rows must now react to the new `POST /api/v1/dataset/<uuid>/restore` lifecycle, or call the eventual hard-delete endpoint.
**New endpoint** — `POST /api/v1/dataset/<uuid>/restore` clears `deleted_at` and returns the dataset to active state. Requires `can_write on Dataset` and ownership of the row (or admin). Soft-deleted datasets can also be surfaced in the list endpoint via the new `dataset_deleted_state` rison filter: `include` returns both live and soft-deleted rows, `only` returns just the soft-deleted ones. Any other value is ignored. For non-admin users, soft-deleted rows are limited to datasets they own — the same audience that can restore them.
**Permissions migration:** existing role grants of `can_write on Dataset` cover the new restore endpoint automatically; no role migration is required.
**Schema migration:** the migration adds a nullable `deleted_at` column and an index on it (`ix_tables_deleted_at`) to the `tables` table. The column add is instant; the index build runs inline (no `CONCURRENTLY`) and may briefly block writes on the `tables` table (INSERT/UPDATE/DELETE are queued while the index builds; reads are unaffected) on large Postgres deployments. MySQL InnoDB builds the index online (no blocking). Production deployments with many thousands of datasets should run this migration during a maintenance window.
**Rollback note:** if the application code is rolled back after datasets have been soft-deleted, the older code path's visibility filter no longer applies and previously hidden rows become visible to the older code. Pair the rollback with a data decision (restore the rows, hard-delete them, or also downgrade the migration) rather than assuming the old hard-delete semantics still hold. **Downgrading the migration destroys the deletion markers**: `downgrade()` drops the `deleted_at` column, so any not-yet-restored soft-deleted datasets silently become live, active datasets with no record they were ever deleted. Reconcile the trash (restore or hard-delete each row) *before* downgrading, and disable the `SOFT_DELETE` flag first so no new soft deletes land mid-rollback.
**SQL Lab / dataset-creation flows:** creating a dataset over a table whose dataset sits in the trash is refused. The SQL Lab "save as dataset" flow (`get_or_create_dataset`) and file uploads return a **422 naming the hidden twin and the restore endpoint**; the plain create, update, and duplicate paths currently fail with the generic "already exists" 422. In all cases the remediation is the same: restore the hidden dataset (or use a different table name). Perm-string maintenance also covers hidden rows: renaming a database rewrites `perm`/`schema_perm`/`catalog_perm` on soft-deleted datasets and their charts, so a later restore does not resurrect stale permission strings.
**Importer behavior:** importing a dataset YAML whose UUID matches an existing **soft-deleted** dataset is treated as an implicit restore-with-update — **and this happens even when `overwrite` is not set**. This is a deliberate asymmetry with active rows: an active dataset imported without `overwrite=true` is returned unchanged, but a soft-deleted UUID match is restored *and* has the upload's contents applied regardless of the `overwrite` argument, on the reasoning that re-importing a deleted dataset's exact UUID is an explicit request to bring it back. The restore preserves the original PK, the chart back-reference, `table_columns`, and `sql_metrics`. Non-owners get `ImportFailedError`. Callers without `can_write` get `ImportFailedError` instead of silently receiving the soft-deleted row.
**Uniqueness-validation changes that apply regardless of the feature flag:** two dataset uniqueness checks were tightened alongside this work and are active even with `SOFT_DELETE` off. (1) Create/update uniqueness treats a dataset whose `catalog` is `NULL` as belonging to the database's default catalog, so a legacy twin pair (`catalog=NULL` vs. `catalog=<default>`, same database/schema/name) that older versions allowed now fails validation with "already exists" when either row is edited — resolve by renaming or removing one of the twins. (2) Duplicating a dataset now checks name collisions scoped to the target (database, catalog, schema) instead of globally by name alone: duplicates into other databases that were previously blocked are now allowed.
### Soft delete and restore for charts
**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.

View File

@@ -1,25 +1,7 @@
{/*
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.
*/}
---
title: AWS IAM Authentication
sidebar_label: AWS IAM Authentication
version: 1
sidebar_position: 15
---

View File

@@ -355,6 +355,17 @@ translations](#applying-translations) above), so an AI-generated string is shown
in the UI as soon as it is built and deployed. Reviewers should verify each
entry and remove the `#, fuzzy` flag to promote it to a confirmed translation.
The script never touches entries that must stay literal — icon names, enum
values, SQL keywords, API field names, and example placeholders. These are
registered in `superset/translations/do-not-translate.txt`;
`scripts/translations/apply_do_not_translate.py` stamps them in `messages.pot`
with a `#. do-not-translate` extracted comment (run automatically
from `babel_update.sh`), which `pybabel update` then propagates to every
catalog. To mark a new string do-not-translate, add its msgid to the registry.
The backfill also honors that marker and any legacy do-not-translate translator
comment (e.g. the `ru` catalog's `# Не переводить`), leaving such entries
untranslated so they fall back to the source token.
#### Prerequisites
```bash

View File

@@ -67,7 +67,6 @@
"docusaurus-plugin-openapi-docs": "^5.1.0",
"docusaurus-theme-openapi-docs": "^5.1.0",
"js-yaml": "^5.2.0",
"js-yaml-loader": "^1.2.2",
"json-bigint": "^1.0.0",
"prism-react-renderer": "^2.4.1",
"react": "^18.3.1",
@@ -118,14 +117,16 @@
"react-redux": "^9.2.0",
"@reduxjs/toolkit": "^2.5.0",
"baseline-browser-mapping": "^2.9.19",
"swagger-client": "3.37.3",
"lodash": "4.18.1",
"lodash-es": "4.18.1",
"yaml": "1.10.3",
"uuid": "11.1.1",
"serialize-javascript": "7.0.5",
"d3-color": "3.1.0",
"ws": "^8.21.0"
"ws": "^8.21.0",
"@docusaurus/core/@docusaurus/utils/gray-matter/js-yaml": "^3.15.0",
"@docusaurus/core/**/js-yaml": "^4.3.0",
"docusaurus-plugin-openapi-docs/**/js-yaml": "^4.3.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}

View File

@@ -176,7 +176,7 @@ export interface DatabaseInfo {
expressions_in_orderby?: boolean;
// Platform features
limit_method?: string;
limit_method?: number;
limit_clause?: boolean;
max_column_name?: number;
supports_file_upload?: boolean;

View File

@@ -19,6 +19,7 @@
import Layout from '@theme/Layout';
import { Avatar, Card, Col, Collapse, Row, Typography } from 'antd';
import { load } from 'js-yaml';
import BlurredSection from '../components/BlurredSection';
import SectionHeader from '../components/SectionHeader';
import DataSet from '../../../RESOURCES/INTHEWILD.yaml';
@@ -36,7 +37,7 @@ interface DataSetType {
categories: Record<string, Organization[]>;
}
const typedDataSet = DataSet as DataSetType;
const typedDataSet = load(DataSet) as DataSetType;
const ContributorAvatars = ({ contributors }: { contributors?: string[] }) => {
if (!contributors?.length) return null;

View File

@@ -22,6 +22,7 @@ import Link from '@docusaurus/Link';
import { Card, Carousel, Flex } from 'antd';
import styled from '@emotion/styled';
import GitHubButton from 'react-github-btn';
import { load } from 'js-yaml';
import { mq } from '../utils';
import SectionHeader from '../components/SectionHeader';
import databaseData from '../data/databases.json';
@@ -60,7 +61,7 @@ interface DataSetType {
categories: Record<string, Organization[]>;
}
const typedDataSet = DataSet as DataSetType;
const typedDataSet = load(DataSet) as DataSetType;
// Extract all organizations with logos for the carousel
const companiesWithLogos = Object.values(typedDataSet.categories)

View File

@@ -18,11 +18,11 @@
*/
declare module '*.yaml' {
const content: unknown;
const content: string;
export default content;
}
declare module '*.yml' {
const content: unknown;
const content: string;
export default content;
}

View File

@@ -61,10 +61,10 @@ export default function webpackExtendPlugin(): Plugin<void> {
);
});
// Add YAML loader rule directly to existing rules
// Load YAML file as raw text string
config.module?.rules?.push({
test: /\.ya?ml$/,
use: 'js-yaml-loader',
type: 'asset/source',
});
// Add swc-loader rule for superset-frontend files

View File

@@ -30,7 +30,6 @@
"exclude": [
"node_modules",
"../superset-frontend/**/*",
"src/webpack.extend.ts",
"src/shims/**"
]
}

File diff suppressed because it is too large Load Diff

View File

@@ -51,7 +51,7 @@ dependencies = [
"flask-cors>=6.0.5, <7.0",
"croniter>=6.2.2",
"cron-descriptor",
"cryptography>=48.0.0, <49.0.0",
"cryptography>=49.0.0, <50.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <4.0.0",
"flask-appbuilder>=5.2.2, <6.0.0",
@@ -127,7 +127,7 @@ aurora-data-api = ["preset-sqlalchemy-aurora-data-api>=0.2.8,<0.3"]
bigquery = [
"pandas-gbq>=0.19.1",
"sqlalchemy-bigquery>=1.17.0",
"google-cloud-bigquery>=3.10.0",
"google-cloud-bigquery>=3.42.1",
]
clickhouse = ["clickhouse-connect>=1.1.1, <2.0"]
cockroachdb = ["cockroachdb>=0.3.5, <0.4"]
@@ -137,7 +137,7 @@ d1 = [
"sqlalchemy-d1>=0.1.0",
"dbapi-d1>=0.1.0",
]
databend = ["databend-sqlalchemy>=0.3.2, <1.0"]
databend = ["databend-sqlalchemy>=0.5.5, <1.0"]
databricks = [
"databricks-sql-connector==4.2.6",
"databricks-sqlalchemy==1.0.5",
@@ -180,13 +180,13 @@ mssql = ["pymssql>=2.3.13, <3"]
motherduck = ["apache-superset[duckdb]"]
mysql = ["mysqlclient>=2.2.8, <3"]
ocient = [
"sqlalchemy-ocient>=1.0.0",
"sqlalchemy-ocient>=1.0.0, <4",
"pyocient>=1.0.15, <4",
"shapely",
"geojson",
]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
@@ -196,7 +196,7 @@ prophet = ["prophet>=1.1.6, <2"]
redshift = ["sqlalchemy-redshift>=0.8.1, <0.9"]
risingwave = ["sqlalchemy-risingwave"]
shillelagh = ["shillelagh[all]>=1.4.4, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.1.1, <2"]
singlestore = ["sqlalchemy-singlestoredb>=1.2.1, <2"]
snowflake = ["snowflake-sqlalchemy>=1.10.2, <2"]
sqlite = ["syntaqlite>=0.1.0,<0.5.0"]
spark = [

View File

@@ -26,7 +26,7 @@ filelock>=3.20.3,<4.0.0
brotli>=1.2.0,<2.0.0
numexpr>=2.9.0
# Security: CVE-2026-34073 (MEDIUM) - Improper Certificate Validation
cryptography>=48.0.0,<49.0.0
cryptography>=49.0.0,<50.0.0
# Security: Snyk - XSS vulnerability in Mako templates
mako>=1.3.11,<2.0.0
# Security: CVE-2024-52338 (CRITICAL) - Deserialization of untrusted data in IPC/Parquet readers

View File

@@ -86,7 +86,7 @@ cron-descriptor==1.4.5
# via apache-superset (pyproject.toml)
croniter==6.2.2
# via apache-superset (pyproject.toml)
cryptography==48.0.1
cryptography==49.0.0
# via
# -r requirements/base.in
# apache-superset (pyproject.toml)
@@ -323,7 +323,7 @@ pyjwt==2.13.0
# redis
pynacl==1.6.2
# via paramiko
pyopenssl==26.2.0
pyopenssl==26.3.0
# via
# -r requirements/base.in
# shillelagh

View File

@@ -178,7 +178,7 @@ croniter==6.2.2
# via
# -c requirements/base-constraint.txt
# apache-superset
cryptography==48.0.1
cryptography==49.0.0
# via
# -c requirements/base-constraint.txt
# apache-superset
@@ -360,7 +360,7 @@ google-auth-oauthlib==1.2.1
# via
# pandas-gbq
# pydata-google-auth
google-cloud-bigquery==3.27.0
google-cloud-bigquery==3.42.1
# via
# apache-superset
# pandas-gbq
@@ -790,9 +790,10 @@ pynacl==1.6.2
# via
# -c requirements/base-constraint.txt
# paramiko
pyopenssl==26.2.0
pyopenssl==26.3.0
# via
# -c requirements/base-constraint.txt
# google-auth
# shillelagh
pyparsing==3.2.3
# via

View File

@@ -0,0 +1,128 @@
#!/usr/bin/env python3
# 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.
"""Stamp do-not-translate msgids in a .pot with an extracted-comment marker.
For every msgid listed in ``superset/translations/do-not-translate.txt`` that is
present in the target .pot, add a ``#. do-not-translate`` extracted
comment. gettext extracted comments (``#.``) propagate from the .pot into every
language .po on ``pybabel update``, so the do-not-translate status stays
consistent across all catalogs from a single registry.
Run from ``babel_update.sh`` after the .pot is extracted and normalized (and
before ``pybabel update``). Idempotent: re-running makes no further changes.
Usage:
python scripts/translations/apply_do_not_translate.py [POT_PATH]
# POT_PATH defaults to superset/translations/messages.pot
"""
from __future__ import annotations
import sys
from pathlib import Path
# The standardized extracted-comment marker. Kept in sync with backfill_po.py.
MARKER: str = "do-not-translate"
_MARKER_LINE: str = f"#. {MARKER}"
TRANSLATIONS_DIR: Path = (
Path(__file__).parent.parent.parent / "superset" / "translations"
)
DEFAULT_POT: Path = TRANSLATIONS_DIR / "messages.pot"
REGISTRY: Path = TRANSLATIONS_DIR / "do-not-translate.txt"
def load_registry(path: Path = REGISTRY) -> set[str]:
"""Return the set of do-not-translate msgids (skips comments/blank lines).
Each line is stripped before the blank/comment check, so trailing
whitespace or an indented comment never yields a msgid that fails to match
the .pot.
"""
if not path.exists():
return set()
entries: set[str] = set()
for raw_line in path.read_text(encoding="utf-8").splitlines():
line: str = raw_line.strip()
if line and not line.startswith("#"):
entries.add(line)
return entries
def _escape(msgid: str) -> str:
"""Escape a msgid the way gettext writes it on a `msgid "..."` line."""
return msgid.replace("\\", "\\\\").replace('"', '\\"')
def apply_markers(pot_path: Path, registry: set[str]) -> int:
"""Insert the marker comment above each registry msgid via text edit.
Text manipulation (rather than a polib round-trip) preserves the .pot's
exact wrapping/layout, so the only change is the added marker lines.
Idempotent. Returns the number of entries newly marked.
"""
lines: list[str] = pot_path.read_text(encoding="utf-8").split("\n")
targets: set[str] = {f'msgid "{_escape(m)}"' for m in registry}
out: list[str] = []
changed: int = 0
for line in lines:
if line in targets and (not out or out[-1] != _MARKER_LINE):
# `#.` extracted comments precede `msgid`; these registry entries are
# bare single-line msgids, so inserting directly above is correct.
out.append(_MARKER_LINE)
changed += 1
out.append(line)
if changed:
pot_path.write_text("\n".join(out), encoding="utf-8")
return changed
def main() -> None:
"""Stamp the marker onto the target .pot from the registry."""
pot_path: Path = Path(sys.argv[1]) if len(sys.argv) > 1 else DEFAULT_POT
if not pot_path.exists():
print(f"POT file not found: {pot_path}", file=sys.stderr)
sys.exit(1)
# Fail fast if the registry file is absent: babel_update.sh depends on this
# step to stamp the .pot, and continuing would silently publish catalogs
# without any do-not-translate markers. An existing-but-empty registry is a
# valid state (nothing to mark), so only a missing file is an error.
if not REGISTRY.exists():
print(
f"do-not-translate registry not found at {REGISTRY}; refusing to "
"produce unmarked translation artifacts.",
file=sys.stderr,
)
sys.exit(1)
registry: set[str] = load_registry()
if not registry:
print(
f"do-not-translate registry {REGISTRY} is empty; nothing to mark.",
file=sys.stderr,
)
return
changed: int = apply_markers(pot_path, registry)
print(
f"do-not-translate: marked {changed} new entr(y/ies) with {MARKER} "
f"in {pot_path.name} ({len(registry)} msgids in registry).",
file=sys.stderr,
)
if __name__ == "__main__":
main()

View File

@@ -55,6 +55,14 @@ msgcat --sort-by-msgid --no-wrap --no-location superset/translations/messages.po
cat $LICENSE_TMP superset/translations/messages.pot > messages.pot.tmp \
&& mv messages.pot.tmp superset/translations/messages.pot
# Stamp do-not-translate msgids (superset/translations/do-not-translate.txt) with
# a `#. do-not-translate` extracted comment. Extracted comments
# propagate from the .pot into every catalog on the `pybabel update` below, so
# the do-not-translate status stays consistent across all languages.
# Fail fast: without this guard the script would continue past a marker-stamping
# failure and `pybabel update` would publish catalogs missing the markers.
python scripts/translations/apply_do_not_translate.py superset/translations/messages.pot || exit 1
# --no-fuzzy-matching: when a *new* source string is added, Babel's fuzzy
# matcher otherwise guesses a "close" existing translation and marks it
# `#, fuzzy` in every language catalog. Those guesses are (a) usually wrong

View File

@@ -152,6 +152,60 @@ def _is_missing(entry: polib.POEntry) -> bool:
return not entry.msgstr
# Canonical registry of msgids that must never be machine-translated: literal
# tokens compared against source (SQL keywords, confirmation words), enum values
# (d3 interpolation modes), icon names (e.g. "bolt" -> the ⚡ Explore control
# icon), API field names, code constants, and example placeholders. Translating
# them can break icon lookups, enum matching, or API contracts, or is simply
# meaningless (proper nouns, example values). apply_do_not_translate.py stamps
# these msgids in messages.pot with a `#. do-not-translate`
# extracted comment that propagates to every catalog on `pybabel update`.
DO_NOT_TRANSLATE_REGISTRY: Path = TRANSLATIONS_DIR / "do-not-translate.txt"
def _load_do_not_translate(path: Path = DO_NOT_TRANSLATE_REGISTRY) -> frozenset[str]:
"""Load the do-not-translate msgids (skips comment/blank lines).
Lines are stripped before the blank/comment checks, matching the parsing in
apply_do_not_translate.py, so trailing whitespace or an indented comment
never yields a msgid that fails to match a catalog entry.
"""
if not path.exists():
return frozenset()
return frozenset(
stripped
for line in path.read_text(encoding="utf-8").splitlines()
if (stripped := line.strip()) and not stripped.startswith("#")
)
DO_NOT_TRANSLATE: frozenset[str] = _load_do_not_translate()
# An explicit do-not-translate marker on an entry, matched in either the
# extracted comment (`#. do-not-translate`, the standard propagated
# from the .pot) or a translator comment (e.g. the ru catalog's legacy
# "# Не переводить"). Honored so a human's deliberate decision is never
# overridden even if a msgid is missing from the registry.
_DO_NOT_TRANSLATE_COMMENT: re.Pattern[str] = re.compile(
r"не\s+переводить|do[\s-]?not[\s-]?translate|don'?t\s+translate",
re.IGNORECASE,
)
def _is_do_not_translate(entry: polib.POEntry) -> bool:
"""Return True if an entry must be left for a human (never machine-filled).
Either its msgid is in the do-not-translate registry, or the entry carries
an explicit do-not-translate marker in its extracted or translator comment.
"""
if entry.msgid in DO_NOT_TRANSLATE:
return True
return any(
comment and _DO_NOT_TRANSLATE_COMMENT.search(comment)
for comment in (entry.comment, entry.tcomment)
)
def _context_langs(
item: dict[str, Any], index: dict[str, Any], target_lang: str
) -> list[str]:
@@ -647,6 +701,15 @@ def backfill(
missing: list[polib.POEntry] = [e for e in cat if e.msgid and _is_missing(e)]
print(f"Found {len(missing)} untranslated entries for '{lang}'.", file=sys.stderr)
skipped_dnt: list[polib.POEntry] = [e for e in missing if _is_do_not_translate(e)]
if skipped_dnt:
missing = [e for e in missing if not _is_do_not_translate(e)]
print(
f"Skipping {len(skipped_dnt)} do-not-translate entries (literal "
f"tokens / translator-marked); they are left untranslated.",
file=sys.stderr,
)
if min_context > 0:
before = len(missing)
missing = [

File diff suppressed because it is too large Load Diff

View File

@@ -37,10 +37,10 @@
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-typescript": "7.29.7",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"tscw-config": "^1.1.2",

View File

@@ -72,7 +72,10 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
'node_modules/(?!@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
// @ant-design/colors and @ant-design/fast-color are allowed through because
// @ant-design/icons >= 6.3 deep-imports the ESM build of @ant-design/colors
// from its CJS output, so babel-jest must transform those files.
'node_modules/(?!@ant-design/(colors|fast-color)|@formatjs/.*|d3-(array|interpolate|color|time|scale|time-format|format)|internmap|@mapbox/tiny-sdf|remark-gfm|(?!@ngrx|(?!deck.gl)|d3-scale)|markdown-table|micromark-*.|decode-named-character-reference|character-entities|mdast-util-*.|unist-util-*.|ccount|escape-string-regexp|nanoid|uuid|@rjsf/*.|echarts|zrender|fetch-mock|pretty-ms|parse-ms|ol|@babel/runtime|@emotion|cheerio|cheerio/lib|parse5|dom-serializer|entities|htmlparser2|rehype-sanitize|hast-util-sanitize|unified|unist-.*|hast-.*|rehype-.*|remark-.*|mdast-.*|micromark-.*|parse-entities|property-information|space-separated-tokens|comma-separated-tokens|bail|devlop|zwitch|longest-streak|geostyler|geostyler-.*|(?!geostyler)lodash|react-error-boundary|react-json-tree|react-base16-styling|lodash-es|rbush|quickselect|react-diff-viewer-continued|storybook/*.|json-stringify-pretty-compact)',
],
preset: 'ts-jest',
transform: {

File diff suppressed because it is too large Load Diff

View File

@@ -176,7 +176,7 @@
"dom-to-pdf": "^0.3.2",
"echarts": "^6.1.0",
"fast-glob": "^3.3.2",
"fs-extra": "^11.3.5",
"fs-extra": "^11.3.6",
"fuse.js": "^7.4.2",
"geolib": "^3.3.14",
"geostyler": "^18.6.0",
@@ -298,8 +298,8 @@
"@types/rison": "0.1.0",
"@types/tinycolor2": "^1.4.3",
"@types/unzipper": "^0.10.11",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.61.0",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -314,7 +314,7 @@
"eslint-config-prettier": "^10.1.8",
"eslint-import-resolver-alias": "^1.1.2",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-cypress": "^3.6.0",
"eslint-plugin-cypress": "^6.4.2",
"eslint-plugin-i18n-strings": "file:eslint-rules/eslint-plugin-i18n-strings",
"eslint-plugin-icons": "file:eslint-rules/eslint-plugin-icons",
"eslint-plugin-import": "^2.32.0",
@@ -324,7 +324,7 @@
"eslint-plugin-prettier": "^5.5.6",
"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.4.5",
"eslint-plugin-storybook": "10.4.6",
"eslint-plugin-testing-library": "^7.16.2",
"eslint-plugin-theme-colors": "file:eslint-rules/eslint-plugin-theme-colors",
"fetch-mock": "^12.6.0",
@@ -344,9 +344,9 @@
"mini-css-extract-plugin": "^2.10.2",
"minimizer-webpack-plugin": "^5.6.1",
"open-cli": "^9.0.0",
"oxlint": "^1.71.0",
"oxlint": "^1.72.0",
"po2json": "^0.4.5",
"prettier": "3.9.3",
"prettier": "3.9.4",
"prettier-plugin-packagejson": "^3.0.2",
"process": "^0.11.10",
"react-dnd-test-backend": "^16.0.1",
@@ -393,7 +393,6 @@
"http-proxy-middleware": "^2.0.10",
"tar": "^7.5.16",
"puppeteer": "^22.4.1",
"remark-gfm": "^3.0.1",
"underscore": "^1.13.7",
"jspdf": "^4.2.0",
"nwsapi": "^2.2.13",
@@ -428,6 +427,9 @@
},
"eslint-plugin-jest-dom": {
"eslint": "$eslint"
},
"lerna": {
"js-yaml": "^4.3.0"
}
},
"readme": "ERROR: No README data found!",

View File

@@ -35,7 +35,7 @@
},
"devDependencies": {
"cross-env": "^10.1.0",
"fs-extra": "^11.3.5",
"fs-extra": "^11.3.6",
"jest": "^30.4.2",
"yeoman-test": "^11.6.0"
},

View File

@@ -258,6 +258,16 @@ export interface SupersetSpecificTokens {
dashboardTileBorder?: string;
dashboardTileBorderRadius?: number;
dashboardTileBoxShadow?: string;
/**
* Results grid customization tokens.
* Control the appearance of AG Grid-backed result tables (e.g. SQL Lab).
*/
resultsGridRowHeight?: number;
resultsGridHeaderFontWeight?: number;
resultsGridHeaderFontSize?: number;
resultsGridBorderRadius?: number;
resultsGridNoStriping?: boolean;
}
/**

View File

@@ -330,8 +330,7 @@ export interface SelectControlConfig<
optionRenderer?: (option: O) => ReactNode;
valueRenderer?: (option: O) => ReactNode;
filterOption?:
| ((option: FilterOption<O>, rawInput: string) => boolean)
| null;
((option: FilterOption<O>, rawInput: string) => boolean) | null;
}
export type SharedControlConfig<
@@ -396,9 +395,7 @@ export const isCustomControlItem = (obj: unknown): obj is CustomControlItem =>
export type ExpandedControlItem = CustomControlItem | ReactElement | null;
export type ControlSetItem =
| SharedControlAlias
| OverrideSharedControlItem
| ExpandedControlItem;
SharedControlAlias | OverrideSharedControlItem | ExpandedControlItem;
export type ControlSetRow = ControlSetItem[];
@@ -702,10 +699,7 @@ export interface DataColumnMeta {
originalLabel?: string;
dataType: GenericDataType;
formatter?:
| TimeFormatter
| NumberFormatter
| CustomFormatter
| CurrencyFormatter;
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
isMetric?: boolean;
isPercentMetric?: boolean;
isNumeric?: boolean;

View File

@@ -24,7 +24,7 @@
"lib"
],
"dependencies": {
"@ant-design/icons": "^6.2.5",
"@ant-design/icons": "^6.3.2",
"@apache-superset/core": "*",
"@babel/runtime": "^7.29.7",
"@braintree/sanitize-url": "^7.1.2",
@@ -53,16 +53,16 @@
"re-resizable": "^6.11.2",
"react-ace": "^14.0.1",
"react-draggable": "^4.7.0",
"react-error-boundary": "6.0.0",
"react-error-boundary": "^6.1.2",
"react-js-cron": "^5.2.0",
"react-markdown": "^8.0.7",
"react-markdown": "^9.0.7",
"react-resize-detector": "^7.1.2",
"react-syntax-highlighter": "^16.1.1",
"react-ultimate-pagination": "^1.3.2",
"regenerator-runtime": "^0.14.1",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^3.0.1",
"remark-gfm": "^4.0.1",
"reselect": "^5.2.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
@@ -73,7 +73,7 @@
"@emotion/styled": "^11.14.1",
"@types/d3-format": "^3.0.4",
"@types/d3-interpolate": "^3.0.4",
"@types/d3-scale": "^2.1.1",
"@types/d3-scale": "^4.0.9",
"@types/d3-time": "^3.0.4",
"@types/d3-time-format": "^4.0.3",
"@types/jquery": "^4.0.1",

View File

@@ -396,17 +396,24 @@ class CategoricalColorScale extends ExtensibleFunction {
/**
* Returns the current unknown value, which defaults to "implicit".
*/
unknown(): string | { name: 'implicit' };
unknown(): { name: 'implicit' };
/**
* Sets the output value of the scale for unknown input values and returns this scale.
* The implicit value enables implicit domain construction. scaleImplicit can be used as a convenience to set the implicit value.
*
* The signatures mirror d3's `ScaleOrdinal.unknown` so this class remains
* assignable to `ScaleOrdinal<{ toString(): string }, string>`.
*
* @param value Unknown value to be used or scaleImplicit to set implicit scale generation.
*/
unknown(value: string | { name: 'implicit' }): this;
unknown<NewUnknown>(
value: NewUnknown,
): NewUnknown extends { name: 'implicit' }
? ScaleOrdinal<{ toString(): string }, string>
: ScaleOrdinal<{ toString(): string }, string, NewUnknown>;
unknown(value?: string | { name: 'implicit' }): unknown {
unknown(value?: unknown): unknown {
if (typeof value === 'undefined') {
return this.scale.unknown();
}

View File

@@ -29,13 +29,7 @@ import {
import type { AsyncAceEditorOptions } from './types';
type EditorType =
| 'sql'
| 'full-sql'
| 'markdown'
| 'text-area'
| 'css'
| 'json'
| 'config';
'sql' | 'full-sql' | 'markdown' | 'text-area' | 'css' | 'json' | 'config';
const editorTypes: EditorType[] = [
'sql',

View File

@@ -70,6 +70,199 @@ test('SQLEditor uses fontFamilyCode from theme', async () => {
expect(fontFamily).toMatch(/mono|courier|consolas/i);
});
test('re-measures Ace font metrics on load and preserves a consumer onLoad (#41664)', async () => {
// Ace caches glyph width at construction; if the editor font settles later,
// the caret drifts. The editor forces a re-measure on load and again once
// `document.fonts.ready` resolves. Mock `document.fonts` so the re-measure
// path is deterministic regardless of the jsdom FontFaceSet implementation.
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
const fontsReady = Promise.resolve();
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { ready: fontsReady },
});
const ref = createRef<AceEditor>();
let updateFontSizeSpy: jest.SpyInstance | undefined;
// The spy is installed from inside the consumer onLoad, so it captures the
// asynchronous (post-fonts-ready) re-measure that runs after this callback.
const consumerOnLoad = jest.fn((editor: AceEditor['editor']) => {
// Cast to a minimal shape so `jest.spyOn` resolves cleanly; it is the
// same renderer instance the component re-measures, so the spy still
// observes the production calls.
const renderer = editor.renderer as unknown as {
updateFontSize: () => void;
};
updateFontSizeSpy = jest.spyOn(renderer, 'updateFontSize');
});
try {
const { container } = render(
<SQLEditor
ref={ref as React.Ref<never>}
onLoad={consumerOnLoad as never}
/>,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
// The wrapper must call through to the consumer's onLoad with the editor.
expect(consumerOnLoad).toHaveBeenCalledTimes(1);
expect(consumerOnLoad).toHaveBeenCalledWith(ref.current?.editor);
// Once fonts settle, the editor re-measures (Ace itself resizes and
// re-renders when the measured character size changed) so the caret
// realigns.
await fontsReady;
await waitFor(() => {
expect(updateFontSizeSpy).toHaveBeenCalled();
});
} finally {
updateFontSizeSpy?.mockRestore();
if (originalFonts) {
Object.defineProperty(document, 'fonts', originalFonts);
} else {
delete (document as { fonts?: unknown }).fonts;
}
}
});
test('does not crash when the Font Loading API is unavailable (#41697)', async () => {
// jsdom has no `document.fonts` by default; some embedded webviews don't
// either. The load-time re-measure must still run and the editor must mount.
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
delete (document as { fonts?: unknown }).fonts;
const consumerOnLoad = jest.fn();
try {
const { container } = render(
<SQLEditor onLoad={consumerOnLoad as never} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
expect(consumerOnLoad).toHaveBeenCalledTimes(1);
} finally {
if (originalFonts) {
Object.defineProperty(document, 'fonts', originalFonts);
}
}
});
test('explicitly loads the editor font and re-measures when it resolves (#41664)', async () => {
// `fonts.ready` can settle BEFORE a lazily-referenced editor font even
// starts loading, so the fix must request the font itself via
// `fonts.load()` and re-measure when that specific load resolves.
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
let resolveFontLoad: () => void = () => {};
const fontLoad = new Promise<void>(resolve => {
resolveFontLoad = resolve;
});
const load = jest.fn((_font: string) => fontLoad);
Object.defineProperty(document, 'fonts', {
configurable: true,
// `ready` already settled: the pre-fix code would never re-measure.
value: {
ready: Promise.resolve(),
load,
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
},
});
let updateFontSizeSpy: jest.SpyInstance | undefined;
const consumerOnLoad = jest.fn((editor: AceEditor['editor']) => {
const renderer = editor.renderer as unknown as {
updateFontSize: () => void;
};
updateFontSizeSpy = jest.spyOn(renderer, 'updateFontSize');
});
try {
const { container } = render(
<SQLEditor onLoad={consumerOnLoad as never} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
// The editor font itself is requested (family from the theme's code font).
expect(load).toHaveBeenCalledTimes(1);
expect(load.mock.calls[0][0]).toMatch(/^12px /);
const callsBefore = updateFontSizeSpy?.mock.calls.length ?? 0;
resolveFontLoad();
await waitFor(() => {
expect(updateFontSizeSpy!.mock.calls.length).toBeGreaterThan(callsBefore);
});
} finally {
updateFontSizeSpy?.mockRestore();
if (originalFonts) {
Object.defineProperty(document, 'fonts', originalFonts);
} else {
delete (document as { fonts?: unknown }).fonts;
}
}
});
test('re-measures on later loadingdone events and unsubscribes on unmount (#41664)', async () => {
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts');
const listeners: Record<string, (() => void)[]> = {};
const addEventListener = jest.fn((event: string, handler: () => void) => {
(listeners[event] ??= []).push(handler);
});
const removeEventListener = jest.fn((event: string, handler: () => void) => {
listeners[event] = (listeners[event] ?? []).filter(h => h !== handler);
});
Object.defineProperty(document, 'fonts', {
configurable: true,
value: {
ready: Promise.resolve(),
load: jest.fn(() => Promise.resolve()),
addEventListener,
removeEventListener,
},
});
let updateFontSizeSpy: jest.SpyInstance | undefined;
const consumerOnLoad = jest.fn((editor: AceEditor['editor']) => {
const renderer = editor.renderer as unknown as {
updateFontSize: () => void;
};
updateFontSizeSpy = jest.spyOn(renderer, 'updateFontSize');
});
try {
const { container, unmount } = render(
<SQLEditor onLoad={consumerOnLoad as never} />,
);
await waitFor(() => {
expect(container.querySelector(selector)).toBeInTheDocument();
});
await waitFor(() => {
expect(listeners.loadingdone?.length ?? 0).toBeGreaterThan(0);
});
// A font finishing later (e.g. a user-config font) re-measures again.
const callsBefore = updateFontSizeSpy?.mock.calls.length ?? 0;
listeners.loadingdone.forEach(handler => handler());
expect(updateFontSizeSpy!.mock.calls.length).toBeGreaterThan(callsBefore);
// Unmount removes the listener so destroyed editors are not re-measured.
unmount();
expect(listeners.loadingdone?.length ?? 0).toBe(0);
} finally {
updateFontSizeSpy?.mockRestore();
if (originalFonts) {
Object.defineProperty(document, 'fonts', originalFonts);
} else {
delete (document as { fonts?: unknown }).fonts;
}
}
});
test('renders FullSQLEditor', async () => {
const { container } = render(<FullSQLEditor />);

View File

@@ -0,0 +1,219 @@
/**
* 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 ace from 'ace-builds/src-noconflict/ace';
import {
emojiWidth,
isBmpEmojiPresentation,
patchAceEmojiWidths,
} from './emojiWidthPatch';
const { EditSession } = ace.require('ace/edit_session');
const { Text: TextLayer } = ace.require('ace/layer/text');
beforeAll(() => {
patchAceEmojiWidths(EditSession, TextLayer);
});
test('classifies BMP emoji-presentation codepoints and nothing else', () => {
expect(isBmpEmojiPresentation('✨'.charCodeAt(0))).toBe(true); // U+2728
expect(isBmpEmojiPresentation('⭐'.charCodeAt(0))).toBe(true); // U+2B50
expect(isBmpEmojiPresentation('❤'.charCodeAt(0))).toBe(false); // text-default
expect(isBmpEmojiPresentation('A'.charCodeAt(0))).toBe(false);
expect(isBmpEmojiPresentation('中'.charCodeAt(0))).toBe(false); // CJK, ace's own tables
expect(isBmpEmojiPresentation(0xd83d)).toBe(false); // lone surrogate
});
test('emojiWidth: VS16 tops its base up to 2 columns total', () => {
const VS16 = 0xfe0f;
expect(emojiWidth('✨'.charCodeAt(0), 0)).toBe(2);
// ❤️ = U+2764 (1 by default rules) + VS16 (1) = 2 total
expect(emojiWidth('❤'.charCodeAt(0), 0)).toBe(null);
expect(emojiWidth(VS16, '❤'.charCodeAt(0))).toBe(1);
// Redundant ✨️ = ✨ (2) + VS16 (0) = 2 total
expect(emojiWidth(VS16, '✨'.charCodeAt(0))).toBe(0);
expect(emojiWidth('A'.charCodeAt(0), 0)).toBe(null);
});
test('caret math counts emoji as two columns (issue #41664 repro line)', () => {
const session = new EditSession('### ✨Header 3');
// Before "H" (doc column 5): "### " = 4 + ✨ = 2 → screen column 6.
expect(session.documentToScreenColumn(0, 5)).toBe(6);
// End of line: 4 + 2 + "Header 3".length (8) = 14.
expect(session.documentToScreenColumn(0, 13)).toBe(14);
});
test('astral emoji keep their pre-existing two-column accounting', () => {
const session = new EditSession('💡X');
// 💡 is two code units (doc columns 0-1) and two screen columns.
expect(session.documentToScreenColumn(0, 2)).toBe(2);
});
test('$getStringScreenWidth counts emoji, VS16 pairs, and plain text', () => {
const session = new EditSession('');
expect(session.$getStringScreenWidth('✨')[0]).toBe(2);
expect(session.$getStringScreenWidth('❤️')[0]).toBe(2);
expect(session.$getStringScreenWidth('✨️')[0]).toBe(2);
expect(session.$getStringScreenWidth('abc')[0]).toBe(3);
expect(session.$getStringScreenWidth('中')[0]).toBe(2); // ace's own CJK path
});
test('renderer draws emoji in a forced 2×charWidth box like CJK', () => {
const container = document.createElement('div');
const layer = new TextLayer(container);
layer.config = { characterWidth: 10 };
const parent = document.createElement('div');
const screenColumn = layer.$renderToken(
parent,
0,
{ type: 'text', value: '# ✨Header' },
'# ✨Header',
);
const box = parent.querySelector<HTMLElement>('.ace_cjk');
expect(box).not.toBeNull();
expect(box?.textContent).toBe('✨');
expect(box?.style.width).toBe('20px');
// "# " (2) + ✨ (2) + "Header" (6) = 10 columns.
expect(screenColumn).toBe(10);
// Text around the emoji is preserved in order.
expect(parent.textContent).toBe('# ✨Header');
});
test('renderer handles a leading emoji (empty split part) cleanly', () => {
const container = document.createElement('div');
const layer = new TextLayer(container);
layer.config = { characterWidth: 10 };
const parent = document.createElement('div');
const screenColumn = layer.$renderToken(
parent,
0,
{ type: 'text', value: '✨x' },
'✨x',
);
expect(parent.querySelector('.ace_cjk')?.textContent).toBe('✨');
expect(parent.textContent).toBe('✨x');
expect(screenColumn).toBe(3);
});
test('renderer leaves emoji-free tokens entirely to the original path', () => {
const container = document.createElement('div');
const layer = new TextLayer(container);
layer.config = { characterWidth: 10 };
const parent = document.createElement('div');
const screenColumn = layer.$renderToken(
parent,
0,
{ type: 'text', value: 'SELECT 1' },
'SELECT 1',
);
expect(parent.querySelector('.ace_cjk')).toBeNull();
expect(parent.textContent).toBe('SELECT 1');
expect(screenColumn).toBe(8);
});
test('emoji boxes carry the token-class styling of their token', () => {
const container = document.createElement('div');
const layer = new TextLayer(container);
layer.config = { characterWidth: 10 };
const parent = document.createElement('div');
layer.$renderToken(
parent,
0,
{ type: 'constant.language', value: '✨' },
'✨',
);
const box = parent.querySelector<HTMLElement>('.ace_cjk');
expect(box?.className).toBe('ace_cjk ace_constant ace_language');
});
test('emoji boxes in plain text tokens carry no extra classes', () => {
const container = document.createElement('div');
const layer = new TextLayer(container);
layer.config = { characterWidth: 10 };
const parent = document.createElement('div');
layer.$renderToken(parent, 0, { type: 'text', value: '✨' }, '✨');
const box = parent.querySelector<HTMLElement>('.ace_cjk');
expect(box?.className).toBe('ace_cjk');
});
test('patch is idempotent', () => {
const before = EditSession.prototype.$getStringScreenWidth;
patchAceEmojiWidths(EditSession, TextLayer);
expect(EditSession.prototype.$getStringScreenWidth).toBe(before);
});
// --- copied-internals parity ------------------------------------------------
// The two overridden EditSession methods are verbatim copies of the
// ace-builds internals plus the emoji branch; these pin the non-emoji
// behavior so a future ace upgrade that drifts is caught here.
test('$getStringScreenWidth keeps tab, early-return, and break semantics', () => {
const session = new EditSession('');
// Tab expands to the next tab stop (default tab size 4).
expect(session.$getStringScreenWidth('\ta')[0]).toBe(5);
// maxScreenColumn === 0 short-circuits.
expect(session.$getStringScreenWidth('abc', 0)).toEqual([0, 0]);
// Stops consuming once past maxScreenColumn.
const [screen, column] = session.$getStringScreenWidth('abcdef', 3);
expect(screen).toBe(4);
expect(column).toBe(3);
});
test('$getDisplayTokens keeps tab, space, punctuation, and CJK tokens', () => {
const session = new EditSession('');
const CHAR = 1;
const CHAR_EXT = 2;
const PUNCTUATION = 9;
const SPACE = 10;
const TAB = 11;
const TAB_SPACE = 12;
// Tab at offset 0 with default tab size 4: TAB + 3 TAB_SPACEs.
expect(session.$getDisplayTokens('\t', 0)).toEqual([
TAB,
TAB_SPACE,
TAB_SPACE,
TAB_SPACE,
]);
expect(session.$getDisplayTokens(' a', 0)).toEqual([SPACE, CHAR]);
expect(session.$getDisplayTokens('*:', 0)).toEqual([
PUNCTUATION,
PUNCTUATION,
]);
expect(session.$getDisplayTokens('中', 0)).toEqual([CHAR, CHAR_EXT]);
expect(session.$getDisplayTokens('✨', 0)).toEqual([CHAR, CHAR_EXT]);
// VS16 keeps one entry per code unit so wrap offsets stay aligned.
expect(session.$getDisplayTokens('✨️', 0)).toEqual([
CHAR,
CHAR_EXT,
CHAR_EXT,
]);
expect(session.$getDisplayTokens('❤️', 0)).toEqual([CHAR, CHAR_EXT]);
// Second pass hits the memoized emoji-base classification.
expect(session.$getDisplayTokens('❤️', 0)).toEqual([CHAR, CHAR_EXT]);
});

View File

@@ -0,0 +1,283 @@
/**
* 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.
*/
/**
* Ace positions the caret on a monospace grid (`column × charWidth`) and
* renders anything it classifies as "full width" inside a forced
* `2 × charWidth` inline-block (`.ace_cjk`), so the grid model and the pixels
* agree. Its width tables only cover East-Asian ranges, though:
*
* - Astral emoji (💡, surrogate pairs) happen to work: the renderer's
* surrogate-pair branch forces the 2-cell box and the column counter
* counts the two code units.
* - BMP emoji with default emoji presentation (✨ U+2728, ⭐ U+2B50, …) fall
* through BOTH nets: they count as 1 column but render ~1.6 cells wide, so
* the caret drifts on any line containing one (issue #41664, upstream
* ajaxorg/ace#3404).
* - VS16 sequences (❤️ = U+2764 U+FE0F) render as one color glyph while the
* two code units count as two ordinary columns, without the forced box.
*
* The patch extends the exact mechanism Ace already uses — count 2 columns
* and force the 2-cell box — to emoji-presentation codepoints and VS16
* sequences, by overriding the two `EditSession` width methods (verbatim
* copies of the ace-builds internals plus one branch; the originals call a
* module-private `isFullWidth`, so they cannot be extended in place) and
* wrapping the text layer's `$renderToken` to emit the forced-width span for
* emoji runs before delegating everything else to the original.
*
* Deliberately out of scope: ZWJ sequences (family emoji) and flag pairs
* render as one glyph but count per component; handling them needs grapheme
* segmentation across every Ace layer, and they were equally misaligned
* before this patch.
*/
const VS16 = 0xfe0f;
// Tokens ace renders as bare text nodes with no token-class span; mirrors
// ace/layer/text_util's textTokens set.
const TEXT_TOKENS = new Set(['text', 'rparen', 'lparen']);
// Emoji_Presentation covers exactly the codepoints browsers render as color
// emoji without a variation selector. Lone surrogates never match it (the
// astral path is already consistent), so only BMP emoji change behavior.
const EMOJI_PRESENTATION_RE = /\p{Emoji_Presentation}/u;
// One renderable emoji cluster: an emoji-presentation codepoint with an
// optional (redundant) VS16, or a text-presentation emoji forced to color
// presentation by VS16.
const EMOJI_CLUSTER_SOURCE =
'(?:\\p{Emoji_Presentation}\\uFE0F?|\\p{Emoji}\\uFE0F)';
const EMOJI_RUN_TEST_RE = new RegExp(`${EMOJI_CLUSTER_SOURCE}`, 'u');
const EMOJI_RUN_SPLIT_RE = new RegExp(`(${EMOJI_CLUSTER_SOURCE}+)`, 'gu');
const EMOJI_CLUSTER_RE = new RegExp(EMOJI_CLUSTER_SOURCE, 'gu');
// Memoized per-code-unit classification; $getStringScreenWidth runs per
// character per rendered line, so the regexes must not run every time.
const EMOJI_BASE_RE = /\p{Emoji}/u;
const emojiBaseCache = new Map<number, boolean>();
function isEmojiBase(code: number): boolean {
let result = emojiBaseCache.get(code);
if (result === undefined) {
result = EMOJI_BASE_RE.test(String.fromCharCode(code));
emojiBaseCache.set(code, result);
}
return result;
}
const bmpEmojiCache = new Map<number, boolean>();
export function isBmpEmojiPresentation(code: number): boolean {
if (code < 0x2000 || code > 0xffff || (code >= 0xd800 && code <= 0xdfff)) {
return false;
}
let result = bmpEmojiCache.get(code);
if (result === undefined) {
result = EMOJI_PRESENTATION_RE.test(String.fromCharCode(code));
bmpEmojiCache.set(code, result);
}
return result;
}
/**
* Screen-column contribution of one code unit under the emoji rules, or null
* when Ace's default handling should apply. Emoji-presentation chars are 2
* columns; VS16 tops its base up to 2 in total (so ❤️ = 1 + 1 and a
* redundant ✨️ = 2 + 0).
*/
export function emojiWidth(code: number, prevCode: number): number | null {
if (isBmpEmojiPresentation(code)) {
return 2;
}
if (code === VS16) {
return isBmpEmojiPresentation(prevCode) ? 0 : 1;
}
return null;
}
interface PatchableEditSessionClass {
prototype: {
isFullWidth: (c: number) => boolean;
getScreenTabSize: (screenColumn: number) => number;
$getStringScreenWidth: (
str: string,
maxScreenColumn?: number,
screenColumn?: number,
) => [number, number];
$getDisplayTokens: (str: string, offset: number) => number[];
};
}
interface TextLayerInstance {
config: { characterWidth: number };
dom: { createElement: (tag: string) => HTMLElement };
}
type RenderToken = (
this: TextLayerInstance,
parent: HTMLElement,
screenColumn: number,
token: { type: string; value: string },
value: string,
) => number;
interface PatchableTextLayerClass {
prototype: { $renderToken: RenderToken };
}
let patched = false;
export function patchAceEmojiWidths(
EditSession: PatchableEditSessionClass,
TextLayer: PatchableTextLayerClass,
): void {
if (patched) {
return;
}
patched = true;
const sessionProto = EditSession.prototype;
const { isFullWidth } = sessionProto;
// Mirrors EditSession.prototype.$getStringScreenWidth from ace-builds
// (src-noconflict/ace.js), adding the emojiWidth branch.
sessionProto.$getStringScreenWidth = function $getStringScreenWidth(
this: PatchableEditSessionClass['prototype'],
str: string,
maxScreenColumn?: number,
screenColumn?: number,
): [number, number] {
if (maxScreenColumn === 0) {
return [0, 0];
}
const max = maxScreenColumn ?? Infinity;
let screen = screenColumn || 0;
let column;
for (column = 0; column < str.length; column += 1) {
const c = str.charCodeAt(column);
const emoji = emojiWidth(c, column > 0 ? str.charCodeAt(column - 1) : 0);
if (c === 9) {
screen += this.getScreenTabSize(screen);
} else if (emoji !== null) {
screen += emoji;
} else if (c >= 0x1100 && isFullWidth(c)) {
screen += 2;
} else {
screen += 1;
}
if (screen > max) {
break;
}
}
return [screen, column];
};
// Display-token codes from ace's edit_session module.
const CHAR = 1;
const CHAR_EXT = 2;
const PUNCTUATION = 9;
const SPACE = 10;
const TAB = 11;
const TAB_SPACE = 12;
// Mirrors EditSession.prototype.$getDisplayTokens, adding the emoji branch.
// One entry per code unit is preserved (VS16 pushes a lone CHAR_EXT) so the
// wrap-split offset math stays aligned with document columns.
sessionProto.$getDisplayTokens = function $getDisplayTokens(
this: PatchableEditSessionClass['prototype'],
str: string,
offset: number,
): number[] {
const arr: number[] = [];
let tabSize: number;
for (let i = 0; i < str.length; i += 1) {
const c = str.charCodeAt(i);
const emoji = emojiWidth(c, i > 0 ? str.charCodeAt(i - 1) : 0);
if (c === 9) {
tabSize = this.getScreenTabSize(arr.length + offset);
arr.push(TAB);
for (let n = 1; n < tabSize; n += 1) {
arr.push(TAB_SPACE);
}
} else if (emoji === 2) {
arr.push(CHAR, CHAR_EXT);
} else if (emoji === 0) {
arr.push(CHAR_EXT);
} else if (c === VS16 && i > 0 && isEmojiBase(str.charCodeAt(i - 1))) {
// VS16 after a text-presentation emoji base (❤️): keep the pair
// atomic for wrap splitting; its width contribution stays 1.
arr.push(CHAR_EXT);
} else if (c === 32) {
arr.push(SPACE);
} else if ((c > 39 && c < 48) || (c > 57 && c < 64)) {
arr.push(PUNCTUATION);
} else if (c >= 0x1100 && isFullWidth(c)) {
arr.push(CHAR, CHAR_EXT);
} else {
arr.push(CHAR);
}
}
return arr;
};
// Pre-split token text on emoji runs: emoji clusters get the same forced
// 2 × charWidth `.ace_cjk` box Ace gives CJK (so rendered geometry matches
// the 2-column model above); everything else delegates to the original.
const origRenderToken = TextLayer.prototype.$renderToken;
TextLayer.prototype.$renderToken = function $renderToken(
this: TextLayerInstance,
parent: HTMLElement,
screenColumn: number,
token: { type: string; value: string },
value: string,
): number {
if (!EMOJI_RUN_TEST_RE.test(value)) {
return origRenderToken.call(this, parent, screenColumn, token, value);
}
// Non-text tokens normally have their whole fragment wrapped in a
// token-class span ("ace_" + dotted type); carry those classes on the
// emoji box itself so syntax styling that isn't glyph color (comment
// italics, invalid-token backgrounds, …) still applies to emoji.
// Mirrors ace's text_util.isTextToken and $renderToken class handling.
const tokenClasses = TEXT_TOKENS.has(token.type)
? ''
: ` ace_${token.type.replace(/\./g, ' ace_')}`;
let column = screenColumn;
value.split(EMOJI_RUN_SPLIT_RE).forEach((part, index) => {
if (!part) {
return;
}
const isEmojiRun = index % 2 === 1;
if (isEmojiRun) {
// Parts at odd indices are the split's capture group, so every one
// is a non-empty sequence of clusters; matchAll never comes up empty.
Array.from(part.matchAll(EMOJI_CLUSTER_RE), m => m[0]).forEach(
cluster => {
const span = this.dom.createElement('span');
span.style.width = `${this.config.characterWidth * 2}px`;
span.className = `ace_cjk${tokenClasses}`;
span.textContent = cluster;
parent.appendChild(span);
column += 2;
},
);
} else {
column = origRenderToken.call(this, parent, column, token, part);
}
});
return column;
};
}

View File

@@ -16,7 +16,13 @@
* specific language governing permissions and limitations
* under the License.
*/
import { forwardRef, useEffect, useCallback, ComponentType } from 'react';
import {
forwardRef,
useEffect,
useCallback,
useRef,
ComponentType,
} from 'react';
import type {
Editor as OrigEditor,
@@ -35,6 +41,8 @@ import {
import { useTheme, css, type SupersetTheme } from '@apache-superset/core/theme';
import { Global } from '@emotion/react';
import { patchAceEmojiWidths } from './emojiWidthPatch';
export { getTooltipHTML } from './Tooltip';
export { useJsonValidation } from './useJsonValidation';
export type {
@@ -162,6 +170,14 @@ export function AsyncAceEditor(
config.setModuleUrl('ace/mode/javascript_worker', javascriptWorkerUrl);
config.setModuleUrl('ace/mode/html_worker', htmlWorkerUrl);
// Align caret math and rendered glyph geometry for emoji (issue #41664);
// see emojiWidthPatch for the full story. Applied once, globally, since
// the prototypes are shared by every Ace editor instance.
patchAceEmojiWidths(
acequire('ace/edit_session').EditSession,
acequire('ace/layer/text').Text,
);
await Promise.all(aceModules.map(x => aceModuleLoaders[x]()));
const inferredMode =
@@ -179,6 +195,7 @@ export function AsyncAceEditor(
theme = inferredTheme,
tabSize = defaultTabSize,
defaultValue = '',
onLoad,
...props
},
ref,
@@ -281,6 +298,62 @@ export function AsyncAceEditor(
};
}, [ref]);
// Ace caches the measured glyph width in its internal FontMetrics and
// only re-measures when its hidden measure node's own size changes. If
// the editor font finishes loading after construction, the cached width
// can stop matching the rendered glyphs and the caret drifts further
// from the text the longer the line, the residual misalignment in issue
// #41664 that the font-family CSS from #38928 does not address.
// `updateFontSize` runs Ace's `checkForSizeChanges`, which on a metrics
// change emits `changeCharacterSize`; Ace's renderer reacts with a
// forced resize and full re-render.
//
// Re-measure on every signal that glyph geometry may have changed:
// - immediately, for the already-loaded case;
// - after explicitly loading the editor font via `fonts.load()` —
// `fonts.ready` alone is NOT enough, because it can settle before a
// lazily-referenced editor font even starts loading, in which case
// waiting on it misses the swap entirely;
// - after `fonts.ready`, for everything in flight at mount;
// - on every subsequent `loadingdone` event while mounted, for fonts
// that load even later (removed on unmount via fontCleanupsRef).
const fontCleanupsRef = useRef<(() => void)[]>([]);
useEffect(
() => () => {
fontCleanupsRef.current.forEach(cleanup => cleanup());
fontCleanupsRef.current = [];
},
[],
);
const handleLoad = useCallback(
(editor: Ace.Editor) => {
const remeasure = () => {
try {
editor.renderer.updateFontSize();
} catch {
// The editor was destroyed before a pending font event fired.
}
};
remeasure();
const { fonts } = document;
if (fonts) {
// The size in the shorthand is irrelevant; any value triggers
// the load of the family's faces.
fonts
.load?.(`12px ${editorFontFamily}`)
?.then(remeasure)
?.catch(() => {});
fonts.ready?.then(remeasure)?.catch(() => {});
fonts.addEventListener?.('loadingdone', remeasure);
fontCleanupsRef.current.push(() =>
fonts.removeEventListener?.('loadingdone', remeasure),
);
}
onLoad?.(editor);
},
[onLoad, editorFontFamily],
);
return (
<>
<Global
@@ -316,8 +389,9 @@ export function AsyncAceEditor(
}
/* Adjust selection color */
.ace_editor .ace_selection {
background-color: ${token.colorEditorSelection ??
token.colorPrimaryBgHover} !important;
background-color: ${
token.colorEditorSelection ?? token.colorPrimaryBgHover
} !important;
}
/* Improve active line highlighting */
@@ -468,6 +542,7 @@ export function AsyncAceEditor(
tabSize={tabSize}
defaultValue={defaultValue}
setOptions={{ fontFamily: editorFontFamily }}
onLoad={handleLoad}
{...props}
/>
</>

View File

@@ -31,12 +31,7 @@ export type { AntdButtonProps, ButtonType, ButtonVariantType, ButtonColorType };
export type OnClickHandler = MouseEventHandler<HTMLElement>;
export type ButtonStyle =
| 'primary'
| 'secondary'
| 'tertiary'
| 'danger'
| 'link'
| 'dashed';
'primary' | 'secondary' | 'tertiary' | 'danger' | 'link' | 'dashed';
export type ButtonSize = 'default' | 'small' | 'xsmall';

View File

@@ -37,13 +37,7 @@ import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-monokai';
export type CodeEditorMode =
| 'handlebars'
| 'css'
| 'json'
| 'sql'
| 'markdown'
| 'javascript'
| 'html';
'handlebars' | 'css' | 'json' | 'sql' | 'markdown' | 'javascript' | 'html';
export type CodeEditorTheme = 'light' | 'dark';

View File

@@ -34,8 +34,9 @@ export const DropdownButton = ({
const { type: buttonType } = rest;
// divider implementation for default (non-primary) buttons
const defaultBtnCss = css`
${(!buttonType || buttonType === 'default') &&
`.ant-dropdown-trigger {
${
(!buttonType || buttonType === 'default') &&
`.ant-dropdown-trigger {
position: relative;
&:before {
content: '';
@@ -48,7 +49,8 @@ export const DropdownButton = ({
.anticon {
vertical-align: middle;
}
}`}
}`
}
`;
const button = (
<Dropdown.Button
@@ -58,13 +60,13 @@ export const DropdownButton = ({
defaultBtnCss,
css`
.ant-btn {
height: ${styleConfig?.controlHeight ??
theme.buttonControlHeightSM ??
30}px;
height: ${
styleConfig?.controlHeight ?? theme.buttonControlHeightSM ?? 30
}px;
box-shadow: ${styleConfig?.boxShadow ?? 'none'};
font-size: ${styleConfig?.fontSize ??
theme.buttonFontSize ??
theme.fontSizeSM}px;
font-size: ${
styleConfig?.fontSize ?? theme.buttonFontSize ?? theme.fontSizeSM
}px;
font-weight: ${styleConfig?.fontWeight ?? theme.fontWeightStrong};
}
`,

View File

@@ -221,18 +221,22 @@ export const DynamicEditableTitle = memo(
onPressEnter={handleKeyPress}
placeholder={placeholder}
css={css`
${!canEdit &&
`&[disabled] {
${
!canEdit &&
`&[disabled] {
cursor: default;
}
`}
`
}
font-size: ${theme.fontSizeXL}px;
transition: auto;
${inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`}
${
inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`
}
`}
disabled={!canEdit}
/>

View File

@@ -167,9 +167,11 @@ export const EmptyState: React.FC<EmptyStateProps> = ({
{image && <ImageContainer image={image} size={size} />}
<div
css={(theme: SupersetTheme) => css`
max-width: ${containerSize === 'large'
? theme.sizeUnit * 150
: theme.sizeUnit * 100}px;
max-width: ${
containerSize === 'large'
? theme.sizeUnit * 150
: theme.sizeUnit * 100
}px;
`}
>
{title && <Title size={effectiveTextSize}>{title}</Title>}

View File

@@ -21,8 +21,7 @@ export type { FormProps, FormInstance, FormItemProps } from 'antd/es/form';
export interface LabeledErrorBoundInputProps {
label?: string;
validationMethods:
| { onBlur: (value: any) => void }
| { onChange: (value: any) => void };
{ onBlur: (value: any) => void } | { onChange: (value: any) => void };
errorMessage?: string | null;
helpText?: string;
required?: boolean;

View File

@@ -77,8 +77,7 @@ customIcons.forEach(customIcon => {
});
export type IconNameType =
| keyof typeof antdEnhancedIcons
| keyof typeof iconOverrides;
keyof typeof antdEnhancedIcons | keyof typeof iconOverrides;
type IconComponentType = Record<IconNameType, IconComponent>;

View File

@@ -27,12 +27,7 @@ import type {
export type OnClickHandler = MouseEventHandler<HTMLElement>;
export type LabelType =
| 'success'
| 'warning'
| 'error'
| 'info'
| 'default'
| 'primary';
'success' | 'warning' | 'error' | 'info' | 'default' | 'primary';
export interface LabelProps extends HTMLAttributes<HTMLSpanElement> {
key?: string;

View File

@@ -17,10 +17,7 @@
* under the License.
*/
export type PositionOption =
| 'floating'
| 'inline'
| 'inline-centered'
| 'normal';
'floating' | 'inline' | 'inline-centered' | 'normal';
export type SizeOption = 's' | 'm' | 'l';

View File

@@ -81,11 +81,4 @@ export type Tags = {
};
export type ContentType =
| Dashboards
| Description
| LastModified
| Owner
| Rows
| Sql
| Table
| Tags;
Dashboards | Description | LastModified | Owner | Rows | Sql | Table | Tags;

View File

@@ -73,14 +73,16 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
const closeButtonWidth = theme.sizeUnit * 14;
return css`
${responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`}
${
responsive &&
css`
max-width: ${maxWidth ?? '900px'};
padding-left: ${theme.sizeUnit * 3}px;
padding-right: ${theme.sizeUnit * 3}px;
padding-bottom: 0;
top: 0;
`
}
.ant-modal-content {
background-color: ${theme.colorBgContainer};
@@ -168,40 +170,46 @@ export const StyledModal = styled(BaseModal)<StyledModalProps>`
padding: 0;
}
${draggable &&
css`
.ant-modal-header {
padding: 0;
${
draggable &&
css`
.ant-modal-header {
padding: 0;
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
width: 100%;
}
}
`}
${resizable &&
css`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
height: ${hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`};
.draggable-trigger {
cursor: move;
padding: ${theme.sizeUnit * 4}px ${closeButtonWidth}px
${theme.sizeUnit * 4}px ${theme.sizeUnit * 4}px;
width: 100%;
}
}
}
`}
`
}
${
resizable &&
css`
.resizable {
pointer-events: all;
.resizable-wrapper {
height: 100%;
}
.ant-modal-content {
height: 100%;
.ant-modal-body {
height: ${
hideFooter
? `calc(100% - ${MODAL_HEADER_HEIGHT}px)`
: `calc(100% - ${MODAL_HEADER_HEIGHT}px - ${MODAL_FOOTER_HEIGHT}px)`
};
}
}
}
`
}
`;
}}
`;

View File

@@ -18,9 +18,6 @@
*/
import { useEffect, useMemo, useState } from 'react';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
// TODO: Upgrade to remark-gfm v4+ after migrating to React 18.
// remark-gfm v4+ requires react-markdown v9+, which requires React 18.
// Currently pinned to v3.0.1 for compatibility with react-markdown v8 and React 17.
import remarkGfm from 'remark-gfm';
import { cloneDeep, mergeWith } from 'lodash-es';
import { FeatureFlag, isFeatureEnabled } from '../../utils';
@@ -139,7 +136,7 @@ export function SafeMarkdown({
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
skipHtml={false}
transformLinkUri={transformLinkUri}
urlTransform={transformLinkUri}
>
{source}
</ReactMarkdown>

View File

@@ -51,8 +51,10 @@ const StyledTabs = ({
.ant-tabs-content-holder {
overflow: ${allowOverflow ? 'visible' : 'auto'};
${fullHeight && 'height: 100%;'}
${contentHeight &&
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`}
${
contentHeight &&
`height: ${typeof contentHeight === 'number' ? `${contentHeight}px` : contentHeight};`
}
${contentPadding}
}
.ant-tabs-content {
@@ -66,9 +68,11 @@ const StyledTabs = ({
margin: 0;
}
.ant-tabs-nav-wrap {
${!(tabBarStyle && 'paddingLeft' in tabBarStyle)
? `padding: 0 ${theme.sizeUnit * 4}px;`
: ''}
${
!(tabBarStyle && 'paddingLeft' in tabBarStyle)
? `padding: 0 ${theme.sizeUnit * 4}px;`
: ''
}
}
.ant-tabs-tab {
flex: 1 1 auto;

View File

@@ -211,6 +211,7 @@ export type {
GridState,
GridReadyEvent,
CellClickedEvent,
CellKeyDownEvent,
CellClassParams,
IMenuActionParams,
IHeaderParams,

View File

@@ -51,9 +51,7 @@ export type JsonPrimitive = string | number | boolean | null;
* (Ref: https://github.com/microsoft/TypeScript/issues/15300).
*/
export type StrictJsonValue =
| JsonPrimitive
| StrictJsonObject
| StrictJsonArray;
JsonPrimitive | StrictJsonObject | StrictJsonArray;
export type StrictJsonArray = StrictJsonValue[];
/**
* More strict JSON objects that makes sure all values are plain objects.
@@ -79,12 +77,7 @@ export type Mode = RequestInit['mode'];
export type Redirect = RequestInit['redirect'];
export type ClientTimeout = number | undefined;
export type ParseMethod =
| 'json'
| 'json-bigint'
| 'text'
| 'raw'
| null
| undefined;
'json' | 'json-bigint' | 'text' | 'raw' | null | undefined;
export type Signal = RequestInit['signal'];
export type Stringify = boolean;
export type Url = string;

View File

@@ -18,12 +18,6 @@
*/
export type RowDataValue =
| string
| number
| boolean
| Date
| bigint
| null
| undefined;
string | number | boolean | Date | bigint | null | undefined;
export type RowData = Record<string, RowDataValue>;

View File

@@ -36,9 +36,7 @@ export interface ItemWithLoader<T> {
type InclusiveLoaderResult<V> = V | Promise<V>;
export type RegistryValue<V, W extends InclusiveLoaderResult<V>> =
| V
| W
| undefined;
V | W | undefined;
export type RegistryEntry<V, W extends InclusiveLoaderResult<V>> = {
key: string;

View File

@@ -59,8 +59,7 @@ export default async function handleError(error: ErrorInput): Promise<never> {
errorMessage = `${status} ${statusText}`;
try {
errorJson = (await error.json()) as
| SupersetApiErrorPayload
| SupersetApiMultiErrorsPayload;
SupersetApiErrorPayload | SupersetApiMultiErrorsPayload;
originalError = errorJson;
} catch (error_) {
originalError = error;

View File

@@ -46,10 +46,7 @@ interface TimeoutError {
}
type ErrorType =
| SupersetClientResponse
| TimeoutError
| { response: Response }
| string;
SupersetClientResponse | TimeoutError | { response: Response } | string;
type ErrorTextSource = 'dashboard' | 'chart' | 'query' | 'dataset' | 'database';
@@ -162,10 +159,7 @@ export function parseErrorJson(responseJson: JsonObject): ClientErrorObject {
export function getClientErrorObject(
response:
| SupersetClientResponse
| TimeoutError
| { response: Response }
| string,
SupersetClientResponse | TimeoutError | { response: Response } | string,
): Promise<ClientErrorObject> {
// takes a SupersetClientResponse as input, attempts to read response as Json
// if possible, and returns a Promise that resolves to a plain object with

View File

@@ -186,10 +186,7 @@ export function isFilterDivider(
export function isChartCustomization(
filterElement:
| Filter
| Divider
| ChartCustomization
| ChartCustomizationDivider,
Filter | Divider | ChartCustomization | ChartCustomizationDivider,
): filterElement is ChartCustomization {
return filterElement.type === ChartCustomizationType.ChartCustomization;
}
@@ -204,10 +201,7 @@ export type FilterConfiguration = Array<Filter | Divider>;
export type Filters = {
[filterId: string]:
| Filter
| Divider
| ChartCustomization
| ChartCustomizationDivider;
Filter | Divider | ChartCustomization | ChartCustomizationDivider;
};
export type PartialFilters = {
@@ -226,8 +220,7 @@ export type ChartCustomizationConfiguration = Array<
export type ChartCustomizations = {
[chartCustomizationId: string]:
| ChartCustomization
| ChartCustomizationDivider;
ChartCustomization | ChartCustomizationDivider;
};
export type PartialChartCustomizations = {

View File

@@ -53,9 +53,7 @@ export type SetAdhocFilter = BaseSimpleAdhocFilter & {
};
export type SimpleAdhocFilter =
| UnaryAdhocFilter
| BinaryAdhocFilter
| SetAdhocFilter;
UnaryAdhocFilter | BinaryAdhocFilter | SetAdhocFilter;
export interface FreeFormAdhocFilter extends BaseAdhocFilter {
expressionType: 'SQL';

View File

@@ -21,12 +21,7 @@ import { Currency, Maybe, QueryFormMetric } from '../../types';
import { Column } from './Column';
export type Aggregate =
| 'AVG'
| 'COUNT'
| 'COUNT_DISTINCT'
| 'MAX'
| 'MIN'
| 'SUM';
'AVG' | 'COUNT' | 'COUNT_DISTINCT' | 'MAX' | 'MIN' | 'SUM';
export interface AdhocMetricBase {
hasCustomLabel?: boolean;

View File

@@ -74,8 +74,7 @@ interface _PostProcessingAggregation {
};
}
export type PostProcessingAggregation =
| _PostProcessingAggregation
| DefaultPostProcessing;
_PostProcessingAggregation | DefaultPostProcessing;
export type BoxPlotQueryObjectWhiskerType = 'tukey' | 'min/max' | 'percentile';
interface _PostProcessingBoxplot {
@@ -88,8 +87,7 @@ interface _PostProcessingBoxplot {
};
}
export type PostProcessingBoxplot =
| _PostProcessingBoxplot
| DefaultPostProcessing;
_PostProcessingBoxplot | DefaultPostProcessing;
interface _PostProcessingContribution {
operation: 'contribution';
@@ -101,8 +99,7 @@ interface _PostProcessingContribution {
};
}
export type PostProcessingContribution =
| _PostProcessingContribution
| DefaultPostProcessing;
_PostProcessingContribution | DefaultPostProcessing;
interface _PostProcessingPivot {
operation: 'pivot';
@@ -132,8 +129,7 @@ interface _PostProcessingProphet {
};
}
export type PostProcessingProphet =
| _PostProcessingProphet
| DefaultPostProcessing;
_PostProcessingProphet | DefaultPostProcessing;
interface _PostProcessingDiff {
operation: 'diff';
@@ -155,8 +151,7 @@ interface _PostProcessingRolling {
};
}
export type PostProcessingRolling =
| _PostProcessingRolling
| DefaultPostProcessing;
_PostProcessingRolling | DefaultPostProcessing;
interface _PostProcessingCum {
operation: 'cum';
@@ -177,8 +172,7 @@ export interface _PostProcessingCompare {
};
}
export type PostProcessingCompare =
| _PostProcessingCompare
| DefaultPostProcessing;
_PostProcessingCompare | DefaultPostProcessing;
interface _PostProcessingSort {
operation: 'sort';
@@ -199,8 +193,7 @@ interface _PostProcessingResample {
};
}
export type PostProcessingResample =
| _PostProcessingResample
| DefaultPostProcessing;
_PostProcessingResample | DefaultPostProcessing;
interface _PostProcessingRename {
operation: 'rename';
@@ -211,8 +204,7 @@ interface _PostProcessingRename {
};
}
export type PostProcessingRename =
| _PostProcessingRename
| DefaultPostProcessing;
_PostProcessingRename | DefaultPostProcessing;
interface _PostProcessingFlatten {
operation: 'flatten';
@@ -222,8 +214,7 @@ interface _PostProcessingFlatten {
};
}
export type PostProcessingFlatten =
| _PostProcessingFlatten
| DefaultPostProcessing;
_PostProcessingFlatten | DefaultPostProcessing;
interface _PostProcessingRank {
operation: 'rank';
@@ -245,8 +236,7 @@ interface _PostProcessingHistogram {
};
}
export type PostProcessingHistogram =
| _PostProcessingHistogram
| DefaultPostProcessing;
_PostProcessingHistogram | DefaultPostProcessing;
/**
* Parameters for chart data postprocessing.

View File

@@ -51,8 +51,7 @@ export type QueryFormColumn = PhysicalColumn | AdhocColumn;
* Format: [metric/column, is_ascending].
*/
export type QueryFormOrderBy =
| [QueryFormColumn | QueryFormMetric | {}, boolean]
| [];
[QueryFormColumn | QueryFormMetric | {}, boolean] | [];
export interface FormDataResidual {
[key: string]: any;

View File

@@ -30,14 +30,7 @@ export enum ComparisonTimeRangeType {
}
export type DateTimeGrainType =
| 'second'
| 'minute'
| 'hour'
| 'day'
| 'week'
| 'month'
| 'quarter'
| 'year';
'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'quarter' | 'year';
export type CustomRangeKey =
| 'sinceMode'

View File

@@ -95,6 +95,17 @@ describe('isProbablyHTML', () => {
expect(isProbablyHTML('price < $100')).toBe(false);
});
test('should return false for angle-bracketed data values (issue #34082)', () => {
// MySQL column values wrapped in angle brackets must not be treated as
// HTML, otherwise they get swallowed and appear truncated in SQL Lab
// and Table chart results
expect(isProbablyHTML('<Buddhist Blue Duck-No Giblets>')).toBe(false);
expect(isProbablyHTML('<Roasted Chicken-Whole>')).toBe(false);
expect(
isProbablyHTML('prefix <Buddhist Blue Duck-No Giblets> suffix'),
).toBe(false);
});
test('should return true for all known HTML tags', () => {
expect(isProbablyHTML('<section>Content</section>')).toBe(true);
expect(isProbablyHTML('<article>Content</article>')).toBe(true);
@@ -146,6 +157,12 @@ describe('safeHtmlSpan', () => {
const result = safeHtmlSpan(plainText);
expect(result).toEqual(plainText);
});
test('should return angle-bracketed data values untouched (issue #34082)', () => {
const dataValue = '<Buddhist Blue Duck-No Giblets>';
expect(safeHtmlSpan(dataValue)).toBe(dataValue);
expect(sanitizeHtmlIfNeeded(dataValue)).toBe(dataValue);
});
});
describe('removeHTMLTags', () => {

View File

@@ -30,8 +30,7 @@ const defaultMockLoadFormData = jest.fn(({ formData }: { formData: unknown }) =>
);
type MockLoadFormData =
| typeof defaultMockLoadFormData
| jest.Mock<Promise<unknown>, unknown[]>;
typeof defaultMockLoadFormData | jest.Mock<Promise<unknown>, unknown[]>;
let mockLoadFormData: MockLoadFormData = defaultMockLoadFormData;

View File

@@ -40,6 +40,7 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
CellKeyDownEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { t } from '@apache-superset/core/translation';
@@ -57,8 +58,10 @@ import { SearchOption, SortByItem } from '../types';
import getInitialSortState, { shouldSort } from '../utils/getInitialSortState';
import getInitialFilterModel from '../utils/getInitialFilterModel';
import reconcileColumnState from '../utils/reconcileColumnState';
import getColumnStateSignature from '../utils/getColumnStateSignature';
import { PAGE_SIZE_OPTIONS } from '../consts';
import { getCompleteFilterState } from '../utils/filterStateManager';
import { copyCellValueOnKeyDown } from '../utils/copyCellValue';
export interface AgGridState extends Partial<GridState> {
timestamp?: number;
@@ -235,6 +238,13 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
isSearchFocused.set(searchId, false);
}, [searchId]);
// Copy the focused cell's value on Ctrl/Cmd+C. Needed because cell text is
// no longer natively selectable (see enableCellTextSelection below) and the
// Enterprise clipboard module is not registered (#106389).
const handleCellKeyDown = useCallback((event: CellKeyDownEvent) => {
copyCellValueOnKeyDown(event);
}, []);
const onFilterTextBoxChanged = useCallback(
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
if (serverPagination) {
@@ -337,11 +347,11 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
timestamp: Date.now(),
};
const stateHash = JSON.stringify({
columnOrder: columnState.map(c => c.colId),
sorts: sortModel,
filters: filterModel,
});
const stateHash = getColumnStateSignature(
columnState,
sortModel,
filterModel,
);
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
@@ -514,13 +524,22 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
enableCellTextSelection
// Clicking a cell should select (focus) the cell rather than select
// its text content (#106389). enableCellTextSelection forces browser
// text selection on click, which suppresses the cell-focus behavior.
// Because the Enterprise clipboard module isn't registered, native
// text selection was the only way to copy a value, so onCellKeyDown
// (above) restores Ctrl/Cmd+C copy for the focused cell. Full
// multi-cell range selection still requires AG Grid Enterprise, which
// is not available in the Community build used here.
enableCellTextSelection={false}
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}

View File

@@ -44,3 +44,11 @@ export const FILTER_CONDITION_BODY_INDEX = {
} as const;
export const ROW_NUMBER_COL_ID = '__row_number__';
// Symbol key used to attach a row's basic (increase/decrease) color formatter
// to the row data object so it travels with the row through AG Grid client-side
// sorting (#105973). A Symbol guarantees the key can never collide with a real
// dataset column (even one literally named "__basicColorFormatters__") and is
// automatically excluded from Object.keys, JSON serialization and spreads, so
// it never leaks into exports or cross-filters.
export const BASIC_COLOR_FORMATTERS_ROW_KEY = Symbol('basicColorFormatters');

View File

@@ -80,8 +80,7 @@ function getQueryMode(controls: ControlStateMapping): QueryMode {
return mode as QueryMode;
}
const rawColumns = controls?.all_columns?.value as
| QueryFormColumn[]
| undefined;
QueryFormColumn[] | undefined;
const hasRawColumns = rawColumns && rawColumns.length > 0;
return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate;
}

View File

@@ -24,6 +24,7 @@ import {
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
import { useIsDark } from '../utils/useTableTheme';
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
const StyledTotalCell = styled.div`
${() => `
@@ -163,13 +164,13 @@ export const NumericCellRenderer = (
let arrow = '';
let arrowColor = '';
if (hasBasicColorFormatters && col?.metricName) {
arrow =
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
?.mainArrow;
arrowColor =
basicColorFormatters?.[node?.rowIndex as number]?.[
col.metricName
]?.arrowColor?.toLowerCase();
const rowFormatter = getRowBasicColorFormatter(
node,
node?.rowIndex,
basicColorFormatters,
)?.[col.metricName];
arrow = rowFormatter?.mainArrow ?? '';
arrowColor = rowFormatter?.arrowColor?.toLowerCase() ?? '';
}
const alignment =

View File

@@ -46,6 +46,7 @@ import {
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
TableChartProps,
@@ -703,6 +704,24 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
// Attach each row's basic (increase/decrease) color formatter to the row data
// object so it travels with the row through AG Grid client-side sorting.
// basicColorFormatters is built in the original query order and was previously
// read positionally by the displayed rowIndex, which applied colors to the
// wrong rows once the table was sorted (#105973). The key is a Symbol so it
// can never collide with a real dataset column and never leaks into exports,
// cross-filters or spreads.
if (basicColorFormatters) {
passedData.forEach((row, index) => {
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: basicColorFormatters[index],
enumerable: false,
configurable: true,
writable: true,
});
});
}
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];
@@ -753,8 +772,7 @@ const transformProps = (
// Strip saved filter from chartState after initial application to prevent re-injection
let chartState = serverPaginationData?.chartState as
| AgGridChartState
| undefined;
AgGridChartState | undefined;
const chartStateHasFilter = !!(
chartState?.filterModel && Object.keys(chartState.filterModel).length > 0
);

View File

@@ -189,10 +189,7 @@ export interface InputColumn {
isPercentMetric: boolean;
config: TableColumnConfig;
formatter?:
| TimeFormatter
| NumberFormatter
| CustomFormatter
| CurrencyFormatter;
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
originalLabel?: string;
metricName?: string;
}

View File

@@ -0,0 +1,156 @@
/**
* 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.
*/
/**
* The Interactive Table selects the cell (not its text) on click (#106389).
* Because only AG Grid Community modules are registered, the Enterprise
* clipboard module is unavailable, so the browser's native text-selection was
* previously the *only* way to copy a cell value. To keep copy-a-value working,
* we provide a small Ctrl/Cmd+C handler that copies the focused cell's value.
*/
/**
* Keyboard fields we read to detect a copy shortcut. AG Grid types the event as
* the DOM `Event`, so we accept that and read the keyboard fields optionally
* (at runtime a cell key-down carries a `KeyboardEvent`).
*/
export type CopyKeyboardEvent = Partial<
Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey'>
> &
Partial<Event>;
/**
* Minimal shape of the AG Grid `CellKeyDownEvent` we rely on. The event carries
* no pre-formatted value, so we re-run the column's `valueFormatter` ourselves.
*/
export interface CopyableCellParams {
event?: CopyKeyboardEvent | null;
value?: unknown;
colDef?: {
valueFormatter?: unknown;
} | null;
// Forwarded to the valueFormatter; Superset's reads `value` and `node`.
node?: unknown;
column?: unknown;
data?: unknown;
api?: unknown;
context?: unknown;
}
/** True when the event is Ctrl+C (Win/Linux) or Cmd+C (macOS). */
export function isCopyShortcut(event?: CopyKeyboardEvent | null): boolean {
if (!event) {
return false;
}
const key = (event.key ?? '').toLowerCase();
return (event.ctrlKey === true || event.metaKey === true) && key === 'c';
}
/**
* Resolves the text to copy so it matches what the user sees: runs the column's
* `valueFormatter` (as the grid does when painting the cell) to copy the
* displayed value (`2,871`, `$2.87K`, `N/A`, ...). Falls back to the raw value
* when there is no formatter or it throws; null/undefined copy as empty string.
*/
export function getCellCopyText(params?: CopyableCellParams): string {
if (!params) {
return '';
}
const { valueFormatter } = params.colDef ?? {};
if (typeof valueFormatter === 'function') {
try {
const formatted = valueFormatter({
value: params.value,
node: params.node,
column: params.column,
colDef: params.colDef,
data: params.data,
api: params.api,
context: params.context,
});
if (formatted !== null && formatted !== undefined) {
return String(formatted);
}
} catch {
// Fall through to the raw value below.
}
}
const { value } = params;
if (value === null || value === undefined) {
return '';
}
return String(value);
}
/**
* Writes text to the clipboard, using the async Clipboard API when available
* and falling back to a hidden textarea + execCommand for non-secure contexts.
* Returns whether the write is believed to have succeeded.
*/
export async function writeTextToClipboard(text: string): Promise<boolean> {
try {
if (
typeof navigator !== 'undefined' &&
navigator.clipboard &&
typeof navigator.clipboard.writeText === 'function'
) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// Fall through to the legacy fallback below.
}
if (typeof document === 'undefined') {
return false;
}
try {
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.setAttribute('readonly', '');
textarea.style.position = 'fixed';
textarea.style.top = '-1000px';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
const succeeded = document.execCommand('copy');
document.body.removeChild(textarea);
return succeeded;
} catch {
return false;
}
}
/**
* AG Grid `onCellKeyDown` handler: copies the focused cell's value on Ctrl/Cmd+C.
* Returns true when the copy shortcut was handled, false otherwise (so the event
* is left untouched for any other key).
*/
export function copyCellValueOnKeyDown(params?: CopyableCellParams): boolean {
if (!isCopyShortcut(params?.event)) {
return false;
}
const text = getCellCopyText(params);
// Fire and forget; writeTextToClipboard already swallows its own errors.
writeTextToClipboard(text).catch(() => {});
return true;
}

View File

@@ -24,6 +24,7 @@ import {
} from '@superset-ui/chart-controls';
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn } from '../types';
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
type CellStyleParams = CellClassParams & {
hasColumnColorFormatters: boolean | undefined;
@@ -84,8 +85,11 @@ const getCellStyle = (params: CellStyleParams) => {
col?.metricName &&
node?.rowPinned !== 'bottom'
) {
backgroundColor =
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
backgroundColor = getRowBasicColorFormatter(
node,
rowIndex,
basicColorFormatters,
)?.[col.metricName]?.backgroundColor;
}
const textAlign =

View File

@@ -0,0 +1,51 @@
/**
* 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 type { ColumnState } from '@superset-ui/core/components/ThemedAgGridReact';
type SortModelEntry = {
colId: string;
sort: 'asc' | 'desc';
sortIndex: number;
};
/**
* Stable signature of the persisted parts of AG Grid column state (order,
* value aggregation, sorts, filters), used to detect changes worth saving.
* `aggFunc` is normalized so the "None" option (`null`/`undefined`) produces
* a distinct signature instead of reverting to the default on reload.
*/
export default function getColumnStateSignature(
columnState: ColumnState[],
sortModel: SortModelEntry[],
filterModel: Record<string, unknown>,
): string {
return JSON.stringify({
columnOrder: columnState.map(col => col.colId),
aggregations: columnState.map(col => ({
colId: col.colId,
// "None" (null/undefined) maps to an explicit sentinel; functions map
// to a fixed marker so the signature never depends on JSON.stringify
// dropping them (defensive: persisted aggFuncs are always strings).
aggFunc:
typeof col.aggFunc === 'function' ? 'custom' : (col.aggFunc ?? null),
})),
sorts: sortModel,
filters: filterModel,
});
}

View File

@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
import { BasicColorFormatterType } from '../types';
export type RowFormatters = { [key: string]: BasicColorFormatterType };
/**
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
* row node.
*
* The formatter is attached to the row data object itself (see transformProps),
* so it follows the row through client-side sorting. Looking it up positionally
* by the displayed `rowIndex` was wrong once the user sorted the table, because
* the displayed index no longer matched the original data order (#105973).
*
* Falls back to the positional array for safety when no attached formatter is
* present.
*/
export default function getRowBasicColorFormatter(
node: { data?: Record<string | symbol, unknown> } | undefined,
rowIndex: number | null | undefined,
basicColorFormatters: RowFormatters[] | undefined,
): RowFormatters | undefined {
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
RowFormatters | undefined;
if (attached) {
return attached;
}
if (rowIndex == null) {
return undefined;
}
return basicColorFormatters?.[rowIndex];
}

View File

@@ -84,3 +84,20 @@ test('drops stale order when a dynamic group by swaps the dimension column', ()
],
});
});
test('preserves per-column aggFunc, including explicit "None" (null), through reconciliation', () => {
// Regression #107166: saved aggFunc must reach applyColumnState untouched.
const colDefs: ColDef[] = [{ field: 'name' }, { field: 'SUM(Sales_Amount)' }];
const savedColumnState: ColumnState[] = [
{ colId: 'name' },
{ colId: 'SUM(Sales_Amount)', aggFunc: null },
];
expect(reconcileColumnState(savedColumnState, colDefs)).toEqual({
applyOrder: true,
columnState: [
{ colId: 'name' },
{ colId: 'SUM(Sales_Amount)', aggFunc: null },
],
});
});

View File

@@ -20,7 +20,10 @@ import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@superset-ui/core/spec';
import { QueryMode, TimeGranularity, SMART_DATE_ID } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import {
setupAGGridModules,
type ColumnState,
} from '@superset-ui/core/components/ThemedAgGridReact';
import AgGridTableChart from '../src/AgGridTableChart';
import transformProps from '../src/transformProps';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
@@ -726,3 +729,46 @@ test('AgGridTableChart pins no summary row when totals are absent', async () =>
const pinnedRows = document.querySelectorAll('.ag-floating-bottom .ag-row');
expect(pinnedRows.length).toBe(0);
});
test('AgGridTableChart emits column state with aggFunc through the debounced save path', async () => {
const props = transformProps(testData.basic);
const onChartStateChange = jest.fn();
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...props}
setDataMask={mockSetDataMask}
slice_id={1}
onChartStateChange={onChartStateChange}
chartState={{
columnState: [{ colId: 'sum__num', aggFunc: null }],
sortModel: [],
filterModel: {},
}}
/>
),
}),
);
await waitFor(() => {
expect(document.querySelector('.ag-container')).toBeInTheDocument();
});
// The save path is debounced (SLOW_DEBOUNCE = 500ms); wait for a capture.
await waitFor(() => expect(onChartStateChange).toHaveBeenCalled(), {
timeout: 3000,
});
const savedState =
onChartStateChange.mock.calls[onChartStateChange.mock.calls.length - 1][0];
const savedColumn = (savedState.columnState as ColumnState[]).find(
col => col.colId === 'sum__num',
);
// Entries must carry an aggFunc key so "None" survives reload. The value
// itself is not assertable here: aggregation state needs an enterprise
// (SharedAggregation) module; the community modules always report null.
expect(savedColumn).toMatchObject({ aggFunc: null });
});

View File

@@ -0,0 +1,99 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render, waitFor } from '@superset-ui/core/spec';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
import testData from '../../plugin-chart-table/test/testData';
// Capture the props the grid is rendered with, so we can assert the
// cell-selection wiring without depending on AG Grid's DOM rendering.
const captured: { props?: Record<string, any> } = {};
// Mock the narrow ThemedAgGridReact module (which the components barrel
// re-exports) rather than the whole barrel, to avoid its circular-init.
jest.mock('@superset-ui/core/components/ThemedAgGridReact', () => ({
__esModule: true,
ThemedAgGridReact: (props: Record<string, any>) => {
captured.props = props;
return null;
},
AgGridReact: function AgGridReact() {
return null;
},
AllCommunityModule: {},
ClientSideRowModelModule: {},
ModuleRegistry: { registerModules: () => undefined },
setupAGGridModules: () => undefined,
defaultModules: [],
themeQuartz: {},
colorSchemeDark: {},
colorSchemeLight: {},
}));
// Imported after the mock is declared (jest.mock is hoisted above imports).
// eslint-disable-next-line import/first
import AgGridTableChart from '../src/AgGridTableChart';
// eslint-disable-next-line import/first
import transformProps from '../src/transformProps';
function renderChart() {
captured.props = undefined;
render(
ProviderWrapper({
children: (
<AgGridTableChart
{...transformProps(testData.basic)}
setDataMask={jest.fn()}
slice_id={1}
/>
),
}),
);
}
test('interactive table selects the cell on click (text selection disabled) and wires a copy handler', async () => {
renderChart();
await waitFor(() => expect(captured.props).toBeDefined());
// #106389: clicking selects the cell, not its text.
expect(captured.props?.enableCellTextSelection).toBe(false);
// A key-down handler must be wired to restore copy-a-value.
expect(typeof captured.props?.onCellKeyDown).toBe('function');
});
test('the wired onCellKeyDown copies the focused cell value on Ctrl/Cmd+C', async () => {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
writable: true,
});
renderChart();
await waitFor(() => expect(captured.props?.onCellKeyDown).toBeDefined());
// Shape mirrors AG Grid's real CellKeyDownEvent: no pre-formatted value; the
// displayed text is produced by the column's valueFormatter.
captured.props?.onCellKeyDown({
event: { key: 'c', metaKey: true },
value: 2871,
colDef: { valueFormatter: () => '2,871' },
});
expect(writeText).toHaveBeenCalledWith('2,871');
});

View File

@@ -47,8 +47,7 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
};
const findMetricsMapStateToProps = ():
| ControlConfig['mapStateToProps']
| null => {
ControlConfig['mapStateToProps'] | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {

View File

@@ -0,0 +1,203 @@
/**
* 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 {
isCopyShortcut,
getCellCopyText,
writeTextToClipboard,
copyCellValueOnKeyDown,
} from '../../src/utils/copyCellValue';
const originalClipboard = navigator.clipboard;
afterEach(() => {
Object.defineProperty(navigator, 'clipboard', {
value: originalClipboard,
configurable: true,
writable: true,
});
jest.restoreAllMocks();
});
function mockClipboard(): jest.Mock {
const writeText = jest.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, 'clipboard', {
value: { writeText },
configurable: true,
writable: true,
});
return writeText;
}
// --- isCopyShortcut -------------------------------------------------------
test('isCopyShortcut detects Ctrl+C and Cmd+C', () => {
expect(isCopyShortcut({ key: 'c', ctrlKey: true })).toBe(true);
expect(isCopyShortcut({ key: 'c', metaKey: true })).toBe(true);
expect(isCopyShortcut({ key: 'C', metaKey: true })).toBe(true); // capitalized
});
test('isCopyShortcut ignores other keys and bare C', () => {
expect(isCopyShortcut({ key: 'c' })).toBe(false); // no modifier
expect(isCopyShortcut({ key: 'v', ctrlKey: true })).toBe(false); // paste
expect(isCopyShortcut({ key: 'a', metaKey: true })).toBe(false); // select all
expect(isCopyShortcut(null)).toBe(false);
expect(isCopyShortcut(undefined)).toBe(false);
});
// --- getCellCopyText ------------------------------------------------------
test('getCellCopyText returns what the grid displays via colDef.valueFormatter', () => {
// The real CellKeyDownEvent carries no `valueFormatted`; the displayed text
// must be derived by invoking the column's valueFormatter, exactly as the
// grid does when it paints the cell (useColDefs.ts).
const params = {
value: 2871,
colDef: { valueFormatter: () => '2,871' },
};
expect(getCellCopyText(params)).toBe('2,871');
});
test('getCellCopyText passes the cell value/node to the valueFormatter', () => {
// Superset's valueFormatter reads `value` (and `node`) off its params, so the
// handler must forward them — otherwise currency/percent/date columns copy the
// raw value instead of the formatted one.
const valueFormatter = jest.fn(
(p: { value: unknown }) => `#${String(p.value)}`,
);
const node = { level: 0 };
expect(getCellCopyText({ value: 42, colDef: { valueFormatter }, node })).toBe(
'#42',
);
expect(valueFormatter).toHaveBeenCalledWith(
expect.objectContaining({ value: 42, node }),
);
});
test('getCellCopyText copies the "N/A" the grid shows for empty cells', () => {
// Superset's valueFormatter renders empty/undefined cells as "N/A"; copying an
// empty string would not match what the user sees.
const valueFormatter = (p: { value: unknown }) =>
p.value === undefined || p.value === '' ? 'N/A' : String(p.value);
expect(
getCellCopyText({ value: undefined, colDef: { valueFormatter } }),
).toBe('N/A');
});
test('getCellCopyText falls back to the raw value when no formatter is present', () => {
expect(getCellCopyText({ value: 2871 })).toBe('2871');
expect(getCellCopyText({ value: 'Paris' })).toBe('Paris');
expect(getCellCopyText({ value: 'Paris', colDef: {} })).toBe('Paris');
});
test('getCellCopyText copies empty string for null/undefined values without a formatter', () => {
expect(getCellCopyText({ value: null })).toBe('');
expect(getCellCopyText({ value: undefined })).toBe('');
expect(getCellCopyText({})).toBe('');
expect(getCellCopyText(undefined)).toBe('');
});
test('getCellCopyText falls back to the raw value if the formatter throws', () => {
const valueFormatter = () => {
throw new Error('boom');
};
expect(getCellCopyText({ value: 7, colDef: { valueFormatter } })).toBe('7');
});
// --- writeTextToClipboard -------------------------------------------------
test('writeTextToClipboard uses the async Clipboard API when available', async () => {
const writeText = mockClipboard();
await expect(writeTextToClipboard('hello')).resolves.toBe(true);
expect(writeText).toHaveBeenCalledWith('hello');
});
test('writeTextToClipboard falls back to execCommand when Clipboard API is missing', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
writable: true,
});
const execCommand = jest.fn().mockReturnValue(true);
// jsdom does not implement execCommand.
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
await expect(writeTextToClipboard('fallback')).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith('copy');
});
test('writeTextToClipboard falls back to execCommand when the Clipboard API rejects', async () => {
// e.g. clipboard permission denied — must still copy via the legacy path.
Object.defineProperty(navigator, 'clipboard', {
value: { writeText: jest.fn().mockRejectedValue(new Error('denied')) },
configurable: true,
writable: true,
});
const execCommand = jest.fn().mockReturnValue(true);
(document as unknown as { execCommand: unknown }).execCommand = execCommand;
await expect(writeTextToClipboard('rejected')).resolves.toBe(true);
expect(execCommand).toHaveBeenCalledWith('copy');
});
test('writeTextToClipboard returns false when both clipboard paths fail', async () => {
Object.defineProperty(navigator, 'clipboard', {
value: undefined,
configurable: true,
writable: true,
});
(document as unknown as { execCommand: unknown }).execCommand = jest.fn(
() => {
throw new Error('execCommand unsupported');
},
);
await expect(writeTextToClipboard('nope')).resolves.toBe(false);
});
// --- copyCellValueOnKeyDown (the onCellKeyDown handler) -------------------
test('copyCellValueOnKeyDown copies the displayed cell value on Ctrl/Cmd+C', () => {
const writeText = mockClipboard();
// Realistic CellKeyDownEvent: no `valueFormatted`; the displayed text comes
// from the column's valueFormatter.
const handled = copyCellValueOnKeyDown({
event: { key: 'c', metaKey: true },
value: 2871,
colDef: { valueFormatter: () => '2,871' },
});
expect(handled).toBe(true);
expect(writeText).toHaveBeenCalledWith('2,871');
});
test('copyCellValueOnKeyDown ignores non-copy keystrokes', () => {
const writeText = mockClipboard();
const handled = copyCellValueOnKeyDown({
event: { key: 'v', ctrlKey: true },
value: 'Paris',
});
expect(handled).toBe(false);
expect(writeText).not.toHaveBeenCalled();
});
test('copyCellValueOnKeyDown handles a missing event without throwing', () => {
const writeText = mockClipboard();
expect(copyCellValueOnKeyDown(undefined)).toBe(false);
expect(copyCellValueOnKeyDown({ value: 1 })).toBe(false);
expect(writeText).not.toHaveBeenCalled();
});

View File

@@ -0,0 +1,82 @@
/**
* 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 type { ColumnState } from '@superset-ui/core/components/ThemedAgGridReact';
import getColumnStateSignature from '../../src/utils/getColumnStateSignature';
const filterModel = {};
test('signature changes when a column value aggregation changes', () => {
const before = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }],
[],
filterModel,
);
const after = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'avg' }],
[],
filterModel,
);
expect(after).not.toEqual(before);
});
test('switching value aggregation to "None" (null/undefined) changes the signature', () => {
// Regression #107166: "None" must be captured so it persists on reload.
const sumState = getColumnStateSignature(
[{ colId: 'sales', aggFunc: 'sum' }],
[],
filterModel,
);
const noneNull = getColumnStateSignature(
[{ colId: 'sales', aggFunc: null }],
[],
filterModel,
);
const noneUndefined = getColumnStateSignature(
[{ colId: 'sales' }],
[],
filterModel,
);
expect(noneNull).not.toEqual(sumState);
expect(noneUndefined).not.toEqual(sumState);
// null and undefined both represent "None" and must be treated identically.
expect(noneNull).toEqual(noneUndefined);
});
test('signature is stable when nothing changes', () => {
const columnState: ColumnState[] = [{ colId: 'sales', aggFunc: 'sum' }];
const a = getColumnStateSignature(columnState, [], filterModel);
const b = getColumnStateSignature([...columnState], [], filterModel);
expect(a).toEqual(b);
});
test('a function aggFunc is distinguishable from "None"', () => {
// Pins the defensive marker: function aggFuncs stay distinct from "None".
const customAgg = getColumnStateSignature(
[{ colId: 'sales', aggFunc: () => 42 }],
[],
filterModel,
);
const none = getColumnStateSignature(
[{ colId: 'sales', aggFunc: null }],
[],
filterModel,
);
expect(customAgg).not.toEqual(none);
});

View File

@@ -0,0 +1,81 @@
/**
* 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 getRowBasicColorFormatter, {
RowFormatters,
} from '../../src/utils/getRowBasicColorFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
const red: RowFormatters = {
sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' },
};
const green: RowFormatters = {
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
};
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
const positional: RowFormatters[] = [green, red];
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
// After sorting, the row whose original formatter is `red` is displayed first
// (rowIndex 0). The positional lookup would wrongly return `green`.
const rowData: Record<string | symbol, unknown> = { sales: 5 };
rowData[BASIC_COLOR_FORMATTERS_ROW_KEY] = red;
const node = { data: rowData };
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
expect(
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
).toBe('red');
});
test('falls back to positional lookup when no formatter is attached', () => {
const node = { data: { sales: 5 } };
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
});
test('returns undefined when nothing matches', () => {
expect(
getRowBasicColorFormatter(undefined, null, positional),
).toBeUndefined();
expect(
getRowBasicColorFormatter({ data: {} }, null, positional),
).toBeUndefined();
});
test('attached formatter is a Symbol so it does not leak into the row', () => {
const rowData: Record<string | symbol, unknown> = { sales: 5 };
rowData[BASIC_COLOR_FORMATTERS_ROW_KEY] = green;
// Symbol keys are excluded from Object.keys, JSON serialization and spreads.
expect(Object.keys(rowData)).toEqual(['sales']);
expect(JSON.stringify(rowData)).toBe('{"sales":5}');
});
test('does not collide with a real column named "__basicColorFormatters__"', () => {
// The old string key would have overwritten this user column; the Symbol key
// leaves it untouched while still resolving the attached formatter (#105973).
const rowData: Record<string | symbol, unknown> = {
sales: 5,
__basicColorFormatters__: 'user data',
};
rowData[BASIC_COLOR_FORMATTERS_ROW_KEY] = red;
const node = { data: rowData };
expect(node.data.__basicColorFormatters__).toBe('user data');
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
});

View File

@@ -42,11 +42,7 @@ export type BoxPlotFormDataWhiskerOptions =
| '10/90 percentiles';
export type BoxPlotFormXTickLayout =
| '45°'
| '90°'
| 'auto'
| 'flat'
| 'staggered';
'45°' | '90°' | 'auto' | 'flat' | 'staggered';
// @ts-expect-error
export const DEFAULT_FORM_DATA: BoxPlotQueryFormData = {

View File

@@ -221,8 +221,7 @@ const config: ControlPanelConfig = {
return {
queryResponse: chart?.queriesResponse?.[0] as
| ChartDataResponseResult
| undefined,
ChartDataResponseResult | undefined,
appliedColumnNames: metricColumn,
columnsPropsObject: { colnames, coltypes },
};

View File

@@ -521,8 +521,7 @@ export function transformIntervalAnnotation(
});
const allIntervalData: (
| MarkArea1DDataItemOption
| MarkArea2DDataItemOption
MarkArea1DDataItemOption | MarkArea2DDataItemOption
)[] = annotations.map(annotation => {
const { intervalEnd, time = '' } = annotation;
const combinedLabel = (intervalsByStartTime.get(time) || []).join('\n');

View File

@@ -29,11 +29,7 @@ import type { CallbackDataParams } from 'echarts/types/src/util/types';
import { BaseTransformedProps, LegendFormData } from '../types';
export type WaterfallFormXTicksLayout =
| '45°'
| '90°'
| 'auto'
| 'flat'
| 'staggered';
'45°' | '90°' | 'auto' | 'flat' | 'staggered';
export type ISeriesData = {
originalValue?: number;

View File

@@ -21,11 +21,7 @@ import { LegendOrientation, LegendType } from '../types';
import { getLegendLayoutResult, LegendLayoutResult } from './series';
type LegendDataItem =
| string
| number
| null
| undefined
| { name?: string | number | null };
string | number | null | undefined | { name?: string | number | null };
export type ResolvedLegendLayout = {
effectiveLegendMargin?: string | number | null;

View File

@@ -46,9 +46,7 @@ import {
*/
export class EChartOptionsParseError extends Error {
public readonly errorType:
| 'parse_error'
| 'security_error'
| 'validation_error';
'parse_error' | 'security_error' | 'validation_error';
public readonly validationErrors: string[];
@@ -57,9 +55,7 @@ export class EChartOptionsParseError extends Error {
constructor(
message: string,
errorType:
| 'parse_error'
| 'security_error'
| 'validation_error' = 'parse_error',
'parse_error' | 'security_error' | 'validation_error' = 'parse_error',
validationErrors: string[] = [],
location?: { line: number; column: number },
) {

View File

@@ -73,11 +73,7 @@ const ESTIMATED_LEGEND_SELECTOR_WIDTH = 112;
const LEGEND_TEXT_WIDTH_CACHE = new Map<string, number>();
type LegendDataItem =
| string
| number
| null
| undefined
| { name?: string | number | null };
string | number | null | undefined | { name?: string | number | null };
export type LegendLayoutResult = {
effectiveMargin?: number;

View File

@@ -938,6 +938,46 @@ describe('legend sorting', () => {
});
});
test('honors user-selected plain legend type for top orientation when space allows (#39540)', () => {
// Regression test for issue #39540: switching the legend type control from
// scroll to plain must reach the rendered ECharts config. Horizontal legends
// were once unconditionally forced to scroll; scroll should be a fallback
// reserved for legends that do not fit the available space.
const chartProps = createTestChartProps({
formData: {
...formData,
legendType: LegendType.Plain,
legendOrientation: LegendOrientation.Top,
showLegend: true,
},
});
const { legend } = transformProps(chartProps).echartOptions as {
legend: { show?: boolean; type?: LegendType };
};
expect(legend.show).toBe(true);
expect(legend.type).toBe(LegendType.Plain);
});
test('honors user-selected plain legend type for bottom orientation when space allows (#39540)', () => {
const chartProps = createTestChartProps({
formData: {
...formData,
legendType: LegendType.Plain,
legendOrientation: LegendOrientation.Bottom,
showLegend: true,
},
});
const { legend } = transformProps(chartProps).echartOptions as {
legend: { show?: boolean; type?: LegendType };
};
expect(legend.show).toBe(true);
expect(legend.type).toBe(LegendType.Plain);
});
const timeCompareFormData: SqlaFormData = {
colorScheme: 'bnbColors',
datasource: '3__table',
@@ -968,11 +1008,9 @@ test('should apply dashed line style to time comparison series with single metri
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const mainSeries = series.find(s => s.name === 'sum__num') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
(SeriesOption & { lineStyle?: { type?: number[] | string } }) | undefined;
const comparisonSeries = series.find(s => s.name === '1 week ago') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
(SeriesOption & { lineStyle?: { type?: number[] | string } }) | undefined;
expect(mainSeries).toBeDefined();
expect(comparisonSeries).toBeDefined();
@@ -1013,13 +1051,11 @@ test('should apply dashed line style to time comparison series with metric__offs
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const mainSeries = series.find(s => s.name === 'sum__num') as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
(SeriesOption & { lineStyle?: { type?: number[] | string } }) | undefined;
const comparisonSeries = series.find(
s => s.name === 'sum__num__1 week ago',
) as
| (SeriesOption & { lineStyle?: { type?: number[] | string } })
| undefined;
(SeriesOption & { lineStyle?: { type?: number[] | string } }) | undefined;
expect(mainSeries).toBeDefined();
expect(comparisonSeries).toBeDefined();
@@ -1051,8 +1087,7 @@ test('should apply connectNulls to time comparison series', () => {
const series = (transformed.echartOptions.series as SeriesOption[]) || [];
const comparisonSeries = series.find(s => s.name === '1 week ago') as
| (SeriesOption & { connectNulls?: boolean })
| undefined;
(SeriesOption & { connectNulls?: boolean }) | undefined;
expect(comparisonSeries).toBeDefined();
expect(comparisonSeries?.connectNulls).toBe(true);

View File

@@ -67,11 +67,7 @@ const {
chartHeight: number;
chartWidth: number;
legendItems?: (
| string
| number
| null
| undefined
| { name?: string | number | null }
string | number | null | undefined | { name?: string | number | null }
)[];
legendMargin?: string | number | null;
orientation: LegendOrientation;
@@ -94,11 +90,7 @@ const {
chartHeight: number;
chartWidth: number;
legendItems?: (
| string
| number
| null
| undefined
| { name?: string | number | null }
string | number | null | undefined | { name?: string | number | null }
)[];
legendMargin?: string | number | null;
orientation: LegendOrientation;
@@ -1001,6 +993,62 @@ test('getLegendLayoutResult keeps plain horizontal legends when they fit within
});
});
test('getLegendLayoutResult honors user-selected plain type for many horizontal legend items given ample width (#39540)', () => {
// Regression contract for issue #39540: a legend with enough items to be
// scrollable must still honor a user-selected plain type when the chart is
// wide enough, instead of being unconditionally forced to scroll.
const layout = getLegendLayoutResult({
chartHeight: 600,
chartWidth: 1600,
legendItems: Array.from({ length: 10 }, (_, i) => `Series ${i + 1}`),
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Plain,
});
expect(layout.effectiveType).toBe(LegendType.Plain);
expect(layout.effectiveMargin).toBeGreaterThanOrEqual(
defaultLegendPadding[LegendOrientation.Top],
);
});
test('getLegendLayoutResult keeps user-selected plain type for bottom-oriented legends when space allows', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 800,
legendItems: ['Alpha', 'Beta', 'Gamma', 'Delta'],
legendMargin: null,
orientation: LegendOrientation.Bottom,
show: true,
theme,
type: LegendType.Plain,
}),
).toEqual({
effectiveMargin: defaultLegendPadding[LegendOrientation.Bottom],
effectiveType: LegendType.Plain,
});
});
test('getLegendLayoutResult passes a user-selected scroll type through untouched', () => {
expect(
getLegendLayoutResult({
chartHeight: 400,
chartWidth: 800,
legendItems: ['Alpha', 'Beta'],
legendMargin: null,
orientation: LegendOrientation.Top,
show: true,
theme,
type: LegendType.Scroll,
}),
).toEqual({
effectiveType: LegendType.Scroll,
});
});
test('getLegendLayoutResult adds extra margin for wrapped plain horizontal legends', () => {
const layout = getLegendLayoutResult({
chartHeight: 400,

View File

@@ -29,8 +29,7 @@ export function getQueryMode(controls: ControlStateMapping): QueryMode {
return mode as QueryMode;
}
const rawColumns = controls?.all_columns?.value as
| QueryFormColumn[]
| undefined;
QueryFormColumn[] | undefined;
const hasRawColumns = rawColumns && rawColumns.length > 0;
return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate;
}

View File

@@ -900,8 +900,7 @@ class PivotData {
props: Record<string, unknown>;
aggregator: (...args: unknown[]) => Aggregator;
formattedAggregators:
| Record<string, Record<string, (...args: unknown[]) => Aggregator>>
| false;
Record<string, Record<string, (...args: unknown[]) => Aggregator>> | false;
tree: Record<string, Record<string, Aggregator>>;
rowKeys: string[][];
colKeys: string[][];

View File

@@ -42,9 +42,7 @@ export type FilterType = Record<string, DataRecordValue>;
export type SelectedFiltersType = Record<string, DataRecordValue[]>;
export type DateFormatter =
| TimeFormatter
| NumberFormatter
| ((value: DataRecordValue) => string);
TimeFormatter | NumberFormatter | ((value: DataRecordValue) => string);
export enum MetricsLayoutEnum {
ROWS = 'ROWS',
COLUMNS = 'COLUMNS',

View File

@@ -1135,13 +1135,15 @@ export default function TableChart<D extends DataRecord = DataRecord>(
text-align: ${sharedStyle.textAlign};
white-space: ${value instanceof Date ? 'nowrap' : undefined};
position: relative;
font-weight: ${color
? `${theme.fontWeightBold}`
: `${theme.fontWeightNormal}`};
font-weight: ${
color ? `${theme.fontWeightBold}` : `${theme.fontWeightNormal}`
};
background: ${backgroundColor || undefined};
padding-left: ${column.isChildColumn
? `${theme.sizeUnit * 5}px`
: `${theme.sizeUnit}px`};
padding-left: ${
column.isChildColumn
? `${theme.sizeUnit * 5}px`
: `${theme.sizeUnit}px`
};
`;
const cellBarStyles = css`
@@ -1149,10 +1151,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
height: 100%;
display: block;
top: 0;
${valueRange &&
typeof value === 'number' &&
valueRangeFlag &&
`
${
valueRange &&
typeof value === 'number' &&
valueRangeFlag &&
`
width: ${`${cellWidth({
value: value as number,
valueRange,
@@ -1171,15 +1174,18 @@ export default function TableChart<D extends DataRecord = DataRecord>(
theme,
})
};
`}
`
}
`;
let arrowStyles = css`
color: ${basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError};
color: ${
basicColorFormatters &&
basicColorFormatters[row.index][originKey]?.arrowColor ===
ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
};
margin-right: ${theme.sizeUnit}px;
`;
@@ -1188,10 +1194,12 @@ export default function TableChart<D extends DataRecord = DataRecord>(
basicColorColumnFormatters?.length > 0
) {
arrowStyles = css`
color: ${basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError};
color: ${
basicColorColumnFormatters[row.index][column.key]
?.arrowColor === ColorSchemeEnum.Green
? theme.colorSuccess
: theme.colorError
};
margin-right: ${theme.sizeUnit}px;
`;
}

View File

@@ -227,7 +227,10 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData?.result_type === 'results');
if (isDownloadQuery) {
moreProps.row_limit = Number(formDataCopy.row_limit) || 0;
moreProps.row_limit =
formDataCopy.row_limit != null
? Number(formDataCopy.row_limit)
: undefined;
moreProps.row_offset = 0;
}

View File

@@ -66,8 +66,7 @@ function getQueryMode(controls: ControlStateMapping): QueryMode {
return mode as QueryMode;
}
const rawColumns = controls?.all_columns?.value as
| QueryFormColumn[]
| undefined;
QueryFormColumn[] | undefined;
const hasRawColumns = rawColumns && rawColumns.length > 0;
return hasRawColumns ? QueryMode.Raw : QueryMode.Aggregate;
}

View File

@@ -48,8 +48,7 @@ const findConditionalFormattingControl = (): ControlConfig | null => {
};
const findMetricsMapStateToProps = ():
| ControlConfig['mapStateToProps']
| null => {
ControlConfig['mapStateToProps'] | null => {
for (const section of config.controlPanelSections) {
if (!section) continue;
for (const row of section.controlSetRows) {

View File

@@ -17,7 +17,7 @@
* under the License.
*/
// eslint-disable-next-line import/no-extraneous-dependencies
import { render, screen } from '@testing-library/react';
import { createEvent, fireEvent, render, screen } from '@testing-library/react';
// eslint-disable-next-line import/no-extraneous-dependencies
import '@testing-library/jest-dom';
import { supersetTheme, ThemeProvider } from '@apache-superset/core/theme';
@@ -69,3 +69,58 @@ test('leaves labels untouched when no format is provided', () => {
expect(screen.getByText('[1, 81)')).toBeInTheDocument();
});
test('clicking a legend item toggles the category without triggering anchor navigation', () => {
// Regression proof for #33576: the legend items are href="#" anchors, so a
// click whose default action is not prevented would navigate to "#" and
// scroll the browser window to the top of the page.
const toggleCategory = jest.fn();
renderWithTheme(
<Legend
format={null}
categories={{
Positive: { enabled: true, color: [0, 255, 0] },
Negative: { enabled: true, color: [255, 0, 0] },
}}
toggleCategory={toggleCategory}
/>,
);
const legendItem = screen.getByRole('button', { name: 'Positive' });
const clickEvent = createEvent.click(legendItem);
fireEvent(legendItem, clickEvent);
expect(clickEvent.defaultPrevented).toBe(true);
expect(toggleCategory).toHaveBeenCalledTimes(1);
expect(toggleCategory).toHaveBeenCalledWith('Positive');
});
test('ctrl+clicking a legend item toggles the category without opening a new tab', () => {
// Regression proof for #34157: legend items are href="#" anchors, so a
// ctrl+click whose default action is not prevented would ask the browser
// to open the "#" href in a new tab instead of just toggling the layer.
const toggleCategory = jest.fn();
renderWithTheme(
<Legend
format={null}
categories={{
cat1: { enabled: true, color: [255, 0, 0] },
cat2: { enabled: false, color: [0, 0, 255] },
}}
toggleCategory={toggleCategory}
/>,
);
const legendItem = screen.getByRole('button', { name: 'cat1' });
const ctrlClickEvent = createEvent.click(legendItem, {
ctrlKey: true,
}) as MouseEvent;
fireEvent(legendItem, ctrlClickEvent);
// preventDefault() in the onClick handler is what stops the browser's
// native ctrl+click "open link in new tab" behavior on the anchor.
expect(ctrlClickEvent.defaultPrevented).toBe(true);
expect(ctrlClickEvent.ctrlKey).toBe(true);
expect(toggleCategory).toHaveBeenCalledTimes(1);
expect(toggleCategory).toHaveBeenCalledWith('cat1');
});

View File

@@ -36,8 +36,7 @@ export interface DeckPathFormData extends SqlaFormData {
tooltip_contents?: unknown[];
tooltip_template?: string;
line_width?:
| string
| { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
string | { type?: 'fix' | 'metric'; value?: QueryFormMetric | number };
line_width_multiplier?: number;
min_width?: number;
max_width?: number;

View File

@@ -529,8 +529,7 @@ export const mapProvider = {
options: getLabeledMapRendererOptions({
hasMapboxKey: hasKey,
currentValue: state.form_data?.map_renderer as
| MapProvider
| undefined,
MapProvider | undefined,
}),
default: getDefaultMapRenderer(),
};

View File

@@ -422,11 +422,7 @@ const getLineColumnFilters = ({
const obj = data.object ?? {};
const extraProps = (obj.extraProps ?? {}) as Record<string, unknown>;
const dimensionVal = (obj[col] ?? extraProps[col]) as
| string
| number
| boolean
| null
| undefined;
string | number | boolean | null | undefined;
if (dimensionVal == null) {
throw new Error(
@@ -484,11 +480,7 @@ const getGeojsonFilters = ({
unknown
>;
const dimensionVal = properties[col] as
| string
| number
| boolean
| null
| undefined;
string | number | boolean | null | undefined;
if (dimensionVal == null) {
throw new Error(

View File

@@ -34,6 +34,12 @@ import {
import { SupersetClient, isFeatureEnabled } from '@superset-ui/core';
import { ADD_TOAST } from 'src/components/MessageToasts/actions';
import { EMPTY_STATE_QE_ID } from 'src/SqlLab/hooks/useQueryEditor';
import { api } from 'src/hooks/apiResources/queryApi';
import {
queryHistoryApi,
type QueryResult,
} from 'src/hooks/apiResources/queries';
import { defaultStore } from 'spec/helpers/testing-library';
import { ToastType } from '../../components/MessageToasts/types';
const isFeatureEnabledMock = isFeatureEnabled as unknown as jest.Mock;
@@ -749,7 +755,7 @@ describe('async actions', () => {
database_name: 'examples',
id: 2,
},
description: '',
description: 'A saved query description',
id: 1,
label: 'Query 1',
schema: 'public',
@@ -799,6 +805,7 @@ describe('async actions', () => {
const expectedParams = {
name: 'Query 1',
description: 'A saved query description',
dbId: 2,
catalog: null,
schema: 'public',
@@ -1107,6 +1114,80 @@ describe('async actions', () => {
});
});
test('removeQuery removes the deleted query from the editorQueries cache', async () => {
isFeatureEnabledMock.mockReturnValue(false);
const editorId = 'editor1';
const queryToRemove = {
...query,
id: 'queryToRemove',
sqlEditorId: editorId,
};
await defaultStore.dispatch(
queryHistoryApi.util.upsertQueryData('editorQueries', { editorId }, {
count: 2,
ids: [],
result: [{ id: 'queryToRemove' }, { id: 'keep' }],
} as unknown as QueryResult),
);
await defaultStore.dispatch(
actions.removeQuery(queryToRemove) as unknown as AnyAction,
);
const { data } = queryHistoryApi.endpoints.editorQueries.select({
editorId,
})(defaultStore.getState());
expect(data).toEqual({
count: 1,
ids: [],
result: [{ id: 'keep' }],
});
defaultStore.dispatch(api.util.resetApiState());
});
test('removeQuery removes duplicate cache entries for the deleted query', async () => {
isFeatureEnabledMock.mockReturnValue(false);
const editorId = 'editor1';
const queryToRemove = {
...query,
id: 'queryToRemove',
sqlEditorId: editorId,
};
// The infinite-scroll merge can append the same query twice when
// offsets shift between page fetches; deletion must drop every copy.
await defaultStore.dispatch(
queryHistoryApi.util.upsertQueryData('editorQueries', { editorId }, {
count: 3,
ids: [],
result: [
{ id: 'queryToRemove' },
{ id: 'keep' },
{ id: 'queryToRemove' },
],
} as unknown as QueryResult),
);
await defaultStore.dispatch(
actions.removeQuery(queryToRemove) as unknown as AnyAction,
);
const { data } = queryHistoryApi.endpoints.editorQueries.select({
editorId,
})(defaultStore.getState());
expect(data).toEqual({
count: 2,
ids: [],
result: [{ id: 'keep' }],
});
defaultStore.dispatch(api.util.resetApiState());
});
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
describe('queryEditorSetDb', () => {
test('updates the tab state in the backend', () => {

View File

@@ -48,6 +48,8 @@ import getInitialState from '../reducers/getInitialState';
import { rehydratePersistedState } from '../utils/reduxStateToLocalStorageHelper';
import { PREVIEW_QUERY_LIMIT } from '../constants';
import { EMPTY_STATE_QE_ID } from '../hooks/useQueryEditor';
import { queryHistoryApi } from '../../hooks/apiResources/queries';
import type { AppDispatch as RootDispatch } from '../../views/store';
// Type definitions for SqlLab actions
export interface Query {
@@ -985,7 +987,7 @@ export function removeAllOtherQueryEditors(
}
export function removeQuery(query: Query): SqlLabThunkAction<Promise<unknown>> {
return function (dispatch: AppDispatch) {
return function (dispatch: RootDispatch) {
const queryEditorId = query.sqlEditorId ?? query.id;
const sync = isFeatureEnabled(FeatureFlag.SqllabBackendPersistence)
? SupersetClient.delete({
@@ -996,7 +998,26 @@ export function removeQuery(query: Query): SqlLabThunkAction<Promise<unknown>> {
: Promise.resolve();
return sync
.then(() => dispatch({ type: REMOVE_QUERY, query }))
.then(() => {
dispatch({ type: REMOVE_QUERY, query });
dispatch(
queryHistoryApi.util.updateQueryData(
'editorQueries',
{ editorId: queryEditorId },
draft => {
// The infinite-scroll merge can leave duplicate entries for the
// same id (offset shifts between page fetches), so drop them all.
const remaining = draft.result.filter(
({ id }) => id !== query.id,
);
if (remaining.length !== draft.result.length) {
draft.result = remaining;
draft.count = Math.max(0, draft.count - 1);
}
},
),
);
})
.catch(() =>
dispatch(
addDangerToast(
@@ -1539,6 +1560,7 @@ export function popSavedQuery(
} as Record<string, unknown>;
const tmpAdaptedProps = {
name: queryEditorProps.name as string,
description: queryEditorProps.description as string,
dbId: (queryEditorProps.database as { id: number }).id,
catalog: queryEditorProps.catalog as string,
schema: queryEditorProps.schema as string,

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