Compare commits

...

38 Commits

Author SHA1 Message Date
dependabot[bot]
e1a76797d9 chore(deps-dev): bump databricks-sqlalchemy from 1.0.5 to 2.0.9
Bumps [databricks-sqlalchemy](https://github.com/databricks/databricks-sqlalchemy) from 1.0.5 to 2.0.9.
- [Release notes](https://github.com/databricks/databricks-sqlalchemy/releases)
- [Changelog](https://github.com/databricks/databricks-sqlalchemy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/databricks/databricks-sqlalchemy/compare/v1.0.5...v2.0.9)

---
updated-dependencies:
- dependency-name: databricks-sqlalchemy
  dependency-version: 2.0.9
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-07 15:49:26 +00: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
205 changed files with 12679 additions and 14610 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:

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

@@ -137,10 +137,10 @@ 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",
"databricks-sqlalchemy==2.0.9",
]
db2 = ["ibm-db-sa>0.3.8, <=0.4.4"]
denodo = ["denodo-sqlalchemy>=2.0.5,<2.1.0"]
@@ -186,7 +186,7 @@ ocient = [
"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

@@ -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",
@@ -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",

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

@@ -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

@@ -968,11 +968,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 +1011,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 +1047,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;

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

@@ -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

@@ -749,7 +749,7 @@ describe('async actions', () => {
database_name: 'examples',
id: 2,
},
description: '',
description: 'A saved query description',
id: 1,
label: 'Query 1',
schema: 'public',
@@ -799,6 +799,7 @@ describe('async actions', () => {
const expectedParams = {
name: 'Query 1',
description: 'A saved query description',
dbId: 2,
catalog: null,
schema: 'public',

View File

@@ -1539,6 +1539,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,

View File

@@ -0,0 +1,94 @@
/**
* 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 { SupersetTheme } from '@apache-superset/core/theme';
import { buildResultsGridThemeOverrides } from './buildResultsGridThemeOverrides';
test('returns undefined when no resultsGrid tokens are set', () => {
const theme = {} as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toBeUndefined();
});
test('maps resultsGridHeaderFontSize to headerFontSize', () => {
const theme = { resultsGridHeaderFontSize: 14 } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
headerFontSize: 14,
});
});
test('maps resultsGridHeaderFontWeight to headerFontWeight', () => {
const theme = { resultsGridHeaderFontWeight: 700 } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
headerFontWeight: 700,
});
});
test('maps resultsGridRowHeight to both rowHeight and headerHeight', () => {
const theme = { resultsGridRowHeight: 40 } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
rowHeight: 40,
headerHeight: 40,
});
});
test('maps resultsGridBorderRadius to both borderRadius and wrapperBorderRadius', () => {
const theme = { resultsGridBorderRadius: 8 } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
borderRadius: 8,
wrapperBorderRadius: 8,
});
});
test('maps resultsGridNoStriping to oddRowBackgroundColor transparent', () => {
const theme = { resultsGridNoStriping: true } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
oddRowBackgroundColor: 'transparent',
});
});
test('does not map resultsGridNoStriping when false', () => {
const theme = { resultsGridNoStriping: false } as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toBeUndefined();
});
test('maps all tokens together when all are set', () => {
const theme = {
resultsGridHeaderFontSize: 12,
resultsGridHeaderFontWeight: 600,
resultsGridRowHeight: 36,
resultsGridBorderRadius: 4,
resultsGridNoStriping: true,
} as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toEqual({
headerFontSize: 12,
headerFontWeight: 600,
rowHeight: 36,
headerHeight: 36,
borderRadius: 4,
wrapperBorderRadius: 4,
oddRowBackgroundColor: 'transparent',
});
});
test('ignores non-number values for numeric tokens', () => {
const theme = {
resultsGridRowHeight: '40',
resultsGridHeaderFontSize: undefined,
} as unknown as SupersetTheme;
expect(buildResultsGridThemeOverrides(theme)).toBeUndefined();
});

View File

@@ -0,0 +1,46 @@
/**
* 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 { SupersetTheme } from '@apache-superset/core/theme';
import type { GridThemeOverrides } from 'src/components/GridTable/types';
export function buildResultsGridThemeOverrides(
theme: SupersetTheme,
): GridThemeOverrides | undefined {
const overrides: GridThemeOverrides = {};
if (typeof theme.resultsGridHeaderFontSize === 'number') {
overrides.headerFontSize = theme.resultsGridHeaderFontSize;
}
if (typeof theme.resultsGridHeaderFontWeight === 'number') {
overrides.headerFontWeight = theme.resultsGridHeaderFontWeight;
}
if (typeof theme.resultsGridRowHeight === 'number') {
overrides.rowHeight = theme.resultsGridRowHeight;
overrides.headerHeight = theme.resultsGridRowHeight;
}
if (typeof theme.resultsGridBorderRadius === 'number') {
overrides.borderRadius = theme.resultsGridBorderRadius;
overrides.wrapperBorderRadius = theme.resultsGridBorderRadius;
}
if (theme.resultsGridNoStriping === true) {
overrides.oddRowBackgroundColor = 'transparent';
}
return Object.keys(overrides).length > 0 ? overrides : undefined;
}

View File

@@ -44,6 +44,8 @@ import {
FilterableTable,
ErrorMessageWithStackTrace,
} from 'src/components';
import type { GridThemeOverrides } from 'src/components/GridTable/types';
import { buildResultsGridThemeOverrides } from './buildResultsGridThemeOverrides';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core/translation';
import {
@@ -214,6 +216,12 @@ const ResultSet = ({
extensionsRegistry.get('sqleditor.extension.resultTable') ??
FilterableTable;
const theme = useTheme();
const resultsGridThemeOverrides = useMemo<GridThemeOverrides | undefined>(
() => buildResultsGridThemeOverrides(theme),
[theme],
);
const [searchText, setSearchText] = useState('');
const [cachedData, setCachedData] = useState<Record<string, unknown>[]>([]);
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
@@ -708,6 +716,7 @@ const ResultSet = ({
filterText: searchText,
expandedColumns,
allowHTML,
themeOverrides: resultsGridThemeOverrides,
};
return (

View File

@@ -172,6 +172,47 @@ describe('SavedQuery', () => {
expect(updateBtn).toBeInTheDocument();
});
test('pre-fills the description from an existing saved query and updates with it unchanged', async () => {
const storedDescription = 'This is the stored description';
const mockOnUpdate = jest.fn();
render(<SaveQuery {...mockedProps} onUpdate={mockOnUpdate} />, {
useRedux: true,
store: mockStore({
...mockState,
sqlLab: {
...mockState.sqlLab,
queryEditors: [
{
id: mockedProps.queryEditorId,
dbId: 1,
catalog: null,
schema: 'main',
sql: 'SELECT * FROM t',
name: 'My saved query',
description: storedDescription,
remoteId: 42,
},
],
},
}),
});
userEvent.click(screen.getByRole('button', { name: /save/i }));
const descriptionTextbox = screen.getByRole('textbox', {
name: 'Description',
});
expect(descriptionTextbox).toHaveValue(storedDescription);
userEvent.click(screen.getByRole('button', { name: /update/i }));
await waitFor(() => expect(mockOnUpdate).toHaveBeenCalled());
expect(mockOnUpdate.mock.calls[0][0]).toEqual(
expect.objectContaining({ description: storedDescription }),
);
});
test('renders a split save button when allows_virtual_table_explore is enabled', async () => {
render(<SaveQuery {...splitSaveBtnProps} />, {
useRedux: true,

View File

@@ -162,16 +162,22 @@ const SaveQuery = ({
<Form layout="vertical">
<Row>
<Col xs={24}>
<FormItem label={t('Name')}>
<Input type="text" value={label} onChange={onLabelChange} />
<FormItem label={t('Name')} htmlFor="save-query-name">
<Input
id="save-query-name"
type="text"
value={label}
onChange={onLabelChange}
/>
</FormItem>
</Col>
</Row>
<br />
<Row>
<Col xs={24}>
<FormItem label={t('Description')}>
<FormItem label={t('Description')} htmlFor="save-query-description">
<Input.TextArea
id="save-query-description"
rows={4}
value={description}
onChange={onDescriptionChange}

View File

@@ -44,40 +44,40 @@ const StyledEditableTabs = styled(EditableTabs)`
height: 100%;
display: flex;
flex-direction: column;
& .ant-tabs-nav::before {
border-color: ${({ theme }) => theme.colorBorder} !important;
&& .ant-tabs-nav::before {
border-color: ${({ theme }) => theme.colorBorder};
}
& .ant-tabs-nav-add {
border-color: ${({ theme }) => theme.colorBorder} !important;
&& .ant-tabs-nav-add {
border-color: ${({ theme }) => theme.colorBorder};
height: 34px;
}
& .ant-tabs-nav-list {
&& .ant-tabs-nav-list {
align-items: end;
padding-top: 1px;
column-gap: ${({ theme }) => theme.sizeUnit}px;
}
& .ant-tabs-tab-active {
border-left-color: ${({ theme }) => theme.colorPrimaryActive} !important;
border-top-color: ${({ theme }) => theme.colorPrimaryActive} !important;
border-right-color: ${({ theme }) => theme.colorPrimaryActive} !important;
box-shadow: 0 0 2px ${({ theme }) => theme.colorPrimaryActive} !important;
&& .ant-tabs-tab-active {
border-left-color: ${({ theme }) => theme.colorPrimaryActive};
border-top-color: ${({ theme }) => theme.colorPrimaryActive};
border-right-color: ${({ theme }) => theme.colorPrimaryActive};
box-shadow: 0 0 2px ${({ theme }) => theme.colorPrimaryActive};
border-top: 2px;
}
& .ant-tabs-tab {
border-radius: 2px 2px 0px 0px !important;
&& .ant-tabs-tab {
border-radius: 2px 2px 0px 0px;
padding: ${({ theme }) => theme.sizeUnit}px
${({ theme }) => theme.sizeUnit * 2}px !important;
${({ theme }) => theme.sizeUnit * 2}px;
& + .ant-tabs-nav-add {
margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
}
&:not(.ant-tabs-tab-active) {
border-color: ${({ theme }) => theme.colorBorder} !important;
box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder} !important;
border-color: ${({ theme }) => theme.colorBorder};
box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder};
}
}
& .ant-tabs-nav-add {
border-radius: 2px 2px 0px 0px !important;
min-height: auto !important;
&& .ant-tabs-nav-add {
border-radius: 2px 2px 0px 0px;
min-height: auto;
align-self: flex-end;
}
`;

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