Compare commits

...

40 Commits

Author SHA1 Message Date
Mehmet Salih Yavuz
d7f4a6b0e2 style: prettier formatting for the React 19 changes 2026-07-16 18:35:16 +03:00
Mehmet Salih Yavuz
cc450b91bb fix: keep user-event 12 call sites and move the ReactText alias
The local ReactText alias (React 19 removed the type) sat between imports,
tripping oxlint's import/first — move it below them.

Restore userEvent.paste(element, text) in two tests: they had been migrated
to user-event 14's one-argument signature, and the v14 bump is not part of
this branch.
2026-07-16 18:35:07 +03:00
Mehmet Salih Yavuz
ec5bd3eba1 test: adapt AG Grid tests to React 19; keep user-event at 12
React 19 commits AG Grid's rows/header after the initial render rather than
during it, so assertions that read the DOM synchronously after render() now
wait for the grid. The grid itself renders fine — only the timing moved.

React 19 also passes ref as a regular prop instead of a second argument, so
ThemedAgGridReact asserts on the props object rather than the call arity.

Drop the @testing-library/user-event 14 bump: it is not required (12.8.3
already peers @testing-library/dom >=7.21.4, which dom 10 satisfies) and v14's
async API would silently break ~1000 sync call sites across the suite. RTL 16
+ dom 10 are kept, which is what React 19 actually needs.
2026-07-16 14:47:52 +03:00
Mehmet Salih Yavuz
c786adbc01 fix(types): React 19 type errors against current master
Re-derived on top of master's refactors (the original per-file fixes were
written before those files were rewritten):

- JSX.Element -> ReactElement (React 19 drops the global JSX namespace)
- useRef() -> useRef(null) where React 19 now requires an initial value
- RefObject<T> -> RefObject<T | null> on props fed by useRef(null)
- ReactElement<P> where element.props is now unknown
- ConnectDragSource cast to Ref, matching the existing Column.tsx convention
- dropzoneReducer: drop the default param so useReducer infers non-optional state
- SaveModal/ScheduleQueryButton: use master's ModalTriggerRef API
- SliceHeaderControls: metaKey click via fireEvent (user-event 14 drops the
  event-init argument)
2026-07-16 14:28:24 +03:00
Mehmet Salih Yavuz
e012349eaf fix(superset-ui-core): restore reactify defaultProps + WeakValidationMap under React 19
React 19 ignores defaultProps on function/forwardRef components, so
reactify silently dropped the defaults declared on renderFn. Merge them
explicitly using React's own rule (default applies only where the prop is
undefined).

React 19 also removed WeakValidationMap from its type exports; redefine it
locally from prop-types' Validator.
2026-07-16 14:11:01 +03:00
Mehmet Salih Yavuz
51a03144ec chore: transform ESM-only deps for jest
react-resize-detector (bumped to 12 for React 19), react-markdown, vfile and
friends ship ESM only, so babel-jest has to transform them.
2026-07-16 14:03:37 +03:00
Mehmet Salih Yavuz
1da86f26f7 fix(types): final round of React 19 type errors
- Type cloneElement targets with explicit ReactElement<Props> for
  CopyToClipboard, DrillDetailPane, TabsRenderer, ControlRow
- Update ResizePayload mock in MetadataBar test for v12 onResize API
- userEvent v14: paste(text) replaces paste(el, text) — focus first
- ListView filtersRef now accepts nullable inner type
- ExploreContainer useReducer cast to declared dispatch tuple
- Replace Tabs defaultProps test with a behavioural rendering check
- Cast Storybook story functions to FC in tests (storybook 8 sig change)
2026-07-16 14:02:46 +03:00
Mehmet Salih Yavuz
c4aa582ad1 fix(types): React 19 RefObject<T> -> RefObject<T | null>
React 19 made RefObject<T> require a non-null T. useRef returns
RefObject<T | null>. Widen prop type annotations across the codebase so
consumers accept the new shape. Also remove leftover unused ReactElement
imports from the JSX namespace migration.
2026-07-16 14:02:34 +03:00
Mehmet Salih Yavuz
b727ee180f fix(types): more React 19 type fixes
- AutoSizer: revert to v1 (v2 changes children API significantly,
  defer to a follow-up)
- ModalTrigger: split ModalTriggerImperativeApi (handle shape) from
  ModalTriggerRef (RefObject); update consumers
- Replace ReactChild (removed in React 19) with ReactNode
- d3-scale typing: cast createLinearScale results to include never extras
- Various cloneElement consumers typed with proper ReactElement<Props>
2026-07-16 14:01:45 +03:00
Mehmet Salih Yavuz
83051f8aee fix(types): React 19 element.props is unknown — type ReactElement<P>
Update consumers that read element.props in:
- ag-grid-table CustomPopover/CustomHeader (typed cloneElement targets)
- deckgl DeckGLContainer / common (isCustomTooltip)
- echart Refs/getEchartInstance return shape
- Drop ReactText (removed in React 19); declare local alias
- Type DatePicker.RangePicker explicitly to avoid portability issue
2026-07-16 14:00:27 +03:00
Mehmet Salih Yavuz
a35952269c fix(superset-ui-core): React 19 type errors in components/hooks
- Type cloneElement/Children consumers with explicit ReactElement<Props>
  (React 19 narrowed element.props from any to unknown)
- Update useResizeDetector onResize callbacks for v12 ResizePayload shape
- useRef nullable types in useElementOnScreen and useCSSTextTruncation
2026-07-16 14:00:27 +03:00
Mehmet Salih Yavuz
644ae87081 fix(types): React 19 JSX namespace, useRef, and createLoadableRenderer
- Replace JSX.Element with ReactElement (JSX namespace no longer global)
- useRef<T>() -> useRef<T | null>(null) (React 19 requires initial value)
- Drop WeakValidationMap import (removed in React 19); declare local version
- Update createLoadableRenderer typings to be JSX-component compatible
- Loosen LoadingProps error type to unknown
2026-07-16 13:59:22 +03:00
Mehmet Salih Yavuz
be9dc1fb27 refactor: migrate defaultProps to default destructuring on function components
React 19 ignores defaultProps on function components silently. Move
defaults into function parameter destructuring (MetricsControl,
EditableTabs/EditableTabPane in superset-ui-core/Tabs). Drop the
redundant TableRenderer.defaultProps in pivot-table — PivotData already
applies its own defaults internally. Class component defaultProps are
left intact (still supported).
2026-07-16 13:58:01 +03:00
Mehmet Salih Yavuz
3221b6f893 fix: small React 19 API compatibility fixes
- Switch `act` imports from react-dom/test-utils to react
- Replace `useRef() as ModalTriggerRef` with proper typed initial value
- Update useReducer generic signature from legacy Reducer<S, A> to <S, [A]>
2026-07-16 13:57:16 +03:00
Mehmet Salih Yavuz
2b67d225dc chore(deps): bump react ecosystem to 19
- Update react, react-dom, @types/react, @types/react-dom to ^19.2.0
- Update package/plugin peer deps to "^18.2.0 || ^19.0.0"
- Add overrides for react-router-dom, react-table, react-ultimate-pagination,
  @visx/*, @great-expectations/jsonforms-antd-renderers to allow React 19
- Bump @testing-library/react ^14 -> ^16, @testing-library/dom ^9 -> ^10,
  @testing-library/user-event ^12 -> ^14
- Bump react-redux 7 -> 9 (drop @types/react-redux; types bundled)
- Bump redux 4 -> 5, redux-thunk 2 -> 3, @reduxjs/toolkit 1 -> 2
- Bump react-resize-detector 9 -> 12, react-virtualized-auto-sizer 1 -> 2,
  react-checkbox-tree 1 -> 2, react-arborist 3.5 -> 3.6
- Bump react-error-boundary, react-markdown, react-ace to React 19-compatible versions
- Move react out of dependencies in legacy-plugin-chart-chord to peerDependencies
2026-07-16 13:56:32 +03:00
dependabot[bot]
e84f870787 chore(deps-dev): bump @formatjs/intl-durationformat from 0.10.15 to 0.10.16 in /superset-frontend (#42105)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-16 02:45:07 -07:00
Joe Li
de300c70b9 test(app-root): close two blind spots in the subdirectory redirect tests (#42016)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 23:36:47 -07:00
Greg Neighbors
2f7afe4b47 feat(mcp): deleted_state trash listing for list_charts and list_dashboards (#41855)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 23:36:04 -07:00
lohit geddam
aea4585c6d fix(embedded-sdk): handle malformed JWT refresh timing (#40490) 2026-07-15 23:35:21 -07:00
Evan Rusackas
f697a0c24d test(charts): assert drill-to-detail carries applied filters (#28562) (#41960)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:33 -07:00
Evan Rusackas
13d38a9cbd test(security): assert intended trailing-slash behavior of security API (#29934) (#41965)
Co-authored-by: Claude Code <noreply@anthropic.com>
2026-07-15 22:24:15 -07:00
yousoph
8603048518 fix(dashboard): block dependent filter from fetching until defaultToFirstItem parent selects (#40978)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:01:56 -07:00
Mike Bridge
635b18103d fix(reports): null-guard execution against missing target (#39973)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
2026-07-15 22:00:46 -07:00
gr33nak
d57569c54a feat(reports): add XLSX (Excel) attachments for Alerts & Reports (#41424)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Martin Kominek <martin.kominek@stratox.cz>
Co-authored-by: kominma3 <127758497+kominma3@users.noreply.github.com>
2026-07-15 21:49:47 -07:00
Benedict Jin
409605de70 fix(plugin-chart-echarts): key Mixed Timeseries label maps by rendered series names (#41933) 2026-07-15 21:37:59 -07:00
Greg Neighbors
8ce6d42942 feat(mcp): add restore_chart and restore_dashboard tools (#41842)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:51:15 -07:00
Greg Neighbors
2bbb7d0638 feat(mcp): histogram and box plot chart type plugins (#41860)
Co-authored-by: Greg Neighbors <gregneighbors@Gregs-Air-2.lan>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 15:50:09 -07:00
Evan Rusackas
1b0c6aaed3 chore(a11y): enable jsx-a11y/click-events-have-key-events as error (#42009)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:49:45 -07:00
dependabot[bot]
1f786f1949 chore(deps): bump websocket-driver from 0.7.4 to 0.7.5 in /superset-frontend (#42094)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-15 15:49:15 -07:00
Evan Rusackas
c540f782a3 fix(ci): remove Python 3.10 from the test matrix to unblock CI (#42058)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 15:48:47 -07:00
Joe Li
6fe4655ba2 fix(semantic-layers): separate grain and offset time-axis resolvers (#42093)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 15:33:52 -07:00
Elizabeth Thompson
689fc34ac2 fix: replace deprecated appbuilder.app with current_app in test_explore_redirect (#42086) 2026-07-15 15:05:15 -07:00
Elizabeth Thompson
e564389a01 fix(a11y): associate Name label with input in CssTemplateModal (#42081) 2026-07-15 15:04:33 -07:00
Elizabeth Thompson
da518d7a10 fix(a11y): associate Description label with TagModal input (#42034) 2026-07-15 15:03:40 -07:00
Joe Li
c6da740ce2 fix(app-root): restore legacy redirects for HEAD and the /superset app root (#42015)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 13:13:46 -07:00
Beto Dealmeida
e28b259de0 fix(semantic-layers): expose time grains in Explore for SemanticView datasources (#41456) 2026-07-15 12:25:41 -07:00
Elizabeth Thompson
beb9d53687 fix(plugin-chart-echarts): use echarts 5.6.0 i18n export path for locale import (#42055)
Co-authored-by: Claude <noreply@anthropic.com>
2026-07-15 11:45:14 -07:00
Mike Bridge
90f9238f8a fix(charts): preserve time filter for expression axes (#42052)
Co-authored-by: Mike Bridge <michael.bridge@ext.preset.io>
2026-07-15 14:27:50 -03:00
Amin Ghadersohi
f38fff2a19 test(mcp): close systematic test-coverage gaps in mcp_service (#41924) 2026-07-15 12:30:04 -04:00
Amin Ghadersohi
cec9afb165 fix(mcp): await ctx.info calls in update_dashboard tool (#41920)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-15 12:28:44 -04:00
313 changed files with 9340 additions and 1249 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -47,7 +47,7 @@ export interface MetricOptionProps {
showFormula?: boolean;
showType?: boolean;
url?: string;
labelRef?: RefObject<any>;
labelRef?: RefObject<any | null>;
shouldShowTooltip?: boolean;
}

View File

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

View File

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

View File

@@ -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": {

View File

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

View File

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

View File

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

View File

@@ -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>&nbsp;
<code>chartType=&quot;{loadingChartType}&quot;</code> &mdash;
{error.toString()}
{String(error)}
</div>
);
}

View File

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

View File

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

View File

@@ -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} />);

View File

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

View File

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

View File

@@ -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 = [];

View File

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

View File

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

View File

@@ -57,7 +57,7 @@ export interface DropdownContainerProps {
/**
* Dropdown ref.
*/
dropdownRef?: RefObject<HTMLDivElement>;
dropdownRef?: RefObject<HTMLDivElement | null>;
/**
* Dropdown additional style properties.
*/

View File

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

View File

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

View File

@@ -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();
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -542,7 +542,7 @@ const AsyncSelect = forwardRef(
originNode: ReactElement & { ref?: RefObject<HTMLElement> },
) =>
dropDownRenderHelper(
originNode,
originNode as Parameters<typeof dropDownRenderHelper>[0],
isDropdownVisible,
isLoading,
fullSelectOptions.length,

View File

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

View File

@@ -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,
},
}),

View File

@@ -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: () => {

View File

@@ -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');
});
});

View File

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

View File

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

View File

@@ -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),
);
});

View File

@@ -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 = () => {

View File

@@ -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[]) => {

View File

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

View File

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

View File

@@ -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();
});

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(() => {

View File

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

View File

@@ -44,7 +44,7 @@ export type EchartsStylesProps = {
export type Refs = {
echartRef?: Ref<EchartsHandler>;
divRef?: RefObject<HTMLDivElement>;
divRef?: RefObject<HTMLDivElement | null>;
};
export interface EchartsProps {

View File

@@ -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);
});

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: {} };

View File

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

View File

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

View File

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

View File

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

View File

@@ -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;
});

View File

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

View File

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

View File

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

View File

@@ -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": {

View File

@@ -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']) => {

View File

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

View File

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