Compare commits

..
Author SHA1 Message Date
EvanandClaude Opus 4.8 985ebb796e test(databases): assert form inputs are enabled after SQLAlchemy switch
Strengthens the presence check to also confirm the form fields are
usable, per bito-code-review feedback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 18:26:41 -07:00
Claude Code b304f33a9b test(databases): stabilize flaky SQLAlchemy-form visibility assertion
The form mounts inside DatabaseModal's animated tab pane, and
rc-motion's animation state in jsdom is nondeterministic: toBeVisible
intermittently times out (observed on unrelated PRs' jest shards) even
though the form is rendered. Assert on document presence instead, which
is what the tab-switch regression check actually needs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:17:11 -07:00
191 changed files with 14655 additions and 11619 deletions
+11
View File
@@ -10,7 +10,11 @@ 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
@@ -74,6 +78,8 @@ updates:
- package-ecosystem: "npm"
directory: "/docs/"
ignore:
- dependency-name: "react-error-boundary"
schedule:
interval: "daily"
open-pull-requests-limit: 10
@@ -370,6 +376,11 @@ 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:
-2
View File
@@ -42,8 +42,6 @@ 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:
+19 -1
View File
@@ -1,7 +1,25 @@
{/*
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,17 +355,6 @@ 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
+3 -4
View File
@@ -67,6 +67,7 @@
"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",
@@ -117,16 +118,14 @@
"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",
"@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"
"ws": "^8.21.0"
},
"packageManager": "yarn@1.22.22+sha1.ac34549e6aa8e7ead463a7407e1c7390f61a6610"
}
+1 -1
View File
@@ -176,7 +176,7 @@ export interface DatabaseInfo {
expressions_in_orderby?: boolean;
// Platform features
limit_method?: number;
limit_method?: string;
limit_clause?: boolean;
max_column_name?: number;
supports_file_upload?: boolean;
+1 -2
View File
@@ -19,7 +19,6 @@
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';
@@ -37,7 +36,7 @@ interface DataSetType {
categories: Record<string, Organization[]>;
}
const typedDataSet = load(DataSet) as DataSetType;
const typedDataSet = DataSet as DataSetType;
const ContributorAvatars = ({ contributors }: { contributors?: string[] }) => {
if (!contributors?.length) return null;
+1 -2
View File
@@ -22,7 +22,6 @@ 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';
@@ -61,7 +60,7 @@ interface DataSetType {
categories: Record<string, Organization[]>;
}
const typedDataSet = load(DataSet) as DataSetType;
const typedDataSet = DataSet as DataSetType;
// Extract all organizations with logos for the carousel
const companiesWithLogos = Object.values(typedDataSet.categories)
+2 -2
View File
@@ -18,11 +18,11 @@
*/
declare module '*.yaml' {
const content: string;
const content: unknown;
export default content;
}
declare module '*.yml' {
const content: string;
const content: unknown;
export default content;
}
+2 -2
View File
@@ -61,10 +61,10 @@ export default function webpackExtendPlugin(): Plugin<void> {
);
});
// Load YAML file as raw text string
// Add YAML loader rule directly to existing rules
config.module?.rules?.push({
test: /\.ya?ml$/,
type: 'asset/source',
use: 'js-yaml-loader',
});
// Add swc-loader rule for superset-frontend files
+1
View File
@@ -30,6 +30,7 @@
"exclude": [
"node_modules",
"../superset-frontend/**/*",
"src/webpack.extend.ts",
"src/shims/**"
]
}
+3933 -3663
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -186,7 +186,7 @@ ocient = [
"geojson",
]
oracle = ["oracledb>=2.0.0, <5"]
parseable = ["sqlalchemy-parseable>=0.1.6,<0.2.0"]
parseable = ["sqlalchemy-parseable>=0.1.3,<0.2.0"]
pinot = ["pinotdb>=5.0.0, <10.0.0"]
playwright = ["playwright>=1.60.0, <2"]
postgres = ["psycopg2-binary==2.9.12"]
@@ -1,128 +0,0 @@
#!/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()
-8
View File
@@ -55,14 +55,6 @@ 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
-63
View File
@@ -152,60 +152,6 @@ 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]:
@@ -701,15 +647,6 @@ 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 = [
+816 -600
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -37,10 +37,10 @@
"jwt-decode": "^4.0.0"
},
"devDependencies": {
"@babel/cli": "^7.29.7",
"@babel/core": "^7.29.7",
"@babel/preset-env": "^7.29.7",
"@babel/preset-typescript": "7.29.7",
"@babel/cli": "^7.25.6",
"@babel/core": "^7.25.2",
"@babel/preset-env": "^7.25.4",
"@babel/preset-typescript": "^7.24.7",
"@types/node": "^25.4.0",
"babel-loader": "^9.1.3",
"tscw-config": "^1.1.2",
+1 -4
View File
@@ -72,10 +72,7 @@ module.exports = {
],
coverageReporters: ['lcov', 'json-summary', 'html', 'text'],
transformIgnorePatterns: [
// @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)',
'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)',
],
preset: 'ts-jest',
transform: {
+2127 -851
View File
File diff suppressed because it is too large Load Diff
+2 -4
View File
@@ -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.62.1",
"@typescript-eslint/parser": "^8.61.0",
"babel-jest": "^30.4.1",
"babel-loader": "^10.1.1",
"babel-plugin-dynamic-import-node": "^2.3.3",
@@ -393,6 +393,7 @@
"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",
@@ -427,9 +428,6 @@
},
"eslint-plugin-jest-dom": {
"eslint": "$eslint"
},
"lerna": {
"js-yaml": "^4.3.0"
}
},
"readme": "ERROR: No README data found!",
@@ -258,16 +258,6 @@ 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,7 +330,8 @@ 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<
@@ -395,7 +396,9 @@ 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[];
@@ -699,7 +702,10 @@ 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.3.2",
"@ant-design/icons": "^6.2.5",
"@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.1.2",
"react-error-boundary": "6.0.0",
"react-js-cron": "^5.2.0",
"react-markdown": "^9.0.7",
"react-markdown": "^8.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": "^4.0.1",
"remark-gfm": "^3.0.1",
"reselect": "^5.2.0",
"rison": "^0.1.1",
"seedrandom": "^3.0.5",
@@ -29,7 +29,13 @@ 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,199 +70,6 @@ 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 />);
@@ -1,219 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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]);
});
@@ -1,283 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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,13 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import {
forwardRef,
useEffect,
useCallback,
useRef,
ComponentType,
} from 'react';
import { forwardRef, useEffect, useCallback, ComponentType } from 'react';
import type {
Editor as OrigEditor,
@@ -41,8 +35,6 @@ 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 {
@@ -170,14 +162,6 @@ 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 =
@@ -195,7 +179,6 @@ export function AsyncAceEditor(
theme = inferredTheme,
tabSize = defaultTabSize,
defaultValue = '',
onLoad,
...props
},
ref,
@@ -298,62 +281,6 @@ 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
@@ -389,9 +316,8 @@ 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 */
@@ -542,7 +468,6 @@ export function AsyncAceEditor(
tabSize={tabSize}
defaultValue={defaultValue}
setOptions={{ fontFamily: editorFontFamily }}
onLoad={handleLoad}
{...props}
/>
</>
@@ -31,7 +31,12 @@ 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,7 +37,13 @@ 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,9 +34,8 @@ 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: '';
@@ -49,8 +48,7 @@ export const DropdownButton = ({
.anticon {
vertical-align: middle;
}
}`
}
}`}
`;
const button = (
<Dropdown.Button
@@ -60,13 +58,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,22 +221,18 @@ 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,11 +167,9 @@ 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,7 +21,8 @@ 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,7 +77,8 @@ customIcons.forEach(customIcon => {
});
export type IconNameType =
keyof typeof antdEnhancedIcons | keyof typeof iconOverrides;
| keyof typeof antdEnhancedIcons
| keyof typeof iconOverrides;
type IconComponentType = Record<IconNameType, IconComponent>;
@@ -27,7 +27,12 @@ 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,7 +17,10 @@
* under the License.
*/
export type PositionOption =
'floating' | 'inline' | 'inline-centered' | 'normal';
| 'floating'
| 'inline'
| 'inline-centered'
| 'normal';
export type SizeOption = 's' | 'm' | 'l';
@@ -81,4 +81,11 @@ export type Tags = {
};
export type ContentType =
Dashboards | Description | LastModified | Owner | Rows | Sql | Table | Tags;
| Dashboards
| Description
| LastModified
| Owner
| Rows
| Sql
| Table
| Tags;
@@ -73,16 +73,14 @@ 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};
@@ -170,46 +168,40 @@ 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%;
.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)`};
}
}
`
}
${
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,6 +18,9 @@
*/
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';
@@ -136,7 +139,7 @@ export function SafeMarkdown({
rehypePlugins={rehypePlugins}
remarkPlugins={[remarkGfm]}
skipHtml={false}
urlTransform={transformLinkUri}
transformLinkUri={transformLinkUri}
>
{source}
</ReactMarkdown>
@@ -51,10 +51,8 @@ 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 {
@@ -68,11 +66,9 @@ 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,7 +211,6 @@ export type {
GridState,
GridReadyEvent,
CellClickedEvent,
CellKeyDownEvent,
CellClassParams,
IMenuActionParams,
IHeaderParams,
@@ -51,7 +51,9 @@ 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.
@@ -77,7 +79,12 @@ 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,6 +18,12 @@
*/
export type RowDataValue =
string | number | boolean | Date | bigint | null | undefined;
| string
| number
| boolean
| Date
| bigint
| null
| undefined;
export type RowData = Record<string, RowDataValue>;
@@ -36,7 +36,9 @@ 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,7 +59,8 @@ 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,7 +46,10 @@ interface TimeoutError {
}
type ErrorType =
SupersetClientResponse | TimeoutError | { response: Response } | string;
| SupersetClientResponse
| TimeoutError
| { response: Response }
| string;
type ErrorTextSource = 'dashboard' | 'chart' | 'query' | 'dataset' | 'database';
@@ -159,7 +162,10 @@ 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,7 +186,10 @@ export function isFilterDivider(
export function isChartCustomization(
filterElement:
Filter | Divider | ChartCustomization | ChartCustomizationDivider,
| Filter
| Divider
| ChartCustomization
| ChartCustomizationDivider,
): filterElement is ChartCustomization {
return filterElement.type === ChartCustomizationType.ChartCustomization;
}
@@ -201,7 +204,10 @@ export type FilterConfiguration = Array<Filter | Divider>;
export type Filters = {
[filterId: string]:
Filter | Divider | ChartCustomization | ChartCustomizationDivider;
| Filter
| Divider
| ChartCustomization
| ChartCustomizationDivider;
};
export type PartialFilters = {
@@ -220,7 +226,8 @@ export type ChartCustomizationConfiguration = Array<
export type ChartCustomizations = {
[chartCustomizationId: string]:
ChartCustomization | ChartCustomizationDivider;
| ChartCustomization
| ChartCustomizationDivider;
};
export type PartialChartCustomizations = {
@@ -53,7 +53,9 @@ export type SetAdhocFilter = BaseSimpleAdhocFilter & {
};
export type SimpleAdhocFilter =
UnaryAdhocFilter | BinaryAdhocFilter | SetAdhocFilter;
| UnaryAdhocFilter
| BinaryAdhocFilter
| SetAdhocFilter;
export interface FreeFormAdhocFilter extends BaseAdhocFilter {
expressionType: 'SQL';
@@ -21,7 +21,12 @@ 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,7 +74,8 @@ interface _PostProcessingAggregation {
};
}
export type PostProcessingAggregation =
_PostProcessingAggregation | DefaultPostProcessing;
| _PostProcessingAggregation
| DefaultPostProcessing;
export type BoxPlotQueryObjectWhiskerType = 'tukey' | 'min/max' | 'percentile';
interface _PostProcessingBoxplot {
@@ -87,7 +88,8 @@ interface _PostProcessingBoxplot {
};
}
export type PostProcessingBoxplot =
_PostProcessingBoxplot | DefaultPostProcessing;
| _PostProcessingBoxplot
| DefaultPostProcessing;
interface _PostProcessingContribution {
operation: 'contribution';
@@ -99,7 +101,8 @@ interface _PostProcessingContribution {
};
}
export type PostProcessingContribution =
_PostProcessingContribution | DefaultPostProcessing;
| _PostProcessingContribution
| DefaultPostProcessing;
interface _PostProcessingPivot {
operation: 'pivot';
@@ -129,7 +132,8 @@ interface _PostProcessingProphet {
};
}
export type PostProcessingProphet =
_PostProcessingProphet | DefaultPostProcessing;
| _PostProcessingProphet
| DefaultPostProcessing;
interface _PostProcessingDiff {
operation: 'diff';
@@ -151,7 +155,8 @@ interface _PostProcessingRolling {
};
}
export type PostProcessingRolling =
_PostProcessingRolling | DefaultPostProcessing;
| _PostProcessingRolling
| DefaultPostProcessing;
interface _PostProcessingCum {
operation: 'cum';
@@ -172,7 +177,8 @@ export interface _PostProcessingCompare {
};
}
export type PostProcessingCompare =
_PostProcessingCompare | DefaultPostProcessing;
| _PostProcessingCompare
| DefaultPostProcessing;
interface _PostProcessingSort {
operation: 'sort';
@@ -193,7 +199,8 @@ interface _PostProcessingResample {
};
}
export type PostProcessingResample =
_PostProcessingResample | DefaultPostProcessing;
| _PostProcessingResample
| DefaultPostProcessing;
interface _PostProcessingRename {
operation: 'rename';
@@ -204,7 +211,8 @@ interface _PostProcessingRename {
};
}
export type PostProcessingRename =
_PostProcessingRename | DefaultPostProcessing;
| _PostProcessingRename
| DefaultPostProcessing;
interface _PostProcessingFlatten {
operation: 'flatten';
@@ -214,7 +222,8 @@ interface _PostProcessingFlatten {
};
}
export type PostProcessingFlatten =
_PostProcessingFlatten | DefaultPostProcessing;
| _PostProcessingFlatten
| DefaultPostProcessing;
interface _PostProcessingRank {
operation: 'rank';
@@ -236,7 +245,8 @@ interface _PostProcessingHistogram {
};
}
export type PostProcessingHistogram =
_PostProcessingHistogram | DefaultPostProcessing;
| _PostProcessingHistogram
| DefaultPostProcessing;
/**
* Parameters for chart data postprocessing.
@@ -51,7 +51,8 @@ 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,7 +30,14 @@ 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,7 +30,8 @@ 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,7 +40,6 @@ import {
GridReadyEvent,
GridState,
CellClickedEvent,
CellKeyDownEvent,
SelectionChangedEvent,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { t } from '@apache-superset/core/translation';
@@ -58,10 +57,8 @@ 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;
@@ -238,13 +235,6 @@ 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) {
@@ -347,11 +337,11 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
timestamp: Date.now(),
};
const stateHash = getColumnStateSignature(
columnState,
sortModel,
filterModel,
);
const stateHash = JSON.stringify({
columnOrder: columnState.map(c => c.colId),
sorts: sortModel,
filters: filterModel,
});
if (stateHash !== lastCapturedStateRef.current) {
lastCapturedStateRef.current = stateHash;
@@ -524,22 +514,13 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
rowSelection="multiple"
animateRows
onCellClicked={handleCellClicked}
onCellKeyDown={handleCellKeyDown}
onSelectionChanged={handleSelectionChanged}
onFilterChanged={handleFilterChanged}
onStateUpdated={handleGridStateChange}
initialState={gridInitialState}
maintainColumnOrder
suppressAggFuncInHeader
// 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}
enableCellTextSelection
quickFilterText={serverPagination ? '' : quickFilterText}
suppressMovableColumns={!allowRearrangeColumns}
pagination={pagination}
@@ -80,7 +80,8 @@ 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,7 +753,8 @@ 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,7 +189,10 @@ export interface InputColumn {
isPercentMetric: boolean;
config: TableColumnConfig;
formatter?:
TimeFormatter | NumberFormatter | CustomFormatter | CurrencyFormatter;
| TimeFormatter
| NumberFormatter
| CustomFormatter
| CurrencyFormatter;
originalLabel?: string;
metricName?: string;
}
@@ -1,156 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/**
* 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;
}
@@ -1,51 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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,20 +84,3 @@ 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,10 +20,7 @@ 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,
type ColumnState,
} from '@superset-ui/core/components/ThemedAgGridReact';
import { setupAGGridModules } from '@superset-ui/core/components/ThemedAgGridReact';
import AgGridTableChart from '../src/AgGridTableChart';
import transformProps from '../src/transformProps';
import { ProviderWrapper } from '../../plugin-chart-table/test/testHelpers';
@@ -729,46 +726,3 @@ 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 });
});
@@ -1,99 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { 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,7 +47,8 @@ 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) {
@@ -1,203 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import {
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();
});
@@ -1,82 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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,7 +42,11 @@ 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,7 +221,8 @@ const config: ControlPanelConfig = {
return {
queryResponse: chart?.queriesResponse?.[0] as
ChartDataResponseResult | undefined,
| ChartDataResponseResult
| undefined,
appliedColumnNames: metricColumn,
columnsPropsObject: { colnames, coltypes },
};
@@ -521,7 +521,8 @@ export function transformIntervalAnnotation(
});
const allIntervalData: (
MarkArea1DDataItemOption | MarkArea2DDataItemOption
| MarkArea1DDataItemOption
| MarkArea2DDataItemOption
)[] = annotations.map(annotation => {
const { intervalEnd, time = '' } = annotation;
const combinedLabel = (intervalsByStartTime.get(time) || []).join('\n');
@@ -29,7 +29,11 @@ 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,7 +21,11 @@ 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,7 +46,9 @@ 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[];
@@ -55,7 +57,9 @@ 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,7 +73,11 @@ 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,9 +968,11 @@ 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();
@@ -1011,11 +1013,13 @@ 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();
@@ -1047,7 +1051,8 @@ 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,7 +67,11 @@ 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;
@@ -90,7 +94,11 @@ 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,7 +29,8 @@ 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,7 +900,8 @@ 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,7 +42,9 @@ 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,15 +1135,13 @@ 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`
@@ -1151,11 +1149,10 @@ 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,
@@ -1174,18 +1171,15 @@ 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;
`;
@@ -1194,12 +1188,10 @@ 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,10 +227,7 @@ export const buildQuery: BuildQuery<TableChartFormData> = (
formData?.result_type === 'results');
if (isDownloadQuery) {
moreProps.row_limit =
formDataCopy.row_limit != null
? Number(formDataCopy.row_limit)
: undefined;
moreProps.row_limit = Number(formDataCopy.row_limit) || 0;
moreProps.row_offset = 0;
}
@@ -66,7 +66,8 @@ 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,7 +48,8 @@ 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,7 +36,8 @@ 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,7 +529,8 @@ export const mapProvider = {
options: getLabeledMapRendererOptions({
hasMapboxKey: hasKey,
currentValue: state.form_data?.map_renderer as
MapProvider | undefined,
| MapProvider
| undefined,
}),
default: getDefaultMapRenderer(),
};
@@ -422,7 +422,11 @@ 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(
@@ -480,7 +484,11 @@ 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: 'A saved query description',
description: '',
id: 1,
label: 'Query 1',
schema: 'public',
@@ -799,7 +799,6 @@ describe('async actions', () => {
const expectedParams = {
name: 'Query 1',
description: 'A saved query description',
dbId: 2,
catalog: null,
schema: 'public',
@@ -1539,7 +1539,6 @@ 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,
@@ -1,94 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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();
});
@@ -1,46 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import 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,8 +44,6 @@ 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 {
@@ -216,12 +214,6 @@ 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);
@@ -716,7 +708,6 @@ const ResultSet = ({
filterText: searchText,
expandedColumns,
allowHTML,
themeOverrides: resultsGridThemeOverrides,
};
return (
@@ -172,47 +172,6 @@ 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,22 +162,16 @@ const SaveQuery = ({
<Form layout="vertical">
<Row>
<Col xs={24}>
<FormItem label={t('Name')} htmlFor="save-query-name">
<Input
id="save-query-name"
type="text"
value={label}
onChange={onLabelChange}
/>
<FormItem label={t('Name')}>
<Input type="text" value={label} onChange={onLabelChange} />
</FormItem>
</Col>
</Row>
<br />
<Row>
<Col xs={24}>
<FormItem label={t('Description')} htmlFor="save-query-description">
<FormItem label={t('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};
& .ant-tabs-nav::before {
border-color: ${({ theme }) => theme.colorBorder} !important;
}
&& .ant-tabs-nav-add {
border-color: ${({ theme }) => theme.colorBorder};
& .ant-tabs-nav-add {
border-color: ${({ theme }) => theme.colorBorder} !important;
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};
border-top-color: ${({ theme }) => theme.colorPrimaryActive};
border-right-color: ${({ theme }) => theme.colorPrimaryActive};
box-shadow: 0 0 2px ${({ theme }) => theme.colorPrimaryActive};
& .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;
border-top: 2px;
}
&& .ant-tabs-tab {
border-radius: 2px 2px 0px 0px;
& .ant-tabs-tab {
border-radius: 2px 2px 0px 0px !important;
padding: ${({ theme }) => theme.sizeUnit}px
${({ theme }) => theme.sizeUnit * 2}px;
${({ theme }) => theme.sizeUnit * 2}px !important;
& + .ant-tabs-nav-add {
margin-right: ${({ theme }) => theme.sizeUnit * 4}px;
}
&:not(.ant-tabs-tab-active) {
border-color: ${({ theme }) => theme.colorBorder};
box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder};
border-color: ${({ theme }) => theme.colorBorder} !important;
box-shadow: inset 0 0 1px ${({ theme }) => theme.colorBorder} !important;
}
}
&& .ant-tabs-nav-add {
border-radius: 2px 2px 0px 0px;
min-height: auto;
& .ant-tabs-nav-add {
border-radius: 2px 2px 0px 0px !important;
min-height: auto !important;
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,7 +37,10 @@ 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,7 +613,8 @@ export default function sqlLabReducer(
? actionId
: ((
getFromArr(state.queryEditors, actionId, 'tabViewId') as
QueryEditor | undefined
| QueryEditor
| undefined
)?.id ?? actionId);
if (
unsavedQueryEditor?.id === normalizedId &&
@@ -188,21 +188,17 @@ 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'
};
border-radius: ${theme.borderRadius}px;
background: ${
isOver
${isOver
? isForbidden
? theme.colorErrorBg
: theme.colorPrimaryBg
: 'transparent'
};
? theme.colorError
: theme.colorPrimary
: 'transparent'};
border-radius: ${theme.borderRadius}px;
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,7 +65,6 @@ export const FilterableTable = ({
expandedColumns = [],
allowHTML = true,
striped,
themeOverrides,
}: FilterableTableProps) => {
const getCellContent = useCellContentParser({
columnKeys: orderedColumnKeys,
@@ -132,7 +131,6 @@ 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