mirror of
https://github.com/apache/superset.git
synced 2026-07-07 23:35:36 +00:00
Compare commits
29 Commits
fix-databa
...
sl-hide-si
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7716466704 | ||
|
|
13d94b2d23 | ||
|
|
4dc45aa958 | ||
|
|
4167a21f7d | ||
|
|
5e384f54d3 | ||
|
|
ec4e5deb01 | ||
|
|
de5a233ccf | ||
|
|
c846da8dfb | ||
|
|
1c3b070d34 | ||
|
|
a8a1b220fd | ||
|
|
e52240b9a6 | ||
|
|
87fa7e5e64 | ||
|
|
2b74e140f3 | ||
|
|
847bdad960 | ||
|
|
4b78758fd2 | ||
|
|
101c1e8827 | ||
|
|
85eaf15c5e | ||
|
|
2aa43f6f0f | ||
|
|
ee524b3471 | ||
|
|
9e50d9eca6 | ||
|
|
033b7bc385 | ||
|
|
c0b0a2fdaf | ||
|
|
44c1ad5c71 | ||
|
|
fb496e158a | ||
|
|
69ce27f173 | ||
|
|
51e708da6b | ||
|
|
21b4185819 | ||
|
|
689b4f623e | ||
|
|
ad8e6d4140 |
11
.github/dependabot.yml
vendored
11
.github/dependabot.yml
vendored
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
4
docs/src/types/yaml.d.ts
vendored
4
docs/src/types/yaml.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,6 @@
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"../superset-frontend/**/*",
|
||||
"src/webpack.extend.ts",
|
||||
"src/shims/**"
|
||||
]
|
||||
}
|
||||
|
||||
7598
docs/yarn.lock
7598
docs/yarn.lock
File diff suppressed because it is too large
Load Diff
@@ -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"]
|
||||
|
||||
128
scripts/translations/apply_do_not_translate.py
Normal file
128
scripts/translations/apply_do_not_translate.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
@@ -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 = [
|
||||
|
||||
1412
superset-embedded-sdk/package-lock.json
generated
1412
superset-embedded-sdk/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
2952
superset-frontend/package-lock.json
generated
2952
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -299,7 +299,7 @@
|
||||
"@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/parser": "^8.62.1",
|
||||
"babel-jest": "^30.4.1",
|
||||
"babel-loader": "^10.1.1",
|
||||
"babel-plugin-dynamic-import-node": "^2.3.3",
|
||||
@@ -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!",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 />);
|
||||
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)`
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
`;
|
||||
}}
|
||||
`;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -211,6 +211,7 @@ export type {
|
||||
GridState,
|
||||
GridReadyEvent,
|
||||
CellClickedEvent,
|
||||
CellKeyDownEvent,
|
||||
CellClassParams,
|
||||
IMenuActionParams,
|
||||
IHeaderParams,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -53,9 +53,7 @@ export type SetAdhocFilter = BaseSimpleAdhocFilter & {
|
||||
};
|
||||
|
||||
export type SimpleAdhocFilter =
|
||||
| UnaryAdhocFilter
|
||||
| BinaryAdhocFilter
|
||||
| SetAdhocFilter;
|
||||
UnaryAdhocFilter | BinaryAdhocFilter | SetAdhocFilter;
|
||||
|
||||
export interface FreeFormAdhocFilter extends BaseAdhocFilter {
|
||||
expressionType: 'SQL';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -753,8 +753,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
|
||||
);
|
||||
|
||||
@@ -189,10 +189,7 @@ export interface InputColumn {
|
||||
isPercentMetric: boolean;
|
||||
config: TableColumnConfig;
|
||||
formatter?:
|
||||
| TimeFormatter
|
||||
| NumberFormatter
|
||||
| CustomFormatter
|
||||
| CurrencyFormatter;
|
||||
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
|
||||
originalLabel?: string;
|
||||
metricName?: string;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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 },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -221,8 +221,7 @@ const config: ControlPanelConfig = {
|
||||
|
||||
return {
|
||||
queryResponse: chart?.queriesResponse?.[0] as
|
||||
| ChartDataResponseResult
|
||||
| undefined,
|
||||
ChartDataResponseResult | undefined,
|
||||
appliedColumnNames: metricColumn,
|
||||
columnsPropsObject: { colnames, coltypes },
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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[][];
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -529,8 +529,7 @@ export const mapProvider = {
|
||||
options: getLabeledMapRendererOptions({
|
||||
hasMapboxKey: hasKey,
|
||||
currentValue: state.form_data?.map_renderer as
|
||||
| MapProvider
|
||||
| undefined,
|
||||
MapProvider | undefined,
|
||||
}),
|
||||
default: getDefaultMapRenderer(),
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -348,9 +348,9 @@ const TreeNodeRenderer: React.FC<TreeNodeRendererProps> = ({
|
||||
<Icons.SortAscendingOutlined
|
||||
iconSize="m"
|
||||
css={css`
|
||||
color: ${sortedTables[data.id]
|
||||
? theme.colorPrimary
|
||||
: 'inherit'};
|
||||
color: ${
|
||||
sortedTables[data.id] ? theme.colorPrimary : 'inherit'
|
||||
};
|
||||
`}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -37,10 +37,7 @@ import { BYTES_PER_CHAR, KB_STORAGE } from '../constants';
|
||||
type SqlLabState = SqlLabRootState['sqlLab'];
|
||||
|
||||
type ClearEntityHelperValue =
|
||||
| Table[]
|
||||
| SqlLabState['queries']
|
||||
| QueryEditor[]
|
||||
| UnsavedQueryEditor;
|
||||
Table[] | SqlLabState['queries'] | QueryEditor[] | UnsavedQueryEditor;
|
||||
|
||||
interface ClearEntityHelpersMap {
|
||||
tables: (tables: Table[]) => ReturnType<typeof emptyTablePersistData>;
|
||||
|
||||
@@ -613,8 +613,7 @@ export default function sqlLabReducer(
|
||||
? actionId
|
||||
: ((
|
||||
getFromArr(state.queryEditors, actionId, 'tabViewId') as
|
||||
| QueryEditor
|
||||
| undefined
|
||||
QueryEditor | undefined
|
||||
)?.id ?? actionId);
|
||||
if (
|
||||
unsavedQueryEditor?.id === normalizedId &&
|
||||
|
||||
@@ -188,17 +188,21 @@ export const EmptyFolderDropZone = styled.div<{
|
||||
margin-left: ${depth * FOLDER_INDENTATION_WIDTH + ITEM_INDENTATION_WIDTH}px;
|
||||
padding: ${theme.paddingLG}px;
|
||||
border: 2px dashed
|
||||
${isOver
|
||||
? isForbidden
|
||||
? theme.colorError
|
||||
: theme.colorPrimary
|
||||
: 'transparent'};
|
||||
${
|
||||
isOver
|
||||
? isForbidden
|
||||
? theme.colorError
|
||||
: theme.colorPrimary
|
||||
: 'transparent'
|
||||
};
|
||||
border-radius: ${theme.borderRadius}px;
|
||||
background: ${isOver
|
||||
? isForbidden
|
||||
? theme.colorErrorBg
|
||||
: theme.colorPrimaryBg
|
||||
: 'transparent'};
|
||||
background: ${
|
||||
isOver
|
||||
? isForbidden
|
||||
? theme.colorErrorBg
|
||||
: theme.colorPrimaryBg
|
||||
: 'transparent'
|
||||
};
|
||||
text-align: center;
|
||||
transition: all 0.2s ease-in-out;
|
||||
cursor: ${isOver && isForbidden ? 'not-allowed' : 'default'};
|
||||
|
||||
@@ -305,8 +305,8 @@ export function serializeForAPI(items: TreeItem[]): DatasourceFolder[] {
|
||||
(
|
||||
child,
|
||||
): child is
|
||||
| DatasourceFolder
|
||||
| { uuid: string; type: FoldersEditorItemType } => child !== null,
|
||||
DatasourceFolder | { uuid: string; type: FoldersEditorItemType } =>
|
||||
child !== null,
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -65,6 +65,7 @@ export const FilterableTable = ({
|
||||
expandedColumns = [],
|
||||
allowHTML = true,
|
||||
striped,
|
||||
themeOverrides,
|
||||
}: FilterableTableProps) => {
|
||||
const getCellContent = useCellContentParser({
|
||||
columnKeys: orderedColumnKeys,
|
||||
@@ -131,6 +132,7 @@ export const FilterableTable = ({
|
||||
striped={striped}
|
||||
enableActions
|
||||
columnReorderable
|
||||
themeOverrides={themeOverrides}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user