mirror of
https://github.com/apache/superset.git
synced 2026-07-16 19:55:39 +00:00
Compare commits
40 Commits
dependabot
...
msyavuz/ch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7f4a6b0e2 | ||
|
|
cc450b91bb | ||
|
|
ec5bd3eba1 | ||
|
|
c786adbc01 | ||
|
|
e012349eaf | ||
|
|
51a03144ec | ||
|
|
1da86f26f7 | ||
|
|
c4aa582ad1 | ||
|
|
b727ee180f | ||
|
|
83051f8aee | ||
|
|
a35952269c | ||
|
|
644ae87081 | ||
|
|
be9dc1fb27 | ||
|
|
3221b6f893 | ||
|
|
2b67d225dc | ||
|
|
e84f870787 | ||
|
|
de300c70b9 | ||
|
|
2f7afe4b47 | ||
|
|
aea4585c6d | ||
|
|
f697a0c24d | ||
|
|
13d38a9cbd | ||
|
|
8603048518 | ||
|
|
635b18103d | ||
|
|
d57569c54a | ||
|
|
409605de70 | ||
|
|
8ce6d42942 | ||
|
|
2bbb7d0638 | ||
|
|
1b0c6aaed3 | ||
|
|
1f786f1949 | ||
|
|
c540f782a3 | ||
|
|
6fe4655ba2 | ||
|
|
689fc34ac2 | ||
|
|
e564389a01 | ||
|
|
da518d7a10 | ||
|
|
c6da740ce2 | ||
|
|
e28b259de0 | ||
|
|
beb9d53687 | ||
|
|
90f9238f8a | ||
|
|
f38fff2a19 | ||
|
|
cec9afb165 |
2
.github/actions/setup-backend/action.yml
vendored
2
.github/actions/setup-backend/action.yml
vendored
@@ -32,8 +32,6 @@ runs:
|
||||
elif [ "$INPUT_PYTHON_VERSION" = "next" ]; then
|
||||
# currently disabled in GHA matrixes because of library compatibility issues
|
||||
RESOLVED_VERSION="3.12"
|
||||
elif [ "$INPUT_PYTHON_VERSION" = "previous" ]; then
|
||||
RESOLVED_VERSION="3.10"
|
||||
elif printf '%s' "$INPUT_PYTHON_VERSION" | grep -Eq '^[0-9]+\.[0-9]+(\.[0-9]+)?$'; then
|
||||
RESOLVED_VERSION="$INPUT_PYTHON_VERSION"
|
||||
else
|
||||
|
||||
2
.github/workflows/pre-commit.yml
vendored
2
.github/workflows/pre-commit.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
# Run the full version spread on push (master/release) and nightly,
|
||||
# but only the current version on PRs — lint/format/type results
|
||||
# rarely differ across patch versions, so 3x per PR is wasteful.
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
|
||||
steps:
|
||||
- name: "Checkout ${{ github.ref }} ( ${{ github.sha }} )"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
@@ -25,7 +25,7 @@ jobs:
|
||||
matrix:
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: superset-extensions-cli
|
||||
|
||||
@@ -135,7 +135,7 @@ jobs:
|
||||
matrix:
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "previous", "next"]') }}
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
SUPERSET_CONFIG: tests.integration_tests.superset_test_config
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
matrix:
|
||||
# Full version spread on push (master/release) + nightly; current only
|
||||
# on PRs to cut runner cost (cross-version breaks are caught at merge).
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["previous", "current", "next"]') }}
|
||||
python-version: ${{ github.event_name == 'pull_request' && fromJSON('["current"]') || fromJSON('["current", "next"]') }}
|
||||
env:
|
||||
PYTHONPATH: ${{ github.workspace }}
|
||||
# Promotes the SQLAlchemy 2.0 deprecation warnings already locked in as
|
||||
|
||||
@@ -135,7 +135,7 @@ Superset sends an HTTP POST with `Content-Type: application/json`:
|
||||
}
|
||||
```
|
||||
|
||||
When a report includes file attachments (CSV, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
When a report includes file attachments (CSV, Excel, PDF, or PNG screenshots), the request is sent as `multipart/form-data` instead. In that case, each top-level payload field (`name`, `text`, `description`, `url`) becomes its own form field, and nested structures like `header` are serialized as a JSON-encoded string in their own field. Every attachment is added as a repeated form field named `files`:
|
||||
|
||||
```
|
||||
POST /webhook HTTP/1.1
|
||||
|
||||
@@ -96,6 +96,26 @@ describe("guest token refresh", () => {
|
||||
expect(timing).toBe(DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("falls back to default timing for a completely malformed token", () => {
|
||||
const timing = getGuestTokenRefreshTiming("not-a-jwt");
|
||||
|
||||
expect(timing).toBe(DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("falls back to default timing for an empty string token", () => {
|
||||
const timing = getGuestTokenRefreshTiming("");
|
||||
|
||||
expect(timing).toBe(DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("falls back to default timing for a token with invalid base64 payload", () => {
|
||||
const timing = getGuestTokenRefreshTiming(
|
||||
"header.!!!invalid-base64!!!.signature",
|
||||
);
|
||||
|
||||
expect(timing).toBe(DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS);
|
||||
});
|
||||
|
||||
it("exposes a positive retry delay for failed token refreshes", () => {
|
||||
// The refresh loop reschedules itself after this delay when a fetch
|
||||
// fails or times out, so it must be a sane positive value.
|
||||
|
||||
@@ -25,16 +25,20 @@ export const DEFAULT_TOKEN_REFRESH_RETRY_MS = 10000; // wait before retrying a f
|
||||
|
||||
// when do we refresh the guest token?
|
||||
export function getGuestTokenRefreshTiming(currentGuestToken: string) {
|
||||
const parsedJwt = jwtDecode<Record<string, any>>(currentGuestToken);
|
||||
// if exp is int, it is in seconds, but Date() takes milliseconds
|
||||
const exp = new Date(
|
||||
/[^0-9\.]/g.test(parsedJwt.exp)
|
||||
? parsedJwt.exp
|
||||
: parseFloat(parsedJwt.exp) * 1000,
|
||||
);
|
||||
const isValidDate = exp.toString() !== "Invalid Date";
|
||||
const ttl = isValidDate
|
||||
? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now())
|
||||
: DEFAULT_TOKEN_EXP_MS;
|
||||
return ttl - REFRESH_TIMING_BUFFER_MS;
|
||||
try {
|
||||
const parsedJwt = jwtDecode<Record<string, any>>(currentGuestToken);
|
||||
// if exp is int, it is in seconds, but Date() takes milliseconds
|
||||
const exp = new Date(
|
||||
/[^0-9\.]/g.test(parsedJwt.exp)
|
||||
? parsedJwt.exp
|
||||
: parseFloat(parsedJwt.exp) * 1000,
|
||||
);
|
||||
const isValidDate = exp.toString() !== "Invalid Date";
|
||||
const ttl = isValidDate
|
||||
? Math.max(MIN_REFRESH_WAIT_MS, exp.getTime() - Date.now())
|
||||
: DEFAULT_TOKEN_EXP_MS;
|
||||
return ttl - REFRESH_TIMING_BUFFER_MS;
|
||||
} catch {
|
||||
return DEFAULT_TOKEN_EXP_MS - REFRESH_TIMING_BUFFER_MS;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
ReactNode,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
SupersetClient,
|
||||
@@ -54,7 +61,7 @@ export default function VerifyCORS({
|
||||
host,
|
||||
method,
|
||||
postPayload,
|
||||
}: Props): JSX.Element {
|
||||
}: Props): ReactElement {
|
||||
const [didVerify, setDidVerify] = useState(false);
|
||||
const [error, setError] = useState<Error | SupersetApiError | undefined>(
|
||||
undefined,
|
||||
|
||||
@@ -75,7 +75,7 @@ module.exports = {
|
||||
// @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/*.|@x0k/.*|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/(?!@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/*.|@x0k/.*|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|react-resize-detector|react-markdown|vfile|vfile-message|trim-lines|trough|is-plain-obj)',
|
||||
],
|
||||
preset: 'ts-jest',
|
||||
transform: {
|
||||
|
||||
@@ -201,7 +201,7 @@
|
||||
"jsx-a11y/aria-role": ["error", { "ignoreNonDOM": false }],
|
||||
"jsx-a11y/aria-unsupported-elements": "error",
|
||||
"jsx-a11y/autocomplete-valid": "error",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "error",
|
||||
"jsx-a11y/control-has-associated-label": "error",
|
||||
"jsx-a11y/heading-has-content": "error",
|
||||
"jsx-a11y/html-has-lang": "error",
|
||||
|
||||
501
superset-frontend/package-lock.json
generated
501
superset-frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -205,20 +205,19 @@
|
||||
"ol": "^10.9.0",
|
||||
"query-string": "9.4.1",
|
||||
"re-resizable": "^6.11.2",
|
||||
"react": "^18.3.0",
|
||||
"react": "^19.2.0",
|
||||
"react-arborist": "^3.13.2",
|
||||
"react-checkbox-tree": "^1.8.0",
|
||||
"react-checkbox-tree": "^2.0.1",
|
||||
"react-diff-viewer-continued": "^4.2.2",
|
||||
"react-dnd": "^11.1.3",
|
||||
"react-dnd-html5-backend": "^11.1.3",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-google-recaptcha": "^3.1.0",
|
||||
"react-intersection-observer": "^10.0.3",
|
||||
"react-json-tree": "^0.20.0",
|
||||
"react-lines-ellipsis": "^0.16.1",
|
||||
"react-loadable": "^5.5.0",
|
||||
"react-redux": "^7.2.9",
|
||||
"react-resize-detector": "^9.1.1",
|
||||
"react-resize-detector": "^12.3.0",
|
||||
"react-reverse-portal": "^2.3.0",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-search-input": "^0.11.3",
|
||||
@@ -263,7 +262,7 @@
|
||||
"@babel/types": "^7.29.7",
|
||||
"@emotion/babel-plugin": "^11.13.5",
|
||||
"@emotion/jest": "^11.14.2",
|
||||
"@formatjs/intl-durationformat": "^0.10.15",
|
||||
"@formatjs/intl-durationformat": "^0.10.16",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@pmmmwh/react-refresh-webpack-plugin": "^0.6.2",
|
||||
@@ -275,9 +274,9 @@
|
||||
"@swc/core": "^1.15.43",
|
||||
"@swc/plugin-emotion": "^14.15.0",
|
||||
"@swc/plugin-transform-imports": "^12.5.0",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^12.8.3",
|
||||
"@types/content-disposition": "^0.5.9",
|
||||
"@types/dom-to-image": "^2.6.7",
|
||||
@@ -288,10 +287,9 @@
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/mousetrap": "^1.6.15",
|
||||
"@types/node": "^26.1.1",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@types/react-loadable": "^5.5.11",
|
||||
"@types/react-redux": "^7.1.10",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@types/react-redux": "^7.1.34",
|
||||
"@types/react-router-dom": "^5.3.3",
|
||||
"@types/react-transition-group": "^4.4.12",
|
||||
"@types/react-window": "^1.8.8",
|
||||
@@ -381,7 +379,7 @@
|
||||
"ace-builds": "^1.41.0",
|
||||
"core-js": "^3.38.1",
|
||||
"handlebars": "^4.7.8",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-ace": "^14.0.1",
|
||||
"regenerator-runtime": "^0.14.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -389,9 +387,53 @@
|
||||
"npm": "^11.13.0"
|
||||
},
|
||||
"overrides": {
|
||||
"react-router-dom": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"react-table": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"react-redux": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"react-virtualized-auto-sizer": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"@reduxjs/toolkit": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"react-ultimate-pagination": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"@visx/axis": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"@visx/grid": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"@visx/responsive": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"@visx/scale": {
|
||||
"react": "^19.2.0"
|
||||
},
|
||||
"@visx/tooltip": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"@visx/xychart": {
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"uuid": "$uuid",
|
||||
"@great-expectations/jsonforms-antd-renderers": {
|
||||
"antd": "$antd"
|
||||
"antd": "$antd",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
"core-js": "^3.38.1",
|
||||
"dompurify": "^3.4.11",
|
||||
|
||||
@@ -89,12 +89,11 @@
|
||||
"typescript": "^5.0.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@types/lodash": "^4.17.24",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*"
|
||||
},
|
||||
@@ -105,9 +104,8 @@
|
||||
"@fontsource/ibm-plex-mono": "^5.2.7",
|
||||
"@fontsource/inter": "^5.2.6",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-loadable": "^5.5.0",
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0",
|
||||
"tinycolor2": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"antd": "^6.0.0",
|
||||
|
||||
@@ -34,16 +34,16 @@
|
||||
"@ant-design/icons": "^5.6.1 || ^6.0.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "*",
|
||||
"ace-builds": "^1.4.14",
|
||||
"brace": "^0.11.1",
|
||||
"memoize-one": "^6.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -36,7 +36,7 @@ import { SQLPopover } from './SQLPopover';
|
||||
export type ColumnOptionProps = {
|
||||
column: ColumnMeta;
|
||||
showType?: boolean;
|
||||
labelRef?: RefObject<any>;
|
||||
labelRef?: RefObject<any | null>;
|
||||
};
|
||||
|
||||
const StyleOverrides = styled.span`
|
||||
|
||||
@@ -47,7 +47,7 @@ export interface MetricOptionProps {
|
||||
showFormula?: boolean;
|
||||
showType?: boolean;
|
||||
url?: string;
|
||||
labelRef?: RefObject<any>;
|
||||
labelRef?: RefObject<any | null>;
|
||||
shouldShowTooltip?: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const TooltipSection = ({
|
||||
</TooltipSectionWrapper>
|
||||
);
|
||||
|
||||
export const isLabelTruncated = (labelRef?: RefObject<any>): boolean =>
|
||||
export const isLabelTruncated = (labelRef?: RefObject<any | null>): boolean =>
|
||||
!!(labelRef?.current?.scrollWidth > labelRef?.current?.clientWidth);
|
||||
|
||||
export const getColumnLabelText = (column: ColumnMeta): string =>
|
||||
@@ -92,7 +92,7 @@ export const getColumnTypeTooltipNode = (column: ColumnMeta): ReactNode => {
|
||||
|
||||
export const getColumnTooltipNode = (
|
||||
column: ColumnMeta,
|
||||
labelRef?: RefObject<any>,
|
||||
labelRef?: RefObject<any | null>,
|
||||
): ReactNode => {
|
||||
if (
|
||||
(!column.column_name || !column.verbose_name) &&
|
||||
@@ -121,7 +121,7 @@ type MetricType = Omit<Metric, 'id' | 'uuid'> & { label?: string };
|
||||
|
||||
export const getMetricTooltipNode = (
|
||||
metric: MetricType,
|
||||
labelRef?: RefObject<any>,
|
||||
labelRef?: RefObject<any | null>,
|
||||
): ReactNode => {
|
||||
if (
|
||||
!metric.verbose_name &&
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactElement, ReactNode, ReactText, ComponentType } from 'react';
|
||||
import { ReactElement, ReactNode, ComponentType } from 'react';
|
||||
|
||||
import type {
|
||||
AdhocColumn,
|
||||
@@ -39,6 +39,9 @@ import type {
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { sharedControls, sharedControlComponents } from './shared-controls';
|
||||
|
||||
// React 19 removed the ReactText type.
|
||||
type ReactText = string | number;
|
||||
|
||||
export type { Metric } from '@superset-ui/core';
|
||||
export type { ControlComponentProps } from './shared-controls/components/types';
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@
|
||||
"react-error-boundary": "^6.1.2",
|
||||
"react-js-cron": "^6.0.2",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resize-detector": "^7.1.2",
|
||||
"react-resize-detector": "^12.3.0",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"react-ultimate-pagination": "^1.3.2",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
@@ -92,19 +92,17 @@
|
||||
"@emotion/cache": "^11.4.0",
|
||||
"@emotion/react": "^11.4.1",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"@types/react-loadable": "*",
|
||||
"@types/react-window": "^1.8.8",
|
||||
"@types/tinycolor2": "*",
|
||||
"antd": "^6.0.0",
|
||||
"nanoid": "^5.0.9",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0",
|
||||
"react-loadable": "^5.5.0",
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0",
|
||||
"tinycolor2": "*"
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
@@ -17,7 +17,14 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import {
|
||||
SupersetClientInterface,
|
||||
RequestConfig,
|
||||
@@ -77,7 +84,7 @@ function ChartDataProvider({
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
}: ChartDataProviderProps): JSX.Element | null {
|
||||
}: ChartDataProviderProps): ReactElement | null {
|
||||
const [state, setState] = useState<ChartDataProviderState>({
|
||||
status: 'uninitialized',
|
||||
});
|
||||
@@ -159,7 +166,7 @@ function ChartDataProvider({
|
||||
const { status, payload, error } = state;
|
||||
|
||||
// Wrap the children result in a Fragment so the component's return type
|
||||
// stays `JSX.Element | null` (which TypeScript requires for JSX components)
|
||||
// stays `ReactElement | null` (which TypeScript requires for JSX components)
|
||||
// while still letting consumers return any ReactNode (strings, fragments,
|
||||
// arrays, null, etc.) from the render prop.
|
||||
switch (status) {
|
||||
|
||||
@@ -186,8 +186,8 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [formData, setFormData] = useState<QueryFormData>();
|
||||
|
||||
const chartClientRef = useRef<ChartClient>();
|
||||
const abortControllerRef = useRef<AbortController>();
|
||||
const chartClientRef = useRef<ChartClient | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
|
||||
// Initialize chart client
|
||||
if (!chartClientRef.current) {
|
||||
@@ -325,7 +325,7 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
}, []);
|
||||
|
||||
// Combined effect for all prop changes and lifecycle
|
||||
const prevPropsRef = useRef<StatefulChartProps>();
|
||||
const prevPropsRef = useRef<StatefulChartProps | null>(null);
|
||||
useEffect(() => {
|
||||
const currentProps = props;
|
||||
const prevProps = prevPropsRef.current;
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -76,9 +77,9 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
/** Prop for form plugins using superchart */
|
||||
showOverflow?: boolean;
|
||||
/** Prop for popovercontainer ref */
|
||||
parentRef?: RefObject<any>;
|
||||
parentRef?: RefObject<any | null>;
|
||||
/** Prop for chart ref */
|
||||
inputRef?: RefObject<any>;
|
||||
inputRef?: RefObject<any | null>;
|
||||
/** Chart width */
|
||||
height?: number | string;
|
||||
/** Chart height */
|
||||
@@ -125,7 +126,7 @@ function SuperChart({
|
||||
height = 400,
|
||||
width = '100%',
|
||||
...rest
|
||||
}: Props): JSX.Element {
|
||||
}: Props): ReactElement {
|
||||
/**
|
||||
* SuperChart's core ref
|
||||
*/
|
||||
@@ -264,7 +265,7 @@ function SuperChart({
|
||||
({ data }) => !data || (Array.isArray(data) && data.length === 0),
|
||||
));
|
||||
|
||||
let chart: JSX.Element;
|
||||
let chart: ReactElement;
|
||||
if (noResultQueries) {
|
||||
chart = noResults ? (
|
||||
<>{noResults}</>
|
||||
|
||||
@@ -42,10 +42,10 @@ function IDENTITY<T>(x: T) {
|
||||
return x;
|
||||
}
|
||||
|
||||
const EMPTY = () => null;
|
||||
const EMPTY = Object.assign(() => null, { preload: () => Promise.resolve() });
|
||||
|
||||
interface LoadingProps {
|
||||
error: { toString(): string };
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
interface LoadedModules {
|
||||
@@ -204,7 +204,7 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{error.toString()}
|
||||
{String(error)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -26,11 +26,21 @@ import {
|
||||
} from 'react';
|
||||
import type {
|
||||
ComponentType,
|
||||
WeakValidationMap,
|
||||
ForwardRefExoticComponent,
|
||||
PropsWithoutRef,
|
||||
RefAttributes,
|
||||
} from 'react';
|
||||
import type { Validator } from 'prop-types';
|
||||
|
||||
// React 19 dropped `WeakValidationMap` from its type exports; keep the original
|
||||
// shape so `renderFn.propTypes` still type-checks.
|
||||
type WeakValidationMap<T> = {
|
||||
[K in keyof T]?: null extends T[K]
|
||||
? Validator<T[K] | null | undefined>
|
||||
: undefined extends T[K]
|
||||
? Validator<T[K] | null | undefined>
|
||||
: Validator<T[K]>;
|
||||
};
|
||||
|
||||
// TODO: Note that id and className can collide between Props and ReactifyProps
|
||||
// leading to (likely) unexpected behaviors. We should either require Props to not
|
||||
@@ -69,6 +79,24 @@ export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
|
||||
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
|
||||
>;
|
||||
|
||||
// Mirrors React's own `defaultProps` rule: a default fills in only where the
|
||||
// caller passed `undefined`.
|
||||
function applyDefaultProps<P extends object>(
|
||||
props: P,
|
||||
defaultProps?: object,
|
||||
): P {
|
||||
if (!defaultProps) {
|
||||
return props;
|
||||
}
|
||||
const merged = { ...props } as Record<string, unknown>;
|
||||
Object.entries(defaultProps).forEach(([key, value]) => {
|
||||
if (merged[key] === undefined) {
|
||||
merged[key] = value;
|
||||
}
|
||||
});
|
||||
return merged as P;
|
||||
}
|
||||
|
||||
// Return the widest public type that covers "use it as a React component" so
|
||||
// TypeScript JSX callers and `ComponentType<...>`-typed variables still compile;
|
||||
// callers with explicit `ComponentClass<...>` annotations must widen to
|
||||
@@ -77,11 +105,18 @@ export type ReactifiedComponent<Props> = ForwardRefExoticComponent<
|
||||
export default function reactify<Props extends object>(
|
||||
renderFn: RenderFuncType<Props>,
|
||||
callbacks?: LifeCycleCallbacks,
|
||||
): ComponentType<Props & ReactifyProps> {
|
||||
): ComponentType<Props & ReactifyProps> & {
|
||||
// Copied off `renderFn` for introspection. React 19 no longer applies these
|
||||
// itself — `applyDefaultProps` does.
|
||||
defaultProps?: Partial<Props & ReactifyProps>;
|
||||
} {
|
||||
const ReactifiedComponent = forwardRef<
|
||||
ReactifiedComponentRef,
|
||||
Props & ReactifyProps
|
||||
>(function ReactifiedComponent(props, ref) {
|
||||
>(function ReactifiedComponent(incomingProps, ref) {
|
||||
// React 19 no longer applies `defaultProps` to function components, so the
|
||||
// defaults declared on `renderFn` are merged here instead.
|
||||
const props = applyDefaultProps(incomingProps, renderFn.defaultProps);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Keep the latest props available to the unmount callback — legacy
|
||||
// consumers read values off `this.props` (e.g. ReactNVD3 uses id).
|
||||
|
||||
@@ -101,7 +101,7 @@ export interface ChartPropsConfig {
|
||||
/** is the chart refreshing its contents */
|
||||
isRefreshing?: boolean;
|
||||
/** chart ref */
|
||||
inputRef?: RefObject<any>;
|
||||
inputRef?: RefObject<any | null>;
|
||||
/** Theme object */
|
||||
theme: SupersetTheme;
|
||||
/* legend index */
|
||||
@@ -152,7 +152,7 @@ export default class ChartProps<FormData extends RawFormData = RawFormData> {
|
||||
|
||||
isRefreshing?: boolean;
|
||||
|
||||
inputRef?: RefObject<any>;
|
||||
inputRef?: RefObject<any | null>;
|
||||
|
||||
inContextMenu?: boolean;
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, userEvent } from '@superset-ui/core/spec';
|
||||
import { render, screen, userEvent, fireEvent } from '@superset-ui/core/spec';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { ActionButton } from '.';
|
||||
|
||||
@@ -45,6 +45,18 @@ test('calls onClick when clicked', async () => {
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls onClick when activated with the keyboard', () => {
|
||||
const onClick = jest.fn();
|
||||
render(<ActionButton {...defaultProps} onClick={onClick} />);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.keyDown(button, { key: 'Enter' });
|
||||
expect(onClick).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.keyDown(button, { key: ' ' });
|
||||
expect(onClick).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('renders with tooltip when tooltip prop is provided', async () => {
|
||||
const tooltipText = 'This is a tooltip';
|
||||
render(<ActionButton {...defaultProps} tooltip={tooltipText} />);
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { handleKeyboardActivation } from '../../utils';
|
||||
import type { ReactElement, ReactNode } from 'react';
|
||||
import { Tooltip, type TooltipPlacement } from '@superset-ui/core/components';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
@@ -55,6 +56,7 @@ export const ActionButton = ({
|
||||
className="action-button"
|
||||
data-test={label}
|
||||
onClick={onClick}
|
||||
onKeyDown={onClick ? handleKeyboardActivation(onClick) : undefined}
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
|
||||
@@ -238,8 +238,8 @@ export function AsyncAceEditor(
|
||||
|
||||
// Move autocomplete popup to the nearest parent container with data-ace-container
|
||||
useEffect(() => {
|
||||
const editorInstance = (ref as React.RefObject<AceEditor>)?.current
|
||||
?.editor;
|
||||
const editorInstance = (ref as React.RefObject<AceEditor | null>)
|
||||
?.current?.editor;
|
||||
if (!editorInstance) return;
|
||||
|
||||
const editorContainer = editorInstance.container;
|
||||
|
||||
@@ -16,7 +16,14 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Children, ReactElement, Fragment, forwardRef, Ref } from 'react';
|
||||
import {
|
||||
Children,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Fragment,
|
||||
forwardRef,
|
||||
Ref,
|
||||
} from 'react';
|
||||
import cx from 'classnames';
|
||||
import { Button as AntdButton } from 'antd';
|
||||
import { useTheme } from '@apache-superset/core/theme';
|
||||
@@ -164,7 +171,9 @@ function ButtonInner(props: ButtonProps, ref: Ref<HTMLElement>) {
|
||||
color,
|
||||
} = resolvedStyleMap[buttonStyle ?? 'primary'] ?? BUTTON_STYLE_MAP.primary;
|
||||
|
||||
const element = children as ReactElement;
|
||||
// React 19 types `ReactElement['props']` as `unknown`, so spell out the
|
||||
// shape read below.
|
||||
const element = children as ReactElement<{ children?: ReactNode }>;
|
||||
|
||||
let renderedChildren = [];
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { ComponentType } from 'react';
|
||||
import { DatePicker as AntdDatePicker } from 'antd';
|
||||
import { css } from '@apache-superset/core/theme';
|
||||
import type { DatePickerProps, RangePickerProps } from './types';
|
||||
@@ -29,6 +30,7 @@ export const DatePicker = (props: DatePickerProps) => (
|
||||
/>
|
||||
);
|
||||
|
||||
export const { RangePicker } = AntdDatePicker;
|
||||
export const RangePicker: ComponentType<RangePickerProps> =
|
||||
AntdDatePicker.RangePicker;
|
||||
|
||||
export type { DatePickerProps, RangePickerProps };
|
||||
|
||||
@@ -16,7 +16,12 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactElement, cloneElement } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
type FocusEventHandler,
|
||||
type KeyboardEventHandler,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { Dropdown as AntdDropdown, DropdownProps } from 'antd';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
@@ -98,10 +103,16 @@ export const MenuDotsDropdown = ({
|
||||
|
||||
export const NoAnimationDropdown = (props: NoAnimationDropdownProps) => {
|
||||
const { children, onBlur, onKeyDown, ...rest } = props;
|
||||
const childrenWithProps = cloneElement(children as ReactElement, {
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
});
|
||||
const childrenWithProps = cloneElement(
|
||||
children as ReactElement<{
|
||||
onBlur?: FocusEventHandler;
|
||||
onKeyDown?: KeyboardEventHandler;
|
||||
}>,
|
||||
{
|
||||
onBlur,
|
||||
onKeyDown,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<AntdDropdown autoFocus overlayStyle={props.overlayStyle} {...rest}>
|
||||
|
||||
@@ -57,7 +57,7 @@ export interface DropdownContainerProps {
|
||||
/**
|
||||
* Dropdown ref.
|
||||
*/
|
||||
dropdownRef?: RefObject<HTMLDivElement>;
|
||||
dropdownRef?: RefObject<HTMLDivElement | null>;
|
||||
/**
|
||||
* Dropdown additional style properties.
|
||||
*/
|
||||
|
||||
@@ -24,7 +24,9 @@ import { BaseIconComponent } from './BaseIcon';
|
||||
|
||||
const AsyncIcon = forwardRef<HTMLSpanElement, IconType>((props, ref) => {
|
||||
const [, setLoaded] = useState(false);
|
||||
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>>>();
|
||||
const ImportedSVG = useRef<FC<SVGProps<SVGSVGElement>> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const { fileName, customIcons, iconSize, iconColor, viewBox, ...restProps } =
|
||||
props;
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type React from 'react';
|
||||
import { fireEvent, render } from '@superset-ui/core/spec';
|
||||
|
||||
import { Icons } from '../Icons';
|
||||
@@ -57,7 +58,11 @@ test('preserves a custom icon color (antd v6 Tag icon-style regression)', () =>
|
||||
|
||||
// test stories from the storybook!
|
||||
test('renders all the storybook gallery variants', () => {
|
||||
const { container } = render(<LabelGallery />);
|
||||
// Cast the storybook fn — its render signature changed in storybook 8
|
||||
const GalleryComponent = LabelGallery as unknown as React.FC<
|
||||
Record<string, never>
|
||||
>;
|
||||
const { container } = render(<GalleryComponent />);
|
||||
const nonInteractiveLabelCount = 4;
|
||||
const renderedLabelCount = options.length * 2 + nonInteractiveLabelCount;
|
||||
expect(container.querySelectorAll('.ant-tag')).toHaveLength(
|
||||
|
||||
@@ -43,14 +43,14 @@ const TWO_DAYS_AGO = '2 days ago';
|
||||
|
||||
const runWithBarCollapsed = async (func: Function) => {
|
||||
const spy = jest.spyOn(resizeDetector, 'useResizeDetector');
|
||||
let width: number;
|
||||
spy.mockImplementation(props => {
|
||||
let width = 0;
|
||||
spy.mockImplementation(((props: any) => {
|
||||
if (props?.onResize && !width) {
|
||||
width = 80;
|
||||
props.onResize(width);
|
||||
props.onResize({ width, height: 0, entry: null as unknown });
|
||||
}
|
||||
return { ref: { current: undefined } };
|
||||
});
|
||||
return { ref: () => {} };
|
||||
}) as never);
|
||||
await func();
|
||||
spy.mockRestore();
|
||||
};
|
||||
|
||||
@@ -17,7 +17,10 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import {
|
||||
useResizeDetector,
|
||||
type OnResizeCallback,
|
||||
} from 'react-resize-detector';
|
||||
import { uniqWith } from 'lodash-es';
|
||||
import { styled } from '@apache-superset/core/theme';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
@@ -181,7 +184,7 @@ export interface MetadataBarProps {
|
||||
* This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
|
||||
*/
|
||||
const MetadataBar = ({ items, tooltipPlacement = 'top' }: MetadataBarProps) => {
|
||||
const [width, setWidth] = useState<number>();
|
||||
const [width, setWidth] = useState<number | undefined>(undefined);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const uniqueItems = uniqWith(items, (a, b) => a.type === b.type);
|
||||
const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]);
|
||||
@@ -193,8 +196,9 @@ const MetadataBar = ({ items, tooltipPlacement = 'top' }: MetadataBarProps) => {
|
||||
throw new Error('The maximum number of items for the metadata bar is 6.');
|
||||
}
|
||||
|
||||
const onResize = useCallback(
|
||||
(width: number | undefined) => {
|
||||
const onResize = useCallback<OnResizeCallback>(
|
||||
payload => {
|
||||
const width = payload.width ?? undefined;
|
||||
// Calculates the breakpoint width to collapse the bar.
|
||||
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
|
||||
const breakpoint =
|
||||
|
||||
@@ -54,7 +54,7 @@ export interface ModalProps {
|
||||
/** @deprecated Use styles.body instead */
|
||||
bodyStyle?: CSSProperties;
|
||||
styles?: { body?: CSSProperties; [key: string]: CSSProperties | undefined };
|
||||
openerRef?: React.RefObject<HTMLElement>;
|
||||
openerRef?: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
export interface StyledModalProps {
|
||||
|
||||
@@ -17,13 +17,14 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { ModalTrigger } from '.';
|
||||
import type { ReactElement } from 'react';
|
||||
|
||||
interface IModalTriggerProps {
|
||||
triggerNode: JSX.Element;
|
||||
triggerNode: ReactElement;
|
||||
dialogClassName?: string;
|
||||
modalTitle?: string;
|
||||
modalBody?: JSX.Element;
|
||||
modalFooter?: JSX.Element;
|
||||
modalBody?: ReactElement;
|
||||
modalFooter?: ReactElement;
|
||||
beforeOpen?: () => void;
|
||||
onExit?: () => void;
|
||||
isButton?: boolean;
|
||||
|
||||
@@ -16,12 +16,13 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { handleKeyboardActivation } from '../../utils';
|
||||
import {
|
||||
forwardRef,
|
||||
ForwardedRef,
|
||||
useState,
|
||||
ReactNode,
|
||||
MouseEvent,
|
||||
SyntheticEvent,
|
||||
} from 'react';
|
||||
|
||||
import { Button } from '../Button';
|
||||
@@ -96,7 +97,7 @@ export const ModalTrigger = forwardRef(
|
||||
onExit?.();
|
||||
};
|
||||
|
||||
const open = (e: MouseEvent) => {
|
||||
const open = (e: SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
beforeOpen?.();
|
||||
setShowModal(true);
|
||||
@@ -126,7 +127,13 @@ export const ModalTrigger = forwardRef(
|
||||
</Button>
|
||||
)}
|
||||
{!isButton && (
|
||||
<div data-test="span-modal-trigger" onClick={open} role="button">
|
||||
<div
|
||||
data-test="span-modal-trigger"
|
||||
onClick={open}
|
||||
onKeyDown={handleKeyboardActivation(open)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
>
|
||||
{triggerNode}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Key } from 'react';
|
||||
import { Key, ReactElement } from 'react';
|
||||
import cx from 'classnames';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -29,7 +29,7 @@ export interface OptionProps {
|
||||
}
|
||||
|
||||
export type OnChangeHandler = (key: Key) => void;
|
||||
export type RenderElementHandler = (option: OptionProps) => JSX.Element;
|
||||
export type RenderElementHandler = (option: OptionProps) => ReactElement;
|
||||
|
||||
export interface PopoverDropdownProps {
|
||||
id: string;
|
||||
|
||||
@@ -16,7 +16,8 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { MouseEventHandler, ReactNode } from 'react';
|
||||
import { handleKeyboardActivation } from '../../utils';
|
||||
import { ReactNode, SyntheticEvent } from 'react';
|
||||
import { css, useTheme } from '@apache-superset/core/theme';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import { Tooltip } from '../Tooltip';
|
||||
@@ -24,7 +25,10 @@ import { Tooltip } from '../Tooltip';
|
||||
export interface PopoverSectionProps {
|
||||
title: string;
|
||||
isSelected?: boolean;
|
||||
onSelect?: MouseEventHandler<HTMLDivElement>;
|
||||
// `SyntheticEvent` (rather than `MouseEventHandler`) so the same callback
|
||||
// can be reused as the keyboard-activation handler via
|
||||
// `handleKeyboardActivation`, which invokes it with a `KeyboardEvent`.
|
||||
onSelect?: (event: SyntheticEvent) => void;
|
||||
info?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
@@ -48,6 +52,7 @@ export default function PopoverSection({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={onSelect}
|
||||
onKeyDown={onSelect ? handleKeyboardActivation(onSelect) : undefined}
|
||||
css={css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -542,7 +542,7 @@ const AsyncSelect = forwardRef(
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
) =>
|
||||
dropDownRenderHelper(
|
||||
originNode,
|
||||
originNode as Parameters<typeof dropDownRenderHelper>[0],
|
||||
isDropdownVisible,
|
||||
isLoading,
|
||||
fullSelectOptions.length,
|
||||
|
||||
@@ -583,10 +583,10 @@ const Select = forwardRef(
|
||||
const isLoading = loading ?? false;
|
||||
|
||||
const popupRender = (
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement | null> },
|
||||
) =>
|
||||
dropDownRenderHelper(
|
||||
originNode,
|
||||
originNode as Parameters<typeof dropDownRenderHelper>[0],
|
||||
isDropdownVisible,
|
||||
isLoading,
|
||||
fullSelectOptions.length,
|
||||
|
||||
@@ -153,14 +153,20 @@ export const getSuffixIcon = (
|
||||
return <Icons.DownOutlined iconSize="s" aria-label="down" />;
|
||||
};
|
||||
|
||||
type FlattenOptionsProps = {
|
||||
flattenOptions?: Array<Record<string, any>>;
|
||||
};
|
||||
|
||||
export const dropDownRenderHelper = (
|
||||
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
|
||||
originNode: ReactElement<FlattenOptionsProps> & {
|
||||
ref?: RefObject<HTMLElement | null>;
|
||||
},
|
||||
isDropdownVisible: boolean,
|
||||
isLoading: boolean | undefined,
|
||||
optionsLength: number,
|
||||
helperText: string | undefined,
|
||||
errorComponent?: JSX.Element,
|
||||
bulkSelectComponents?: JSX.Element,
|
||||
errorComponent?: ReactElement,
|
||||
bulkSelectComponents?: ReactElement,
|
||||
) => {
|
||||
if (!isDropdownVisible) {
|
||||
originNode.ref?.current?.scrollTo({ top: 0 });
|
||||
@@ -172,17 +178,19 @@ export const dropDownRenderHelper = (
|
||||
return errorComponent;
|
||||
}
|
||||
|
||||
const originProps = originNode.props;
|
||||
|
||||
// remap for accessibility for proper item count
|
||||
const accessibilityNode = {
|
||||
...originNode,
|
||||
props: {
|
||||
...originNode.props,
|
||||
flattenOptions: ensureIsArray(originNode.props.flattenOptions).map(
|
||||
...originProps,
|
||||
flattenOptions: ensureIsArray(originProps.flattenOptions).map(
|
||||
(opt: Record<string, any>, idx: number) => ({
|
||||
...opt,
|
||||
data: {
|
||||
...opt.data,
|
||||
'aria-setsize': originNode.props.flattenOptions?.length || 0,
|
||||
'aria-setsize': originProps.flattenOptions?.length || 0,
|
||||
'aria-posinset': idx + 1,
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -23,7 +23,10 @@ import {
|
||||
TableProps as AntTableProps,
|
||||
} from 'antd/es/table';
|
||||
import classNames from 'classnames';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import {
|
||||
useResizeDetector,
|
||||
type OnResizeCallback,
|
||||
} from 'react-resize-detector';
|
||||
import { useEffect, useRef, useState, useCallback, CSSProperties } from 'react';
|
||||
import { VariableSizeGrid as Grid } from 'react-window';
|
||||
import { safeHtmlSpan } from '@superset-ui/core';
|
||||
@@ -84,8 +87,8 @@ const VirtualTable = <RecordType extends object>(
|
||||
allowHTML = false,
|
||||
} = props;
|
||||
const [tableWidth, setTableWidth] = useState<number>(0);
|
||||
const onResize = useCallback((width?: number) => {
|
||||
setTableWidth(width ?? 0);
|
||||
const onResize = useCallback<OnResizeCallback>(payload => {
|
||||
setTableWidth(payload.width ?? 0);
|
||||
}, []);
|
||||
const { ref } = useResizeDetector({ onResize });
|
||||
const theme = useTheme();
|
||||
@@ -126,8 +129,8 @@ const VirtualTable = <RecordType extends object>(
|
||||
(lastColumn.width as number) + Math.floor(tableWidth - totalWidth);
|
||||
}
|
||||
|
||||
const gridRef = useRef<any>();
|
||||
const [connectObject] = useState<any>(() => {
|
||||
const gridRef = useRef<any | null>(null);
|
||||
const [connectObject] = useState<Record<string, unknown>>(() => {
|
||||
const obj = {};
|
||||
Object.defineProperty(obj, 'scrollLeft', {
|
||||
get: () => {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { fireEvent, render } from '@superset-ui/core/spec';
|
||||
import { fireEvent, render, screen } from '@superset-ui/core/spec';
|
||||
import Tabs, { EditableTabs, LineEditableTabs } from './Tabs';
|
||||
|
||||
const defaultItems = [
|
||||
@@ -163,12 +163,14 @@ describe('Tabs', () => {
|
||||
expect(onEditMock).toHaveBeenCalledWith(expect.any(String), 'remove');
|
||||
});
|
||||
|
||||
test('should have default props set correctly', () => {
|
||||
expect(EditableTabs.defaultProps?.type).toBe('editable-card');
|
||||
expect(EditableTabs.defaultProps?.animated).toEqual({
|
||||
inkBar: true,
|
||||
tabPane: false,
|
||||
});
|
||||
test('renders with editable-card defaults', () => {
|
||||
render(
|
||||
<EditableTabs
|
||||
items={[{ key: '1', label: 'Tab 1', children: 'one' }]}
|
||||
/>,
|
||||
);
|
||||
const container = screen.getByText('Tab 1').closest('.ant-tabs');
|
||||
expect(container).toHaveClass('ant-tabs-editable-card');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import type { FC } from 'react';
|
||||
import type { ComponentProps, FC } from 'react';
|
||||
import { css, styled } from '@apache-superset/core/theme';
|
||||
import { Tabs as AntdTabs, TabsProps as AntdTabsProps } from 'antd';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
@@ -132,19 +132,23 @@ const StyledEditableTabs = styled(StyledTabs)`
|
||||
const StyledCloseOutlined = styled(Icons.CloseOutlined)`
|
||||
color: ${({ theme }) => theme.colorIcon};
|
||||
`;
|
||||
export const EditableTabs = Object.assign(StyledEditableTabs, {
|
||||
TabPane: StyledTabPane,
|
||||
const EditableTabsBase: FC<TabsProps> = ({
|
||||
type = 'editable-card',
|
||||
animated = { inkBar: true, tabPane: false },
|
||||
...rest
|
||||
}: TabsProps) => (
|
||||
<StyledEditableTabs type={type} animated={animated} {...rest} />
|
||||
);
|
||||
|
||||
const EditableTabPane: FC<ComponentProps<typeof StyledTabPane>> = ({
|
||||
closeIcon = <StyledCloseOutlined iconSize="s" role="button" tabIndex={0} />,
|
||||
...rest
|
||||
}) => <StyledTabPane closeIcon={closeIcon} {...rest} />;
|
||||
|
||||
export const EditableTabs = Object.assign(EditableTabsBase, {
|
||||
TabPane: EditableTabPane,
|
||||
});
|
||||
|
||||
EditableTabs.defaultProps = {
|
||||
type: 'editable-card',
|
||||
animated: { inkBar: true, tabPane: false },
|
||||
};
|
||||
|
||||
EditableTabs.TabPane.defaultProps = {
|
||||
closeIcon: <StyledCloseOutlined iconSize="s" role="button" tabIndex={0} />,
|
||||
};
|
||||
|
||||
export const StyledLineEditableTabs = styled(EditableTabs)`
|
||||
&.ant-tabs-card > .ant-tabs-nav .ant-tabs-tab {
|
||||
margin: 0 ${({ theme }) => theme.sizeUnit * 4}px;
|
||||
|
||||
@@ -42,7 +42,7 @@ interface TelemetryPixelProps {
|
||||
* @param {string} props.sha - The SHA of Superset that's currently in use.
|
||||
* @param {string} props.build - The build of Superset that's currently in use.
|
||||
* @param {boolean} props.enabled - Runtime opt-out switch; when false the pixel is not rendered.
|
||||
* @returns {JSX.Element | null} The rendered TelemetryPixel component.
|
||||
* @returns {ReactElement | null} The rendered TelemetryPixel component.
|
||||
*/
|
||||
|
||||
const PIXEL_ID = '0d3461e1-abb1-4691-a0aa-5ed50de66af0';
|
||||
|
||||
@@ -122,6 +122,10 @@ test('applies dark theme when background is dark', () => {
|
||||
expect(theme.foregroundColor).toBe('#ffffff');
|
||||
});
|
||||
|
||||
// The props AgGridReact was last rendered with.
|
||||
const agGridProps = () =>
|
||||
(AgGridReact as unknown as jest.Mock).mock.calls.at(-1)?.[0];
|
||||
|
||||
test('forwards ref to AgGridReact', () => {
|
||||
const ref = createRef<AgGridReact>();
|
||||
|
||||
@@ -133,13 +137,14 @@ test('forwards ref to AgGridReact', () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
// Check that AgGridReact was called with the ref
|
||||
expect(AgGridReact).toHaveBeenCalledWith(
|
||||
// React 19 hands `ref` to the component as a regular prop rather than as a
|
||||
// second argument, so assert on the props object itself.
|
||||
expect(agGridProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
rowData: mockRowData,
|
||||
columnDefs: mockColumnDefs,
|
||||
ref,
|
||||
}),
|
||||
expect.any(Object), // ref is passed as second argument
|
||||
);
|
||||
});
|
||||
|
||||
@@ -160,7 +165,7 @@ test('passes all props through to AgGridReact', () => {
|
||||
|
||||
// onGridReady and onFirstDataRendered are intercepted by the component to expose
|
||||
// the grid API on the container element; the wrapped function is passed instead.
|
||||
expect(AgGridReact).toHaveBeenCalledWith(
|
||||
expect(agGridProps()).toEqual(
|
||||
expect.objectContaining({
|
||||
rowData: mockRowData,
|
||||
columnDefs: mockColumnDefs,
|
||||
@@ -169,7 +174,6 @@ test('passes all props through to AgGridReact', () => {
|
||||
pagination: true,
|
||||
paginationPageSize: 10,
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export function Timer({
|
||||
const [clockStr, setClockStr] = useState(
|
||||
startTime && endTime ? fDuration(startTime, endTime) : '00:00:00.00',
|
||||
);
|
||||
const timer = useRef<ReturnType<typeof setInterval>>();
|
||||
const timer = useRef<ReturnType<typeof setInterval> | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
const stopTimer = () => {
|
||||
|
||||
@@ -20,8 +20,8 @@ import { useEffect, useRef, useState, RefObject } from 'react';
|
||||
|
||||
export function useElementOnScreen<T extends Element>(
|
||||
options: IntersectionObserverInit,
|
||||
): [RefObject<T>, boolean] {
|
||||
const containerRef = useRef<T>(null);
|
||||
): [RefObject<T | null>, boolean] {
|
||||
const containerRef = useRef<T | null>(null);
|
||||
const [isSticky, setIsSticky] = useState(false);
|
||||
|
||||
const callback = (entries: IntersectionObserverEntry[]) => {
|
||||
|
||||
@@ -38,9 +38,9 @@ export const truncationCSS = css`
|
||||
*/
|
||||
const useCSSTextTruncation = <T extends HTMLElement>(
|
||||
{ isVertical, isHorizontal } = { isVertical: false, isHorizontal: true },
|
||||
): [RefObject<T>, boolean] => {
|
||||
): [RefObject<T | null>, boolean] => {
|
||||
const [isTruncated, setIsTruncated] = useState(true);
|
||||
const ref = useRef<T>(null);
|
||||
const ref = useRef<T | null>(null);
|
||||
const [offsetWidth, setOffsetWidth] = useState(0);
|
||||
const [scrollWidth, setScrollWidth] = useState(0);
|
||||
const [offsetHeight, setOffsetHeight] = useState(0);
|
||||
|
||||
@@ -30,10 +30,10 @@ const genElements = (
|
||||
offsetWidth: number | undefined,
|
||||
childNodes: any = [],
|
||||
) => {
|
||||
const elementRef: RefObject<Partial<HTMLElement>> = {
|
||||
const elementRef: RefObject<Partial<HTMLElement> | null> = {
|
||||
current: { scrollWidth, clientWidth, childNodes },
|
||||
};
|
||||
const plusRef: RefObject<Partial<HTMLElement>> = {
|
||||
const plusRef: RefObject<Partial<HTMLElement> | null> = {
|
||||
current: { offsetWidth },
|
||||
};
|
||||
return [elementRef, plusRef];
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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 { KeyboardEvent } from 'react';
|
||||
import { handleKeyboardActivation } from './handleKeyboardActivation';
|
||||
|
||||
const makeEvent = (key: string, repeat = false) => {
|
||||
const preventDefault = jest.fn();
|
||||
return {
|
||||
event: { key, repeat, preventDefault } as unknown as KeyboardEvent,
|
||||
preventDefault,
|
||||
};
|
||||
};
|
||||
|
||||
test('invokes the callback and prevents default on Enter', () => {
|
||||
const callback = jest.fn();
|
||||
const { event, preventDefault } = makeEvent('Enter');
|
||||
handleKeyboardActivation(callback)(event);
|
||||
expect(callback).toHaveBeenCalledWith(event);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('invokes the callback and prevents default on Space', () => {
|
||||
const callback = jest.fn();
|
||||
const { event, preventDefault } = makeEvent(' ');
|
||||
handleKeyboardActivation(callback)(event);
|
||||
expect(callback).toHaveBeenCalledWith(event);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores other keys', () => {
|
||||
const callback = jest.fn();
|
||||
const { event, preventDefault } = makeEvent('a');
|
||||
handleKeyboardActivation(callback)(event);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores auto-repeat keydown events fired while a key is held', () => {
|
||||
const callback = jest.fn();
|
||||
const { event, preventDefault } = makeEvent('Enter', true);
|
||||
handleKeyboardActivation(callback)(event);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
// preventDefault still fires on the repeat event itself, matching the
|
||||
// non-repeat Enter case above.
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* 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 { KeyboardEvent } from 'react';
|
||||
|
||||
/**
|
||||
* Builds an `onKeyDown` handler that invokes `callback` when the user presses
|
||||
* Enter or Space, mirroring a click for keyboard users. Pair it with an
|
||||
* element's `onClick` so `role="button"` (or similar) controls are operable
|
||||
* from the keyboard, satisfying `jsx-a11y/click-events-have-key-events`.
|
||||
*
|
||||
* <div role="button" onClick={handleClick}
|
||||
* onKeyDown={handleKeyboardActivation(handleClick)} />
|
||||
*/
|
||||
export function handleKeyboardActivation(
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
) {
|
||||
return (event: KeyboardEvent) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
// Prevent the page from scrolling on Space and stop any duplicate
|
||||
// default activation on Enter.
|
||||
event.preventDefault();
|
||||
// Ignore auto-repeat keydown events fired while the key is held, so
|
||||
// a long press activates the callback once, matching a mouse click.
|
||||
if (event.repeat) {
|
||||
return;
|
||||
}
|
||||
callback(event);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default handleKeyboardActivation;
|
||||
@@ -27,6 +27,7 @@ export { default as makeSingleton } from './makeSingleton';
|
||||
export { default as promiseTimeout } from './promiseTimeout';
|
||||
export { default as removeDuplicates } from './removeDuplicates';
|
||||
export { default as withLabel } from './withLabel';
|
||||
export { handleKeyboardActivation } from './handleKeyboardActivation';
|
||||
export { lruCache } from './lruCache';
|
||||
export { getSelectedText } from './getSelectedText';
|
||||
export * from './featureFlags';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { ComponentType } from 'react';
|
||||
import { ComponentType, ReactElement } from 'react';
|
||||
import { render as renderTestComponent, screen } from '@testing-library/react';
|
||||
import createLoadableRenderer, {
|
||||
LoadableRenderer as LoadableRendererType,
|
||||
@@ -29,8 +29,8 @@ describe('createLoadableRenderer', () => {
|
||||
return <div className="test-component">test</div>;
|
||||
}
|
||||
let loadChartSuccess = jest.fn(() => Promise.resolve(TestComponent));
|
||||
let render: (loaded: { Chart: ComponentType }) => JSX.Element;
|
||||
let loading: () => JSX.Element;
|
||||
let render: (loaded: { Chart: ComponentType }) => ReactElement;
|
||||
let loading: () => ReactElement;
|
||||
let LoadableRenderer: LoadableRendererType<{}>;
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -30,12 +30,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"d3": "^3.5.17",
|
||||
"prop-types": "^15.8.1",
|
||||
"react": "^19.2.7"
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*"
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.2.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,6 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -36,6 +36,6 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,9 +32,9 @@
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -39,6 +39,6 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@apache-superset/core": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,6 @@
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,13 +40,13 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { handleKeyboardActivation } from '@superset-ui/core';
|
||||
import { useRef, useState, useEffect, SyntheticEvent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons';
|
||||
import { Column } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
@@ -67,7 +68,7 @@ const getSortIcon = (sortState: SortState[], colId: string | null) => {
|
||||
const autoOpenFilterAndFocus = async (
|
||||
column: Column,
|
||||
api: GridApi,
|
||||
filterRef: React.RefObject<HTMLDivElement>,
|
||||
filterRef: React.RefObject<HTMLDivElement | null>,
|
||||
setFilterVisible: (visible: boolean) => void,
|
||||
lastFilteredInputPosition?: FilterInputPosition,
|
||||
) => {
|
||||
@@ -186,7 +187,10 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
return undefined;
|
||||
}, [lastFilteredColumn, colId, lastFilteredInputPosition]);
|
||||
|
||||
const handleMenuClick = (e: React.MouseEvent) => {
|
||||
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
|
||||
// used as the keyboard-activation handler via `handleKeyboardActivation`,
|
||||
// which invokes it with a `KeyboardEvent`.
|
||||
const handleMenuClick = (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setMenuVisible(!isMenuVisible);
|
||||
};
|
||||
@@ -201,17 +205,35 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
const menuContent = (
|
||||
<MenuContainer>
|
||||
{shouldShowAsc && (
|
||||
<div onClick={() => applySort('asc')} className="menu-item">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => applySort('asc')}
|
||||
onKeyDown={handleKeyboardActivation(() => applySort('asc'))}
|
||||
className="menu-item"
|
||||
>
|
||||
<ArrowUpOutlined /> {t('Sort Ascending')}
|
||||
</div>
|
||||
)}
|
||||
{shouldShowDesc && (
|
||||
<div onClick={() => applySort('desc')} className="menu-item">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => applySort('desc')}
|
||||
onKeyDown={handleKeyboardActivation(() => applySort('desc'))}
|
||||
className="menu-item"
|
||||
>
|
||||
<ArrowDownOutlined /> {t('Sort Descending')}
|
||||
</div>
|
||||
)}
|
||||
{currentSort && currentSort?.colId === colId && (
|
||||
<div onClick={clearSort} className="menu-item">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={clearSort}
|
||||
onKeyDown={handleKeyboardActivation(clearSort)}
|
||||
className="menu-item"
|
||||
>
|
||||
<span style={{ fontSize: 16 }}>↻</span> {t('Clear Sort')}
|
||||
</div>
|
||||
)}
|
||||
@@ -247,7 +269,13 @@ const CustomHeader: React.FC<CustomHeaderParams> = ({
|
||||
isOpen={isMenuVisible}
|
||||
onClose={() => setMenuVisible(false)}
|
||||
>
|
||||
<div className="three-dots-menu" onClick={handleMenuClick}>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="three-dots-menu"
|
||||
onClick={handleMenuClick}
|
||||
onKeyDown={handleKeyboardActivation(handleMenuClick)}
|
||||
>
|
||||
<KebabMenu />
|
||||
</div>
|
||||
</CustomPopover>
|
||||
|
||||
@@ -16,12 +16,19 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useEffect, useRef, useState, cloneElement } from 'react';
|
||||
import {
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
cloneElement,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from 'react';
|
||||
import { PopoverContainer, PopoverWrapper } from '../../styles';
|
||||
|
||||
interface Props {
|
||||
content: React.ReactNode;
|
||||
children: React.ReactElement;
|
||||
children: ReactElement<{ ref?: Ref<HTMLElement> }>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ import {
|
||||
memo,
|
||||
FunctionComponent,
|
||||
useState,
|
||||
ChangeEvent,
|
||||
FormEvent,
|
||||
useEffect,
|
||||
type RefObject,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import { Constants, ThemedAgGridReact } from '@superset-ui/core/components';
|
||||
@@ -102,14 +103,14 @@ export interface AgGridTableProps {
|
||||
handleCellClicked: (event: CellClickedEvent) => void;
|
||||
handleSelectionChanged: (event: SelectionChangedEvent) => void;
|
||||
filters?: Record<string, DataRecordValue[]> | null;
|
||||
renderTimeComparisonDropdown: () => JSX.Element | null;
|
||||
renderTimeComparisonDropdown: () => ReactElement | null;
|
||||
cleanedTotals: DataRecord;
|
||||
showTotals: boolean;
|
||||
width: number;
|
||||
onColumnStateChange?: (state: AgGridChartStateWithMetadata) => void;
|
||||
onFilterChanged?: (filterModel: Record<string, any>) => void;
|
||||
metricColumns?: string[];
|
||||
gridRef?: RefObject<AgGridReact>;
|
||||
gridRef?: RefObject<AgGridReact | null>;
|
||||
chartState?: AgGridChartState;
|
||||
}
|
||||
|
||||
@@ -246,7 +247,8 @@ const AgGridDataTable: FunctionComponent<AgGridTableProps> = memo(
|
||||
}, []);
|
||||
|
||||
const onFilterTextBoxChanged = useCallback(
|
||||
({ target: { value } }: ChangeEvent<HTMLInputElement>) => {
|
||||
(event: FormEvent<HTMLInputElement>) => {
|
||||
const value = (event.target as HTMLInputElement).value;
|
||||
if (serverPagination) {
|
||||
setSearchValue(value);
|
||||
debouncedSearch(value);
|
||||
|
||||
@@ -23,7 +23,14 @@ import {
|
||||
getTimeFormatterForGranularity,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
type ReactElement,
|
||||
} from 'react';
|
||||
import { isEqual } from 'lodash-es';
|
||||
|
||||
import {
|
||||
@@ -439,7 +446,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[setDataMask, serverPagination],
|
||||
);
|
||||
|
||||
const renderTimeComparisonVisibility = (): JSX.Element => (
|
||||
const renderTimeComparisonVisibility = (): ReactElement => (
|
||||
<TimeComparisonVisibility
|
||||
comparisonColumns={comparisonColumns}
|
||||
selectedComparisonColumns={selectedComparisonColumns}
|
||||
|
||||
@@ -120,7 +120,7 @@ async function detectLastFilteredInput(
|
||||
* Gets complete filter state including SQL conversion and input position detection.
|
||||
*/
|
||||
export async function getCompleteFilterState(
|
||||
gridRef: RefObject<AgGridReact>,
|
||||
gridRef: RefObject<AgGridReact | null>,
|
||||
metricColumns: string[],
|
||||
): Promise<FilterState> {
|
||||
// Capture activeElement before any async operations to detect which input
|
||||
|
||||
@@ -24,7 +24,7 @@ import { FILTER_INPUT_POSITIONS } from '../../src/consts';
|
||||
describe('filterStateManager', () => {
|
||||
describe('getCompleteFilterState', () => {
|
||||
test('should return empty state when gridRef.current is null', async () => {
|
||||
const gridRef = { current: null } as RefObject<AgGridReact>;
|
||||
const gridRef = { current: null } as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -41,7 +41,7 @@ describe('filterStateManager', () => {
|
||||
test('should return empty state when gridRef.current.api is undefined', async () => {
|
||||
const gridRef = {
|
||||
current: { api: undefined } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -68,7 +68,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, ['SUM(revenue)']);
|
||||
|
||||
@@ -138,7 +138,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
@@ -182,7 +182,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
@@ -217,7 +217,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement as something outside the filter
|
||||
const outsideElement = document.createElement('div');
|
||||
@@ -272,7 +272,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement in age filter
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
@@ -306,7 +306,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -324,7 +324,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -356,7 +356,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -378,7 +378,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -422,7 +422,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
@@ -449,7 +449,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -469,7 +469,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
const result = await getCompleteFilterState(gridRef, []);
|
||||
|
||||
@@ -509,7 +509,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement in join AND operator
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
@@ -556,7 +556,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
// Mock activeElement in join OR operator
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
@@ -598,7 +598,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
@@ -641,7 +641,7 @@ describe('filterStateManager', () => {
|
||||
|
||||
const gridRef = {
|
||||
current: { api: mockApi } as any,
|
||||
} as RefObject<AgGridReact>;
|
||||
} as RefObject<AgGridReact | null>;
|
||||
|
||||
Object.defineProperty(document, 'activeElement', {
|
||||
writable: true,
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"geostyler-wfs-parser": "^3.0.1",
|
||||
"ol": "^10.8.0",
|
||||
"polished": "*",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"dayjs": "^1.11.21",
|
||||
"echarts": "*",
|
||||
"memoize-one": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -75,8 +75,12 @@ export default function EchartsMixedTimeseries({
|
||||
return {
|
||||
dataMask: {
|
||||
extraFormData: {
|
||||
// An empty lookup result means the clicked series could not be
|
||||
// resolved through the label map; emitting column filters anyway
|
||||
// would produce bogus `IS NULL` clauses (an empty array makes the
|
||||
// `every` below vacuously true), so clear the filters instead.
|
||||
filters:
|
||||
values.length === 0
|
||||
values.length === 0 || groupbyValues.length === 0
|
||||
? []
|
||||
: currentGroupBy.map((col, idx) => {
|
||||
const val: DataRecordValue[] = groupbyValues.map(v => {
|
||||
@@ -148,10 +152,11 @@ export default function EchartsMixedTimeseries({
|
||||
const drillToDetailFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
const drillByFilters: BinaryQueryObjectFilterClause[] = [];
|
||||
const isFirst = isFirstQuery(seriesIndex);
|
||||
const values = [
|
||||
...(eventParams.name ? [eventParams.name] : []),
|
||||
...((isFirst ? labelMap : labelMapB)[eventParams.seriesName] || []),
|
||||
];
|
||||
const currentGroupBy = isFirst ? formData.groupby : formData.groupbyB;
|
||||
const seriesValues = (isFirst ? labelMap : labelMapB)[seriesName] || [];
|
||||
// Label map values may carry metric/offset labels ahead of the
|
||||
// dimension values — anchor from the tail, like getCrossFilterDataMask.
|
||||
const metricsCount = seriesValues.length - currentGroupBy.length;
|
||||
if (data && xAxis.type === AxisType.Time) {
|
||||
drillToDetailFilters.push({
|
||||
col:
|
||||
@@ -164,31 +169,39 @@ export default function EchartsMixedTimeseries({
|
||||
formattedVal: xValueFormatter(data[0]),
|
||||
});
|
||||
}
|
||||
[
|
||||
...(data && xAxis.type === AxisType.Category ? [xAxis.label] : []),
|
||||
...(isFirst ? formData.groupby : formData.groupbyB),
|
||||
].forEach((dimension, i) =>
|
||||
if (
|
||||
data &&
|
||||
xAxis.type === AxisType.Category &&
|
||||
eventParams.name != null
|
||||
) {
|
||||
drillToDetailFilters.push({
|
||||
col: dimension,
|
||||
col: xAxis.label,
|
||||
op: '==',
|
||||
val: values[i],
|
||||
formattedVal: String(values[i]),
|
||||
}),
|
||||
);
|
||||
|
||||
[...(isFirst ? formData.groupby : formData.groupbyB)].forEach(
|
||||
(dimension, i) =>
|
||||
val: eventParams.name,
|
||||
formattedVal: String(eventParams.name),
|
||||
});
|
||||
}
|
||||
if (metricsCount >= 0) {
|
||||
currentGroupBy.forEach((dimension, i) => {
|
||||
const value = seriesValues[metricsCount + i];
|
||||
drillToDetailFilters.push({
|
||||
col: dimension,
|
||||
op: '==',
|
||||
val: value,
|
||||
formattedVal: String(value),
|
||||
});
|
||||
drillByFilters.push({
|
||||
col: dimension,
|
||||
op: '==',
|
||||
val: values[i],
|
||||
formattedVal: formatSeriesName(values[i], {
|
||||
val: value,
|
||||
formattedVal: formatSeriesName(value, {
|
||||
timeFormatter: getTimeFormatter(formData.dateFormat),
|
||||
numberFormatter: getNumberFormatter(formData.numberFormat),
|
||||
coltype: coltypeMapping?.[getColumnLabel(dimension)],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
const hasCrossFilter =
|
||||
(isFirst && groupby.length > 0) || (!isFirst && groupbyB.length > 0);
|
||||
|
||||
|
||||
@@ -146,10 +146,14 @@ export default function transformProps(
|
||||
columnFormats = {},
|
||||
currencyCodeColumn,
|
||||
} = datasource;
|
||||
const { label_map: labelMap, detected_currency: backendDetectedCurrency } =
|
||||
// "raw" because these are keyed by the backend column labels; the maps
|
||||
// returned to the component are re-keyed by the rendered series names below.
|
||||
const { label_map: rawLabelMap, detected_currency: backendDetectedCurrency } =
|
||||
queriesData[0] as TimeseriesChartDataResponseResult;
|
||||
const { label_map: labelMapB, detected_currency: backendDetectedCurrencyB } =
|
||||
queriesData[1] as TimeseriesChartDataResponseResult;
|
||||
const {
|
||||
label_map: rawLabelMapB,
|
||||
detected_currency: backendDetectedCurrencyB,
|
||||
} = queriesData[1] as TimeseriesChartDataResponseResult;
|
||||
const data1 = (queriesData[0].data || []) as TimeseriesDataRecord[];
|
||||
const data2 = (queriesData[1].data || []) as TimeseriesDataRecord[];
|
||||
const annotationData = getAnnotationData(chartProps);
|
||||
@@ -438,6 +442,16 @@ export default function transformProps(
|
||||
const array = ensureIsArray(chartProps.rawFormData?.time_compare);
|
||||
const inverted = invert(verboseMap);
|
||||
|
||||
// The rendered ECharts series names are display names that can diverge from
|
||||
// the backend `label_map` keys: the metric display name is prepended when
|
||||
// dimensions are present, query identifiers may be appended, and verbose
|
||||
// names replace the raw column labels. Cross-filtering and drill lookups in
|
||||
// EchartsMixedTimeseries resolve the clicked series name through the label
|
||||
// map, so expose maps re-keyed by the rendered series names to keep those
|
||||
// lookups working (#41622).
|
||||
const displayLabelMap: Record<string, string[]> = {};
|
||||
const displayLabelMapB: Record<string, string[]> = {};
|
||||
|
||||
rawSeriesA.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesName = inverted[entryName] || entryName;
|
||||
@@ -457,13 +471,19 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query A)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValues = rawLabelMap?.[seriesName];
|
||||
if (labelMapValues) {
|
||||
displayLabelMap[displayName] = labelMapValues;
|
||||
}
|
||||
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndex);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metrics,
|
||||
labelMap?.[seriesName]?.[0],
|
||||
labelMapValues?.[0],
|
||||
!!contributionMode,
|
||||
);
|
||||
|
||||
@@ -514,7 +534,6 @@ export default function transformProps(
|
||||
rawSeriesB.forEach(entry => {
|
||||
const entryName = String(entry.name || '');
|
||||
const seriesEntry = inverted[entryName] || entryName;
|
||||
const seriesName = `${seriesEntry} (1)`;
|
||||
const colorScaleKey = getOriginalSeries(seriesEntry, array);
|
||||
|
||||
let displayName: string;
|
||||
@@ -531,13 +550,19 @@ export default function transformProps(
|
||||
// When no groupby, format as just the entry name with optional query identifier
|
||||
displayName = showQueryIdentifiers ? `${entryName} (Query B)` : entryName;
|
||||
}
|
||||
|
||||
const labelMapValuesB = rawLabelMapB?.[seriesEntry];
|
||||
if (labelMapValuesB) {
|
||||
displayLabelMapB[displayName] = labelMapValuesB;
|
||||
}
|
||||
|
||||
const axisFormatterConfig = getAxisFormatterConfig(yAxisIndexB);
|
||||
|
||||
const seriesFormatter = getFormatter(
|
||||
axisFormatterConfig.customFormatters,
|
||||
axisFormatterConfig.formatter,
|
||||
metricsB,
|
||||
labelMapB?.[seriesName]?.[0],
|
||||
labelMapValuesB?.[0],
|
||||
!!contributionMode,
|
||||
);
|
||||
|
||||
@@ -809,15 +834,15 @@ export default function transformProps(
|
||||
.filter(key => keys.includes(key))
|
||||
.forEach(key => {
|
||||
const value = forecastValues[key];
|
||||
// if there are no dimensions, key is a verbose name of a metric,
|
||||
// otherwise it is a comma separated string where the first part is metric name
|
||||
// The tooltip key is the rendered series name; resolve it through
|
||||
// the display-keyed maps, whose values lead with the raw metric
|
||||
// label both with and without dimensions. Fall back to the
|
||||
// verbose-name inversion for series absent from the maps.
|
||||
let formatterKey;
|
||||
if (primarySeries.has(key)) {
|
||||
formatterKey =
|
||||
groupby.length === 0 ? inverted[key] : labelMap[key]?.[0];
|
||||
formatterKey = displayLabelMap[key]?.[0] ?? inverted[key];
|
||||
} else {
|
||||
formatterKey =
|
||||
groupbyB.length === 0 ? inverted[key] : labelMapB[key]?.[0];
|
||||
formatterKey = displayLabelMapB[key]?.[0] ?? inverted[key];
|
||||
}
|
||||
const tooltipFormatter = getFormatter(
|
||||
customFormatters,
|
||||
@@ -912,8 +937,8 @@ export default function transformProps(
|
||||
echartOptions: mergedEchartOptions,
|
||||
setDataMask,
|
||||
emitCrossFilters,
|
||||
labelMap,
|
||||
labelMapB,
|
||||
labelMap: displayLabelMap,
|
||||
labelMapB: displayLabelMapB,
|
||||
groupby,
|
||||
groupbyB,
|
||||
seriesBreakdown: rawSeriesA.length,
|
||||
|
||||
@@ -66,7 +66,9 @@ export default function EchartsTimeseries({
|
||||
const echartRef = useRef<EchartsHandler | null>(null);
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
refs.echartRef = echartRef;
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout>>();
|
||||
const clickTimer = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const extraControlRef = useRef<HTMLDivElement>(null);
|
||||
const [extraControlHeight, setExtraControlHeight] = useState(0);
|
||||
useEffect(() => {
|
||||
|
||||
@@ -124,7 +124,7 @@ use([
|
||||
const loadLocale = async (locale: string) => {
|
||||
let lang;
|
||||
try {
|
||||
lang = await import(`echarts/lib/i18n/lang${locale}`);
|
||||
lang = await import(`echarts/i18n/lang${locale}.js`);
|
||||
} catch {
|
||||
// Locale not supported in ECharts
|
||||
}
|
||||
@@ -165,7 +165,7 @@ function Echart(
|
||||
refs.divRef = divRef;
|
||||
}
|
||||
const [didMount, setDidMount] = useState(false);
|
||||
const chartRef = useRef<EChartsType>();
|
||||
const chartRef = useRef<EChartsType | null>(null);
|
||||
const previousQueryEventHandlers = useRef<QueryEventHandlers>([]);
|
||||
const currentSelection = useMemo(
|
||||
() => Object.keys(selectedValues) || [],
|
||||
@@ -174,7 +174,7 @@ function Echart(
|
||||
const previousSelection = useRef<string[]>([]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
getEchartInstance: () => chartRef.current,
|
||||
getEchartInstance: () => chartRef.current ?? undefined,
|
||||
}));
|
||||
|
||||
const locale = useSelector(
|
||||
|
||||
@@ -44,7 +44,7 @@ export type EchartsStylesProps = {
|
||||
|
||||
export type Refs = {
|
||||
echartRef?: Ref<EchartsHandler>;
|
||||
divRef?: RefObject<HTMLDivElement>;
|
||||
divRef?: RefObject<HTMLDivElement | null>;
|
||||
};
|
||||
|
||||
export interface EchartsProps {
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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 } from '@testing-library/react';
|
||||
import {
|
||||
ChartDataResponseResult,
|
||||
DataRecord,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import { GenericDataType } from '@apache-superset/core/common';
|
||||
import transformProps from '../../src/MixedTimeseries/transformProps';
|
||||
import EchartsMixedTimeseries from '../../src/MixedTimeseries/EchartsMixedTimeseries';
|
||||
import {
|
||||
DEFAULT_FORM_DATA,
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps,
|
||||
} from '../../src/MixedTimeseries/types';
|
||||
import Echart from '../../src/components/Echart';
|
||||
import { createEchartsTimeseriesTestChartProps } from '../helpers';
|
||||
|
||||
jest.mock('../../src/components/Echart', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => null),
|
||||
}));
|
||||
|
||||
const mockedEchart = jest.mocked(Echart);
|
||||
|
||||
const ts1 = 1704067200000;
|
||||
const ts2 = 1704153600000;
|
||||
|
||||
/**
|
||||
* Backend-shaped fixture: flattened column names keep the metric label, so
|
||||
* label_map values lead with the metric label ahead of the dimension value.
|
||||
*/
|
||||
function createQueryData(): ChartDataResponseResult {
|
||||
const rows = [
|
||||
{ ds: ts1, 'sum__num, boy': 1, 'sum__num, girl': 2 },
|
||||
{ ds: ts2, 'sum__num, boy': 3, 'sum__num, girl': 4 },
|
||||
];
|
||||
return {
|
||||
annotation_data: null,
|
||||
cache_key: null,
|
||||
cache_timeout: null,
|
||||
cached_dttm: null,
|
||||
queried_dttm: null,
|
||||
data: rows as DataRecord[],
|
||||
colnames: ['ds', 'sum__num, boy', 'sum__num, girl'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
error: null,
|
||||
is_cached: false,
|
||||
query: '',
|
||||
rowcount: rows.length,
|
||||
sql_rowcount: rows.length,
|
||||
stacktrace: null,
|
||||
status: 'success',
|
||||
from_dttm: null,
|
||||
to_dttm: null,
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'sum__num, girl': ['sum__num', 'girl'],
|
||||
},
|
||||
} as ChartDataResponseResult;
|
||||
}
|
||||
|
||||
function setup(
|
||||
formDataOverrides: Partial<EchartsMixedTimeseriesFormData> = {},
|
||||
) {
|
||||
const queryData = createQueryData();
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
defaultFormData: DEFAULT_FORM_DATA as EchartsMixedTimeseriesFormData,
|
||||
defaultVizType: 'mixed_timeseries',
|
||||
defaultQueriesData: [queryData, queryData],
|
||||
formData: {
|
||||
colorScheme: 'bnbColors',
|
||||
metrics: ['sum__num'],
|
||||
metricsB: ['sum__num'],
|
||||
groupby: ['gender'],
|
||||
groupbyB: ['gender'],
|
||||
x_axis: 'ds',
|
||||
viz_type: VizType.MixedTimeseries,
|
||||
...formDataOverrides,
|
||||
},
|
||||
queriesData: [queryData, queryData],
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
const setDataMask = jest.fn();
|
||||
const onContextMenu = jest.fn();
|
||||
const onFocusedSeries = jest.fn();
|
||||
render(
|
||||
<EchartsMixedTimeseries
|
||||
{...transformed}
|
||||
setDataMask={setDataMask}
|
||||
onContextMenu={onContextMenu}
|
||||
onFocusedSeries={onFocusedSeries}
|
||||
emitCrossFilters
|
||||
/>,
|
||||
);
|
||||
const lastCall = mockedEchart.mock.calls[mockedEchart.mock.calls.length - 1];
|
||||
const { eventHandlers } = lastCall[0] as any;
|
||||
return { eventHandlers, setDataMask, onContextMenu, onFocusedSeries };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockedEchart.mockClear();
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries click emits cross-filter with tail-anchored dimension values', () => {
|
||||
const { eventHandlers, setDataMask } = setup();
|
||||
|
||||
eventHandlers.click({ seriesName: 'sum__num, boy', seriesIndex: 0 });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
const dataMask = setDataMask.mock.calls[0][0];
|
||||
// label_map values are ['sum__num', 'boy'] — the metric label must be
|
||||
// skipped and the dimension value emitted.
|
||||
expect(dataMask.extraFormData.filters).toEqual([
|
||||
{ col: 'gender', op: 'IN', val: ['boy'] },
|
||||
]);
|
||||
expect(dataMask.filterState.selectedValues).toEqual(['sum__num, boy']);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries click clears filters when the series misses the label map', () => {
|
||||
const { eventHandlers, setDataMask } = setup();
|
||||
|
||||
eventHandlers.click({ seriesName: 'not a series', seriesIndex: 0 });
|
||||
|
||||
expect(setDataMask).toHaveBeenCalledTimes(1);
|
||||
const dataMask = setDataMask.mock.calls[0][0];
|
||||
// An unresolvable series must not emit bogus IS NULL filters (#41622).
|
||||
expect(dataMask.extraFormData.filters).toEqual([]);
|
||||
expect(dataMask.filterState.value).toBeNull();
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries context menu drills with tail-anchored dimension values', async () => {
|
||||
const { eventHandlers, onContextMenu } = setup();
|
||||
|
||||
await eventHandlers.contextmenu({
|
||||
data: [ts1, 1],
|
||||
seriesName: 'sum__num, boy',
|
||||
seriesIndex: 0,
|
||||
name: '',
|
||||
event: { stop: jest.fn(), event: { clientX: 11, clientY: 22 } },
|
||||
});
|
||||
|
||||
expect(onContextMenu).toHaveBeenCalledTimes(1);
|
||||
const [x, y, payload] = onContextMenu.mock.calls[0];
|
||||
expect(x).toBe(11);
|
||||
expect(y).toBe(22);
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
expect.objectContaining({ col: 'ds', op: '==', val: ts1 }),
|
||||
expect.objectContaining({
|
||||
col: 'gender',
|
||||
op: '==',
|
||||
val: 'boy',
|
||||
formattedVal: 'boy',
|
||||
}),
|
||||
]);
|
||||
expect(payload.drillBy.filters).toEqual([
|
||||
expect.objectContaining({ col: 'gender', op: '==', val: 'boy' }),
|
||||
]);
|
||||
expect(payload.drillBy.groupbyFieldName).toBe('groupby');
|
||||
expect(payload.crossFilter.dataMask.extraFormData.filters).toEqual([
|
||||
{ col: 'gender', op: 'IN', val: ['boy'] },
|
||||
]);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries context menu emits the category x-axis filter', async () => {
|
||||
const { eventHandlers, onContextMenu } = setup({
|
||||
xAxisForceCategorical: true,
|
||||
});
|
||||
|
||||
await eventHandlers.contextmenu({
|
||||
data: ['boy-cat', 1],
|
||||
seriesName: 'sum__num, girl',
|
||||
seriesIndex: 1,
|
||||
name: 'boy-cat',
|
||||
event: { stop: jest.fn(), event: { clientX: 0, clientY: 0 } },
|
||||
});
|
||||
|
||||
const payload = onContextMenu.mock.calls[0][2];
|
||||
expect(payload.drillToDetail).toEqual([
|
||||
expect.objectContaining({
|
||||
col: 'ds',
|
||||
op: '==',
|
||||
val: 'boy-cat',
|
||||
formattedVal: 'boy-cat',
|
||||
}),
|
||||
expect.objectContaining({ col: 'gender', op: '==', val: 'girl' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('EchartsMixedTimeseries hover focuses and unfocuses the series', () => {
|
||||
const { eventHandlers, onFocusedSeries } = setup();
|
||||
|
||||
eventHandlers.mouseover({ seriesName: 'sum__num, boy' });
|
||||
expect(onFocusedSeries).toHaveBeenLastCalledWith('sum__num, boy');
|
||||
|
||||
eventHandlers.mouseout();
|
||||
expect(onFocusedSeries).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
@@ -777,6 +777,198 @@ test('xAxisForceCategorical forces Category axis regardless of Numeric coltype',
|
||||
expect(xAxis.type).toBe(AxisType.Category);
|
||||
});
|
||||
|
||||
// labelMap/labelMapB must be keyed by the rendered series names or the
|
||||
// cross-filter/drill lookups in EchartsMixedTimeseries miss (#41622);
|
||||
// see the re-key comment in transformProps.ts.
|
||||
test('cross-filter label maps are keyed by the rendered series names', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: false },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// The backend label_map is keyed by the flattened column names
|
||||
// ("boy"/"girl") while the rendered series are "sum__num, boy" etc.
|
||||
expect(transformed.labelMap).toEqual({
|
||||
'sum__num, boy': ['boy'],
|
||||
'sum__num, girl': ['girl'],
|
||||
});
|
||||
expect(transformed.labelMapB).toEqual({
|
||||
'sum__num, boy': ['boy'],
|
||||
'sum__num, girl': ['girl'],
|
||||
});
|
||||
});
|
||||
|
||||
test('cross-filter label maps resolve every rendered series name', () => {
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: queriesData,
|
||||
formData: { ...formData, showQueryIdentifiers: true },
|
||||
queriesData,
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const names = (transformed.echartOptions.series as SeriesOption[]).map(
|
||||
series => String(series.name),
|
||||
);
|
||||
expect(names).toHaveLength(4);
|
||||
names
|
||||
.slice(0, transformed.seriesBreakdown)
|
||||
.forEach(name => expect(transformed.labelMap[name]).toBeDefined());
|
||||
names
|
||||
.slice(transformed.seriesBreakdown)
|
||||
.forEach(name => expect(transformed.labelMapB[name]).toBeDefined());
|
||||
});
|
||||
|
||||
test('cross-filter label maps resolve verbose series names to raw label_map values', () => {
|
||||
const verboseRows = [
|
||||
{ ds: 599616000000, sum__num: 1 },
|
||||
{ ds: 599916000000, sum__num: 3 },
|
||||
];
|
||||
const verboseQueryData = createTestQueryData(verboseRows, {
|
||||
label_map: { ds: ['ds'], sum__num: ['sum__num'] },
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [verboseQueryData, verboseQueryData],
|
||||
formData: { ...formData, groupby: [], groupbyB: [] },
|
||||
queriesData: [verboseQueryData, verboseQueryData],
|
||||
datasource: {
|
||||
verboseMap: { sum__num: 'Total Births' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
// rebaseForecastDatum renames data columns to their verbose names, so the
|
||||
// rendered series is "Total Births" while label_map stays keyed by
|
||||
// "sum__num" — the display-keyed map bridges the two.
|
||||
expect(transformed.labelMap['Total Births']).toEqual(['sum__num']);
|
||||
expect(transformed.labelMapB['Total Births']).toEqual(['sum__num']);
|
||||
});
|
||||
|
||||
test('tooltip resolves per-metric formats through the display-keyed label map', () => {
|
||||
// Multi-metric so getCustomFormatter cannot short-circuit on a single
|
||||
// saved metric: the formatter key must come from resolving the rendered
|
||||
// series name through the display-keyed map.
|
||||
const rows = [{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 }];
|
||||
const queryData = createTestQueryData(rows, {
|
||||
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'avg__num, boy': ['avg__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryData, queryData],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
x_axis: 'ds',
|
||||
yAxisFormat: undefined,
|
||||
},
|
||||
queriesData: [queryData, queryData],
|
||||
datasource: {
|
||||
columnFormats: { sum__num: '.2%' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
|
||||
params: unknown,
|
||||
) => string;
|
||||
const html = formatter({
|
||||
value: [599616000000, 0.5],
|
||||
seriesId: 'sum__num, boy',
|
||||
marker: '',
|
||||
color: '#333',
|
||||
});
|
||||
|
||||
expect(html).toContain('50.00%');
|
||||
});
|
||||
|
||||
test('tooltip resolves per-metric formats for secondary-query series', () => {
|
||||
const rowsA = [
|
||||
{ ds: 599616000000, 'sum__num, boy': 0.5, 'avg__num, boy': 1 },
|
||||
];
|
||||
const queryDataA = createTestQueryData(rowsA, {
|
||||
colnames: ['ds', 'sum__num, boy', 'avg__num, boy'],
|
||||
coltypes: [
|
||||
GenericDataType.Temporal,
|
||||
GenericDataType.Numeric,
|
||||
GenericDataType.Numeric,
|
||||
],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'sum__num, boy': ['sum__num', 'boy'],
|
||||
'avg__num, boy': ['avg__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const rowsB = [{ ds: 599616000000, 'count__num, boy': 2.5 }];
|
||||
const queryDataB = createTestQueryData(rowsB, {
|
||||
colnames: ['ds', 'count__num, boy'],
|
||||
coltypes: [GenericDataType.Temporal, GenericDataType.Numeric],
|
||||
label_map: {
|
||||
ds: ['ds'],
|
||||
'count__num, boy': ['count__num', 'boy'],
|
||||
},
|
||||
});
|
||||
const chartProps = createEchartsTimeseriesTestChartProps<
|
||||
EchartsMixedTimeseriesFormData,
|
||||
EchartsMixedTimeseriesProps
|
||||
>({
|
||||
...MIXED_TIMESERIES_CHART_PROPS_DEFAULTS,
|
||||
defaultQueriesData: [queryDataA, queryDataB],
|
||||
formData: {
|
||||
...formData,
|
||||
metrics: ['sum__num', 'avg__num'],
|
||||
metricsB: ['count__num', 'max__num'],
|
||||
x_axis: 'ds',
|
||||
yAxisFormat: undefined,
|
||||
yAxisFormatSecondary: undefined,
|
||||
yAxisIndex: 0,
|
||||
yAxisIndexB: 1,
|
||||
},
|
||||
queriesData: [queryDataA, queryDataB],
|
||||
datasource: {
|
||||
columnFormats: { count__num: '.1f' },
|
||||
},
|
||||
});
|
||||
const transformed = transformProps(chartProps);
|
||||
|
||||
const formatter = (transformed.echartOptions.tooltip as any).formatter as (
|
||||
params: unknown,
|
||||
) => string;
|
||||
const html = formatter({
|
||||
value: [599616000000, 2.5],
|
||||
seriesId: 'count__num, boy',
|
||||
marker: '',
|
||||
color: '#333',
|
||||
});
|
||||
|
||||
expect(html).toContain('2.5');
|
||||
});
|
||||
|
||||
test('temporal x coltype wires the time formatter and Time axis', () => {
|
||||
// Regression guard: the happy path for mixed-timeseries charts. Ensures
|
||||
// Temporal coltype still routes through the TimeFormatter so the time axis
|
||||
|
||||
@@ -40,9 +40,9 @@
|
||||
"handlebars": "^4.7.8",
|
||||
"lodash": "^4.18.1",
|
||||
"dayjs": "^1.11.21",
|
||||
"react": "^18.3.0",
|
||||
"react-ace": "^10.1.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-ace": "^14.0.1",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^30.0.0",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { ReactNode } from 'react';
|
||||
import { ReactNode, ReactElement } from 'react';
|
||||
|
||||
interface ControlHeaderProps {
|
||||
children: ReactNode;
|
||||
@@ -24,7 +24,7 @@ interface ControlHeaderProps {
|
||||
|
||||
export const ControlHeader = ({
|
||||
children,
|
||||
}: ControlHeaderProps): JSX.Element => (
|
||||
}: ControlHeaderProps): ReactElement => (
|
||||
<div className="ControlHeader">
|
||||
<div className="pull-left">{children}</div>
|
||||
</div>
|
||||
|
||||
@@ -33,8 +33,8 @@
|
||||
"@superset-ui/core": "*",
|
||||
"lodash": "^4.18.1",
|
||||
"prop-types": "*",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/types": "^7.29.7",
|
||||
|
||||
@@ -20,13 +20,14 @@
|
||||
import {
|
||||
ReactNode,
|
||||
MouseEvent,
|
||||
SyntheticEvent,
|
||||
useState,
|
||||
useCallback,
|
||||
useRef,
|
||||
useMemo,
|
||||
useEffect,
|
||||
} from 'react';
|
||||
import { safeHtmlSpan } from '@superset-ui/core';
|
||||
import { safeHtmlSpan, handleKeyboardActivation } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import PropTypes from 'prop-types';
|
||||
@@ -154,7 +155,10 @@ function displayCell(value: unknown, allowRenderHtml?: boolean): ReactNode {
|
||||
function displayHeaderCell(
|
||||
needToggle: boolean,
|
||||
ArrowIcon: ReactNode,
|
||||
onArrowClick: ((e: MouseEvent<HTMLSpanElement>) => void) | null,
|
||||
// `SyntheticEvent` (rather than `MouseEvent`) so this callback can also be
|
||||
// used as the keyboard-activation handler via `handleKeyboardActivation`,
|
||||
// which invokes it with a `KeyboardEvent`.
|
||||
onArrowClick: ((e: SyntheticEvent) => void) | null,
|
||||
value: unknown,
|
||||
namesMapping: Record<string, string>,
|
||||
allowRenderHtml?: boolean,
|
||||
@@ -172,6 +176,9 @@ function displayHeaderCell(
|
||||
tabIndex={0}
|
||||
className="toggle"
|
||||
onClick={onArrowClick || undefined}
|
||||
onKeyDown={
|
||||
onArrowClick ? handleKeyboardActivation(onArrowClick) : undefined
|
||||
}
|
||||
>
|
||||
{ArrowIcon}
|
||||
</span>
|
||||
@@ -462,7 +469,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
);
|
||||
|
||||
const toggleRowKey = useCallback(
|
||||
(flatRowKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
|
||||
(flatRowKey: string) => (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedRows(state => ({
|
||||
...state,
|
||||
@@ -473,7 +480,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
);
|
||||
|
||||
const toggleColKey = useCallback(
|
||||
(flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
|
||||
(flatColKey: string) => (e: SyntheticEvent) => {
|
||||
e.stopPropagation();
|
||||
setCollapsedCols(state => ({
|
||||
...state,
|
||||
@@ -1046,6 +1053,7 @@ export function TableRenderer(props: TableRendererProps) {
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onKeyDown={e => e.stopPropagation()}
|
||||
aria-label={
|
||||
activeSortColumn === i
|
||||
? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
|
||||
@@ -1533,4 +1541,3 @@ TableRenderer.propTypes = {
|
||||
tableOptions: PropTypes.object,
|
||||
onContextMenu: PropTypes.func,
|
||||
};
|
||||
TableRenderer.defaultProps = { ...PivotData.defaultProps, tableOptions: {} };
|
||||
|
||||
@@ -36,8 +36,8 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -40,14 +40,14 @@
|
||||
"@apache-superset/core": "*",
|
||||
"@superset-ui/chart-controls": "*",
|
||||
"@superset-ui/core": "*",
|
||||
"@testing-library/dom": "^9.3.4",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "*",
|
||||
"@testing-library/react": "^14.0.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "*",
|
||||
"@types/react": "*",
|
||||
"match-sorter": "^8.2.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
DragEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
import { typedMemo, usePrevious } from '@superset-ui/core';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -79,10 +80,10 @@ export interface DataTableProps<D extends object> extends TableOptions<D> {
|
||||
noResults?: string | ((filterString: string) => ReactNode);
|
||||
sticky?: boolean;
|
||||
rowCount: number;
|
||||
wrapperRef?: MutableRefObject<HTMLDivElement>;
|
||||
wrapperRef?: MutableRefObject<HTMLDivElement | null>;
|
||||
onColumnOrderChange?: () => void;
|
||||
renderGroupingHeaders?: () => JSX.Element;
|
||||
renderTimeComparisonDropdown?: () => JSX.Element;
|
||||
renderGroupingHeaders?: () => ReactElement;
|
||||
renderTimeComparisonDropdown?: () => ReactElement;
|
||||
handleSortByChange: (sortBy: SortByItem[]) => void;
|
||||
sortByFromParent: SortByItem[];
|
||||
manualSearch?: boolean;
|
||||
@@ -138,7 +139,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
onFilteredDataChange,
|
||||
onFilteredRowsChange,
|
||||
...moreUseTableOptions
|
||||
}: DataTableProps<D>): JSX.Element {
|
||||
}: DataTableProps<D>): ReactElement {
|
||||
const tableHooks: PluginHook<D>[] = [
|
||||
useGlobalFilter,
|
||||
useSortBy,
|
||||
@@ -343,7 +344,7 @@ export default typedMemo(function DataTable<D extends object>({
|
||||
if (!columns || columns.length === 0) {
|
||||
return (
|
||||
wrapStickyTable ? wrapStickyTable(getNoResults) : getNoResults()
|
||||
) as JSX.Element;
|
||||
) as ReactElement;
|
||||
}
|
||||
|
||||
const shouldRenderFooter = columns.some(x => !!x.Footer);
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
ComponentPropsWithRef,
|
||||
CSSProperties,
|
||||
UIEventHandler,
|
||||
type JSX,
|
||||
} from 'react';
|
||||
import { TableInstance, Hooks } from 'react-table';
|
||||
import { useTheme, css } from '@apache-superset/core/theme';
|
||||
|
||||
@@ -26,7 +26,7 @@ export default function useMountedMemo<T>(
|
||||
factory: () => T,
|
||||
deps?: unknown[],
|
||||
): T | undefined {
|
||||
const mounted = useRef<typeof factory>();
|
||||
const mounted = useRef<typeof factory | null>(null);
|
||||
useLayoutEffect(() => {
|
||||
mounted.current = factory;
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
KeyboardEvent as ReactKeyboardEvent,
|
||||
useEffect,
|
||||
useRef,
|
||||
ReactElement,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -765,7 +766,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[comparisonLabels],
|
||||
);
|
||||
|
||||
const renderTimeComparisonDropdown = (): JSX.Element => {
|
||||
const renderTimeComparisonDropdown = (): ReactElement => {
|
||||
const allKey = comparisonColumns[0].key;
|
||||
const handleOnClick = (data: any) => {
|
||||
const { key } = data;
|
||||
@@ -872,7 +873,7 @@ export default function TableChart<D extends DataRecord = DataRecord>(
|
||||
[visibleColumnsMeta, getHeaderColumns, isUsingTimeComparison],
|
||||
);
|
||||
|
||||
const renderGroupingHeaders = (): JSX.Element => {
|
||||
const renderGroupingHeaders = (): ReactElement => {
|
||||
// TODO: Make use of ColumnGroup to render the aditional headers
|
||||
const headers: any = [];
|
||||
let currentColumnIndex = 0;
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@superset-ui/core": "*",
|
||||
"@types/lodash": "*",
|
||||
"@types/react": "*",
|
||||
"react": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/d3-cloud": "^1.2.9"
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import { isCustomControlItem } from '@superset-ui/chart-controls';
|
||||
import controlPanel from '../src/plugin/controlPanel';
|
||||
import React, { ReactElement } from 'react';
|
||||
import React, { type ReactElement } from 'react';
|
||||
|
||||
const isNameControl = (
|
||||
item: unknown,
|
||||
|
||||
@@ -68,8 +68,8 @@
|
||||
"@superset-ui/core": "*",
|
||||
"dayjs": "^1.11.21",
|
||||
"mapbox-gl": ">=1.0.0",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
"react": "^18.3.0 || ^19.0.0",
|
||||
"react-dom": "^18.3.0 || ^19.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"mapbox-gl": {
|
||||
|
||||
@@ -112,7 +112,7 @@ export const DeckGLContainer = memo(
|
||||
}, [props.layers]);
|
||||
|
||||
const isCustomTooltip = (content: ReactNode): boolean =>
|
||||
isValidElement(content) &&
|
||||
isValidElement<{ 'data-tooltip-type'?: string }>(content) &&
|
||||
content.props?.['data-tooltip-type'] === 'custom';
|
||||
|
||||
const renderTooltip = (tooltipState: TooltipProps['tooltip']) => {
|
||||
|
||||
@@ -116,7 +116,7 @@ const selectDataMask = createSelector(
|
||||
);
|
||||
|
||||
const DeckMulti = (props: DeckMultiProps) => {
|
||||
const containerRef = useRef<DeckGLContainerHandle>();
|
||||
const containerRef = useRef<DeckGLContainerHandle | null>(null);
|
||||
|
||||
const dataMask = useSelector(selectDataMask);
|
||||
|
||||
|
||||
@@ -93,7 +93,7 @@ export function createDeckGLComponent(
|
||||
) {
|
||||
// Higher order component
|
||||
return memo((props: DeckGLComponentProps) => {
|
||||
const containerRef = useRef<DeckGLContainerHandle>();
|
||||
const containerRef = useRef<DeckGLContainerHandle | null>(null);
|
||||
const prevFormData = usePrevious(props.formData);
|
||||
const prevFilterState = usePrevious(props.filterState);
|
||||
const prevPayload = usePrevious(props.payload);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user