You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/02/11 17:16:10 UTC
(superset) 01/01: chore(lint): convert class components to function components
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch chore/lint-cleanup-function-components
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 5da915a37346c2fe36a7b89d6abb776bec5f1c32
Author: Evan Rusackas <ev...@rusackas.com>
AuthorDate: Wed Feb 11 09:15:51 2026 -0800
chore(lint): convert class components to function components
Convert all remaining React class components to function components
using hooks (useState, useCallback, useEffect, useRef, useMemo) to
satisfy the react-prefer-function-component ESLint rule.
Key changes:
- Converted components in dashboard, explore, SqlLab, and Chart areas
- Updated associated test files with proper typing
- Fixed JSX.Element return types for components used as JSX
- Added explicit ControlHeader props where needed
- Fixed shouldFocus callback signature in WithPopoverMenu usage
Notable exceptions (not converted):
- ErrorBoundary (uses componentDidCatch)
- DragDroppable (react-dnd requires class instances)
Co-Authored-By: Claude Opus 4.5 <no...@anthropic.com>
---
.../src/chart-composition/ChartFrame.tsx | 63 +-
.../src/chart-composition/legend/WithLegend.tsx | 167 +-
.../src/chart-composition/tooltip/TooltipFrame.tsx | 28 +-
.../src/chart/components/ChartDataProvider.tsx | 194 +-
.../src/chart/components/SuperChart.tsx | 425 +++--
.../src/chart/components/SuperChartCore.tsx | 376 ++--
.../src/chart/components/reactify.tsx | 109 +-
.../test/chart/components/SuperChartCore.test.tsx | 26 +-
.../test/chart/components/reactify.test.tsx | 84 +-
.../storybook/shared/components/Expandable.tsx | 53 +-
.../storybook/shared/components/VerifyCORS.tsx | 113 +-
.../NumberFormat.stories.tsx | 198 +-
.../superset-ui-time-format/TimeFormat.stories.tsx | 175 +-
.../src/HorizonChart.tsx | 101 +-
.../legacy-plugin-chart-horizon/src/HorizonRow.tsx | 268 ++-
.../legacy-plugin-chart-map-box/src/MapBox.tsx | 198 +-
.../src/ScatterPlotGlowOverlay.tsx | 573 +++---
.../src/PairedTTest.tsx | 71 +-
.../src/TTestTable.tsx | 500 ++---
.../src/react-pivottable/PivotTable.tsx | 10 +-
.../src/react-pivottable/TableRenderers.tsx | 1914 ++++++++++----------
.../src/chart/WordCloud.tsx | 262 +--
.../src/SqlLab/components/App/App.test.tsx | 4 +-
.../src/SqlLab/components/App/index.tsx | 196 +-
.../SqlLab/components/TabbedSqlEditors/index.tsx | 284 +--
superset-frontend/src/components/Chart/Chart.tsx | 480 ++---
.../src/components/Chart/ChartRenderer.tsx | 783 ++++----
.../src/components/CopyToClipboard/index.tsx | 141 +-
.../CollectionTable/CollectionTable.test.tsx | 2 +-
.../components/CollectionTable/index.tsx | 640 ++++---
.../DatasourceEditor/DatasourceEditor.tsx | 1817 +++++++++----------
.../tests/DatasourceEditor.test.tsx | 3 +-
.../tests/DatasourceEditorCurrency.test.tsx | 14 +-
.../src/components/ErrorBoundary/index.tsx | 1 +
.../src/dashboard/components/Dashboard.tsx | 356 ++--
.../dashboard/components/DashboardBuilder/utils.ts | 7 +-
.../src/dashboard/components/DashboardGrid.tsx | 468 +++--
.../dashboard/components/PublishedStatus/index.tsx | 96 +-
.../src/dashboard/components/SliceAdder.tsx | 536 +++---
.../components/UndoRedoKeyListeners/index.tsx | 69 +-
.../src/dashboard/components/dnd/DragDroppable.tsx | 20 +-
.../components/filterscope/FilterScopeModal.tsx | 62 +-
.../components/filterscope/FilterScopeSelector.tsx | 822 ++++-----
.../components/gridComponents/Chart/Chart.tsx | 4 +-
.../components/gridComponents/Divider/Divider.tsx | 81 +-
.../components/gridComponents/Header/Header.tsx | 262 ++-
.../gridComponents/Markdown/Markdown.tsx | 517 +++---
.../gridComponents/new/DraggableNewComponent.tsx | 68 +-
.../components/menu/BackgroundStyleDropdown.tsx | 30 +-
.../src/dashboard/components/menu/HoverMenu.tsx | 71 +-
.../components/menu/MarkdownModeDropdown.tsx | 29 +-
.../components/menu/WithPopoverMenu.test.tsx | 8 +-
.../dashboard/components/menu/WithPopoverMenu.tsx | 252 ++-
.../src/explore/components/SaveModal.tsx | 1235 ++++++-------
.../AnnotationLayerControl/AnnotationLayer.tsx | 1265 +++++++------
.../controls/AnnotationLayerControl/index.tsx | 360 ++--
.../components/controls/CheckboxControl.tsx | 49 +-
.../controls/CollectionControl/index.tsx | 258 +--
.../controls/DatasourceControl/index.tsx | 739 ++++----
.../FilterControl/AdhocFilterControl/index.tsx | 441 +++--
.../FilterControl/AdhocFilterEditPopover/index.tsx | 643 +++----
.../AdhocFilterPopoverTrigger/index.tsx | 138 +-
.../controls/FixedOrMetricControl/index.tsx | 274 ++-
.../MetricControl/AdhocMetricEditPopover/index.tsx | 868 ++++-----
.../controls/MetricControl/AdhocMetricOption.tsx | 104 +-
.../MetricControl/AdhocMetricPopoverTrigger.tsx | 488 +++--
.../explore/components/controls/SelectControl.tsx | 329 ++--
.../explore/components/controls/SpatialControl.tsx | 347 ++--
.../components/controls/TextAreaControl.test.tsx | 4 +-
.../components/controls/TextAreaControl.tsx | 390 ++--
.../components/controls/TextControl/index.tsx | 169 +-
.../controls/TimeSeriesColumnControl/index.tsx | 413 +++--
.../components/controls/ViewportControl.tsx | 102 +-
superset-frontend/src/features/home/RightMenu.tsx | 48 +-
.../src/pages/ChartCreation/ChartCreation.test.tsx | 44 +-
.../src/pages/ChartCreation/index.tsx | 421 +++--
76 files changed, 11649 insertions(+), 11511 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
index 88cf52ec72f..91836814e9f 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/ChartFrame.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { PureComponent, ReactNode } from 'react';
+import { memo, ReactNode } from 'react';
import { isDefined } from '../utils';
@@ -29,7 +29,7 @@ type Props = {
contentWidth?: number;
contentHeight?: number;
height: number;
- renderContent: ({
+ renderContent?: ({
height,
width,
}: {
@@ -39,36 +39,35 @@ type Props = {
width: number;
};
-export default class ChartFrame extends PureComponent<Props, {}> {
- static defaultProps = {
- renderContent() {},
- };
+function ChartFrame({
+ contentWidth,
+ contentHeight,
+ width,
+ height,
+ renderContent = () => null,
+}: Props) {
+ const overflowX = checkNumber(contentWidth) && contentWidth > width;
+ const overflowY = checkNumber(contentHeight) && contentHeight > height;
- render() {
- const { contentWidth, contentHeight, width, height, renderContent } =
- this.props;
-
- const overflowX = checkNumber(contentWidth) && contentWidth > width;
- const overflowY = checkNumber(contentHeight) && contentHeight > height;
-
- if (overflowX || overflowY) {
- return (
- <div
- style={{
- height,
- overflowX: overflowX ? 'auto' : 'hidden',
- overflowY: overflowY ? 'auto' : 'hidden',
- width,
- }}
- >
- {renderContent({
- height: Math.max(contentHeight ?? 0, height),
- width: Math.max(contentWidth ?? 0, width),
- })}
- </div>
- );
- }
-
- return renderContent({ height, width });
+ if (overflowX || overflowY) {
+ return (
+ <div
+ style={{
+ height,
+ overflowX: overflowX ? 'auto' : 'hidden',
+ overflowY: overflowY ? 'auto' : 'hidden',
+ width,
+ }}
+ >
+ {renderContent({
+ height: Math.max(contentHeight ?? 0, height),
+ width: Math.max(contentWidth ?? 0, width),
+ })}
+ </div>
+ );
}
+
+ return <>{renderContent({ height, width })}</>;
}
+
+export default memo(ChartFrame);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
index a2cac92ee33..48e7309cdab 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/legend/WithLegend.tsx
@@ -17,26 +17,19 @@
* under the License.
*/
-import { CSSProperties, ReactNode, PureComponent } from 'react';
+import { CSSProperties, ReactNode, memo, useMemo } from 'react';
import { ParentSize } from '@visx/responsive';
-const defaultProps = {
- className: '',
- height: 'auto' as number | string,
- position: 'top',
- width: 'auto' as number | string,
-};
-
type Props = {
- className: string;
+ className?: string;
debounceTime?: number;
- width: number | string;
- height: number | string;
+ width?: number | string;
+ height?: number | string;
legendJustifyContent?: 'center' | 'flex-start' | 'flex-end';
- position: 'top' | 'left' | 'bottom' | 'right';
+ position?: 'top' | 'left' | 'bottom' | 'right';
renderChart: (dim: { width: number; height: number }) => ReactNode;
renderLegend?: (params: { direction: string }) => ReactNode;
-} & Readonly<typeof defaultProps>;
+};
const LEGEND_STYLE_BASE: CSSProperties = {
display: 'flex',
@@ -52,95 +45,101 @@ const CHART_STYLE_BASE: CSSProperties = {
position: 'relative',
};
-class WithLegend extends PureComponent<Props, {}> {
- static defaultProps = defaultProps;
-
- getContainerDirection(): CSSProperties['flexDirection'] {
- const { position } = this.props;
-
- if (position === 'left') {
- return 'row';
- }
- if (position === 'right') {
- return 'row-reverse';
- }
- if (position === 'bottom') {
- return 'column-reverse';
- }
-
- return 'column';
+function getContainerDirection(
+ position: Props['position'],
+): CSSProperties['flexDirection'] {
+ if (position === 'left') {
+ return 'row';
+ }
+ if (position === 'right') {
+ return 'row-reverse';
+ }
+ if (position === 'bottom') {
+ return 'column-reverse';
}
- getLegendJustifyContent() {
- const { legendJustifyContent, position } = this.props;
- if (legendJustifyContent) {
- return legendJustifyContent;
- }
-
- if (position === 'left' || position === 'right') {
- return 'flex-start';
- }
+ return 'column';
+}
- return 'flex-end';
+function getLegendJustifyContent(
+ legendJustifyContent: Props['legendJustifyContent'],
+ position: Props['position'],
+) {
+ if (legendJustifyContent) {
+ return legendJustifyContent;
}
- render() {
- const {
- className,
- debounceTime,
- width,
- height,
- position,
- renderChart,
- renderLegend,
- } = this.props;
+ if (position === 'left' || position === 'right') {
+ return 'flex-start';
+ }
- const isHorizontal = position === 'left' || position === 'right';
+ return 'flex-end';
+}
- const style: CSSProperties = {
+function WithLegend({
+ className = '',
+ debounceTime,
+ width = 'auto',
+ height = 'auto',
+ legendJustifyContent,
+ position = 'top',
+ renderChart,
+ renderLegend,
+}: Props) {
+ const isHorizontal = position === 'left' || position === 'right';
+
+ const style: CSSProperties = useMemo(
+ () => ({
display: 'flex',
- flexDirection: this.getContainerDirection(),
+ flexDirection: getContainerDirection(position),
height,
width,
- };
+ }),
+ [position, height, width],
+ );
- const chartStyle: CSSProperties = { ...CHART_STYLE_BASE };
+ const chartStyle: CSSProperties = useMemo(() => {
+ const baseStyle = { ...CHART_STYLE_BASE };
if (isHorizontal) {
- chartStyle.width = 0;
+ baseStyle.width = 0;
} else {
- chartStyle.height = 0;
+ baseStyle.height = 0;
}
+ return baseStyle;
+ }, [isHorizontal]);
- const legendDirection = isHorizontal ? 'column' : 'row';
- const legendStyle: CSSProperties = {
+ const legendDirection = isHorizontal ? 'column' : 'row';
+ const legendStyle: CSSProperties = useMemo(
+ () => ({
...LEGEND_STYLE_BASE,
flexDirection: legendDirection,
- justifyContent: this.getLegendJustifyContent(),
- };
-
- return (
- <div className={`with-legend ${className}`} style={style}>
- {renderLegend && (
- <div className="legend-container" style={legendStyle}>
- {renderLegend({
- // Pass flexDirection for @vx/legend to arrange legend items
- direction: legendDirection,
- })}
- </div>
- )}
- <div className="main-container" style={chartStyle}>
- <ParentSize debounceTime={debounceTime}>
- {(parent: { width: number; height: number }) =>
- parent.width > 0 && parent.height > 0
- ? // Only render when necessary
- renderChart(parent)
- : null
- }
- </ParentSize>
+ justifyContent: getLegendJustifyContent(legendJustifyContent, position),
+ }),
+ [legendDirection, legendJustifyContent, position],
+ );
+
+ return (
+ <div className={`with-legend ${className}`} style={style}>
+ {renderLegend && (
+ <div className="legend-container" style={legendStyle}>
+ {renderLegend({
+ // Pass flexDirection for @vx/legend to arrange legend items
+ direction: legendDirection,
+ })}
</div>
+ )}
+ <div className="main-container" style={chartStyle}>
+ <ParentSize debounceTime={debounceTime}>
+ {(parent: { width: number; height: number }) =>
+ parent.width > 0 && parent.height > 0
+ ? // Only render when necessary
+ renderChart(parent)
+ : null
+ }
+ </ParentSize>
</div>
- );
- }
+ </div>
+ );
}
-export default WithLegend;
+export default memo(WithLegend);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx b/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
index d470a229403..19008654316 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart-composition/tooltip/TooltipFrame.tsx
@@ -17,31 +17,21 @@
* under the License.
*/
-import { PureComponent, ReactNode } from 'react';
-
-const defaultProps = {
- className: '',
-};
+import { memo, ReactNode } from 'react';
type Props = {
className?: string;
children: ReactNode;
-} & Readonly<typeof defaultProps>;
+};
const CONTAINER_STYLE = { padding: 8 };
-class TooltipFrame extends PureComponent<Props, {}> {
- static defaultProps = defaultProps;
-
- render() {
- const { className, children } = this.props;
-
- return (
- <div className={className} style={CONTAINER_STYLE}>
- {children}
- </div>
- );
- }
+function TooltipFrame({ className = '', children }: Props) {
+ return (
+ <div className={className} style={CONTAINER_STYLE}>
+ {children}
+ </div>
+ );
}
-export default TooltipFrame;
+export default memo(TooltipFrame);
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
index 457bdd9fefb..a7776fbf0dc 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
@@ -17,8 +17,7 @@
* under the License.
*/
-/* eslint react/sort-comp: 'off' */
-import { PureComponent, ReactNode } from 'react';
+import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import {
SupersetClientInterface,
RequestConfig,
@@ -67,103 +66,102 @@ export type ChartDataProviderState = {
error?: ProvidedProps['error'];
};
-class ChartDataProvider extends PureComponent<
- ChartDataProviderProps,
- ChartDataProviderState
-> {
- readonly chartClient: ChartClient;
-
- constructor(props: ChartDataProviderProps) {
- super(props);
- this.state = { status: 'uninitialized' };
- this.chartClient = new ChartClient({ client: props.client });
- }
-
- componentDidMount() {
- this.handleFetchData();
- }
-
- componentDidUpdate(prevProps: ChartDataProviderProps) {
- const { formData, sliceId } = this.props;
- if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
- this.handleFetchData();
- }
- }
-
- private extractSliceIdAndFormData() {
- const { formData, sliceId } = this.props;
- return formData ? { formData } : { sliceId: sliceId as number };
- }
-
- private handleFetchData = () => {
- const {
- loadDatasource,
- formDataRequestOptions,
- datasourceRequestOptions,
- queryRequestOptions,
- } = this.props;
-
- this.setState({ status: 'loading' }, () => {
- try {
- this.chartClient
- .loadFormData(
- this.extractSliceIdAndFormData(),
- formDataRequestOptions,
- )
- .then(formData =>
- Promise.all([
- loadDatasource
- ? this.chartClient.loadDatasource(
- formData.datasource,
- datasourceRequestOptions,
- )
- : Promise.resolve(undefined),
- this.chartClient.loadQueryData(formData, queryRequestOptions),
- ]).then(
- ([datasource, queriesData]) =>
- // eslint-disable-next-line @typescript-eslint/consistent-type-assertions
- ({
- datasource,
- formData,
- queriesData,
- }) as Payload,
- ),
- )
- .then(this.handleReceiveData)
- .catch(this.handleError);
- } catch (error) {
- this.handleError(error as Error);
- }
- });
- };
-
- private handleReceiveData = (payload?: Payload) => {
- const { onLoaded } = this.props;
- if (onLoaded) onLoaded(payload);
- this.setState({ payload, status: 'loaded' });
- };
-
- private handleError = (error: ProvidedProps['error']) => {
- const { onError } = this.props;
- if (onError) onError(error);
- this.setState({ error, status: 'error' });
- };
-
- render() {
- const { children } = this.props;
- const { status, payload, error } = this.state;
-
- switch (status) {
- case 'loading':
- return children({ loading: true });
- case 'loaded':
- return children({ payload });
- case 'error':
- return children({ error });
- case 'uninitialized':
- default:
- return null;
+function ChartDataProvider({
+ children,
+ client,
+ formData,
+ sliceId,
+ loadDatasource,
+ onError,
+ onLoaded,
+ formDataRequestOptions,
+ datasourceRequestOptions,
+ queryRequestOptions,
+}: ChartDataProviderProps) {
+ const [state, setState] = useState<ChartDataProviderState>({
+ status: 'uninitialized',
+ });
+
+ const chartClient = useMemo(() => new ChartClient({ client }), [client]);
+
+ const extractSliceIdAndFormData = useCallback(
+ (): SliceIdAndOrFormData =>
+ formData ? { formData } : { sliceId: sliceId as number },
+ [formData, sliceId],
+ );
+
+ const handleReceiveData = useCallback(
+ (payload?: Payload) => {
+ if (onLoaded) onLoaded(payload);
+ setState({ payload, status: 'loaded' });
+ },
+ [onLoaded],
+ );
+
+ const handleError = useCallback(
+ (error: ProvidedProps['error']) => {
+ if (onError) onError(error);
+ setState({ error, status: 'error' });
+ },
+ [onError],
+ );
+
+ const handleFetchData = useCallback(() => {
+ setState({ status: 'loading' });
+ try {
+ chartClient
+ .loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
+ .then(loadedFormData =>
+ Promise.all([
+ loadDatasource
+ ? chartClient.loadDatasource(
+ loadedFormData.datasource,
+ datasourceRequestOptions,
+ )
+ : Promise.resolve(undefined),
+ chartClient.loadQueryData(loadedFormData, queryRequestOptions),
+ ]).then(
+ ([datasource, queriesData]) =>
+ ({
+ datasource,
+ formData: loadedFormData,
+ queriesData,
+ }) as Payload,
+ ),
+ )
+ .then(handleReceiveData)
+ .catch(handleError);
+ } catch (error) {
+ handleError(error as Error);
}
+ }, [
+ chartClient,
+ extractSliceIdAndFormData,
+ formDataRequestOptions,
+ loadDatasource,
+ datasourceRequestOptions,
+ queryRequestOptions,
+ handleReceiveData,
+ handleError,
+ ]);
+
+ // Fetch data on mount and when formData or sliceId changes
+ useEffect(() => {
+ handleFetchData();
+ }, [formData, sliceId, handleFetchData]);
+
+ const { status, payload, error } = state;
+
+ switch (status) {
+ case 'loading':
+ return children({ loading: true });
+ case 'loaded':
+ return children({ payload });
+ case 'error':
+ return children({ error });
+ case 'uninitialized':
+ default:
+ return null;
}
}
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
index 553a98f568a..3a91e58c8bb 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChart.tsx
@@ -21,8 +21,10 @@ import {
ReactNode,
RefObject,
ComponentType,
- PureComponent,
Fragment,
+ useCallback,
+ useMemo,
+ useRef,
} from 'react';
import {
@@ -32,22 +34,19 @@ import {
} from 'react-error-boundary';
import { ParentSize } from '@visx/responsive';
import { createSelector } from 'reselect';
-import { withTheme } from '@emotion/react';
+import { useTheme } from '@emotion/react';
import { parseLength, Dimension } from '../../dimension';
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
-import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
+import SuperChartCore, {
+ Props as SuperChartCoreProps,
+ SuperChartCoreRef,
+} from './SuperChartCore';
import DefaultFallbackComponent from './FallbackComponent';
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
import NoResultsComponent from './NoResultsComponent';
import { isMatrixifyEnabled } from '../types/matrixify';
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
-
-const defaultProps = {
- FallbackComponent: DefaultFallbackComponent,
- height: 400 as string | number,
- width: '100%' as string | number,
- enableNoResults: true,
-};
+import { SupersetTheme } from '@apache-superset/core/ui';
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
@@ -102,215 +101,261 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
inContextMenu?: boolean;
};
-type PropsWithDefault = Props & Readonly<typeof defaultProps>;
-
-class SuperChart extends PureComponent<Props, {}> {
+function SuperChart({
+ id,
+ className,
+ chartType,
+ preTransformProps,
+ overrideTransformProps,
+ postTransformProps,
+ onRenderSuccess,
+ onRenderFailure,
+ disableErrorBoundary,
+ FallbackComponent = DefaultFallbackComponent,
+ onErrorBoundary,
+ Wrapper,
+ queriesData,
+ enableNoResults = true,
+ noResults,
+ theme: themeProp,
+ debounceTime,
+ height = 400,
+ width = '100%',
+ ...rest
+}: Props): JSX.Element {
/**
- * SuperChart's core
+ * SuperChart's core ref
*/
- core?: SuperChartCore | null;
+ const coreRef = useRef<SuperChartCoreRef | null>(null);
- private createChartProps = ChartProps.createSelector();
+ // Use theme from hook, falling back to prop if provided
+ const themeFromContext = useTheme() as SupersetTheme;
+ const theme = themeProp ?? themeFromContext;
- private parseDimension = createSelector(
- [
- ({ width }: { width: string | number; height: string | number }) => width,
- ({ height }) => height,
- ],
- (width, height) => {
- // Parse them in case they are % or 'auto'
- const widthInfo = parseLength(width);
- const heightInfo = parseLength(height);
- const boxHeight = heightInfo.isDynamic
- ? `${heightInfo.multiplier * 100}%`
- : heightInfo.value;
- const boxWidth = widthInfo.isDynamic
- ? `${widthInfo.multiplier * 100}%`
- : widthInfo.value;
- const style = {
- height: boxHeight,
- width: boxWidth,
- };
+ const createChartProps = useMemo(() => ChartProps.createSelector(), []);
- // bounding box will ensure that when one dimension is not dynamic
- // e.g. height = 300
- // the auto size will be bound to that value instead of being 100% by default
- // e.g. height: 300 instead of height: '100%'
- const BoundingBox =
- widthInfo.isDynamic &&
- heightInfo.isDynamic &&
- widthInfo.multiplier === 1 &&
- heightInfo.multiplier === 1
- ? Fragment
- : ({ children }: { children: ReactNode }) => (
- <div style={style}>{children}</div>
- );
+ const parseDimension = useMemo(
+ () =>
+ createSelector(
+ [
+ ({ width: w }: { width: string | number; height: string | number }) =>
+ w,
+ ({
+ height: h,
+ }: {
+ width: string | number;
+ height: string | number;
+ }) => h,
+ ],
+ (w, h) => {
+ // Parse them in case they are % or 'auto'
+ const widthInfo = parseLength(w);
+ const heightInfo = parseLength(h);
+ const boxHeight = heightInfo.isDynamic
+ ? `${heightInfo.multiplier * 100}%`
+ : heightInfo.value;
+ const boxWidth = widthInfo.isDynamic
+ ? `${widthInfo.multiplier * 100}%`
+ : widthInfo.value;
+ const style = {
+ height: boxHeight,
+ width: boxWidth,
+ };
- return { BoundingBox, heightInfo, widthInfo };
- },
+ // bounding box will ensure that when one dimension is not dynamic
+ // e.g. height = 300
+ // the auto size will be bound to that value instead of being 100% by default
+ // e.g. height: 300 instead of height: '100%'
+ const BoundingBox =
+ widthInfo.isDynamic &&
+ heightInfo.isDynamic &&
+ widthInfo.multiplier === 1 &&
+ heightInfo.multiplier === 1
+ ? Fragment
+ : ({ children }: { children: ReactNode }) => (
+ <div style={style}>{children}</div>
+ );
+
+ return { BoundingBox, heightInfo, widthInfo };
+ },
+ ),
+ [],
);
- static defaultProps = defaultProps;
+ const setRef = useCallback((core: SuperChartCoreRef | null) => {
+ coreRef.current = core;
+ }, []);
- private setRef = (core: SuperChartCore | null) => {
- this.core = core;
- };
+ const getQueryCount = useCallback(
+ () => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
+ [chartType],
+ );
- private getQueryCount = () =>
- getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
+ const renderChart = useCallback(
+ (chartWidth: number, chartHeight: number) => {
+ const chartProps = createChartProps({
+ ...rest,
+ queriesData,
+ height: chartHeight,
+ width: chartWidth,
+ theme,
+ });
- renderChart(width: number, height: number) {
- const {
- id,
- className,
- chartType,
- preTransformProps,
- overrideTransformProps,
- postTransformProps,
- onRenderSuccess,
- onRenderFailure,
- disableErrorBoundary,
- FallbackComponent,
- onErrorBoundary,
- Wrapper,
- queriesData,
- enableNoResults,
- noResults,
- theme,
- ...rest
- } = this.props as PropsWithDefault;
+ // Check if Matrixify is enabled - use rawFormData (snake_case)
+ const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
- const chartProps = this.createChartProps({
- ...rest,
- queriesData,
- height,
- width,
- theme,
- });
+ if (matrixifyEnabled) {
+ // When matrixify is enabled, queriesData is expected to be empty
+ // since each cell fetches its own data via StatefulChart
+ const matrixifyChart = (
+ <MatrixifyGridRenderer
+ formData={chartProps.rawFormData}
+ datasource={chartProps.datasource}
+ width={chartWidth}
+ height={chartHeight}
+ hooks={chartProps.hooks}
+ />
+ );
- // Check if Matrixify is enabled - use rawFormData (snake_case)
- const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData);
+ // Apply wrapper if provided
+ const wrappedChart = Wrapper ? (
+ <Wrapper width={chartWidth} height={chartHeight}>
+ {matrixifyChart}
+ </Wrapper>
+ ) : (
+ matrixifyChart
+ );
- if (matrixifyEnabled) {
- // When matrixify is enabled, queriesData is expected to be empty
- // since each cell fetches its own data via StatefulChart
- const matrixifyChart = (
- <MatrixifyGridRenderer
- formData={chartProps.rawFormData}
- datasource={chartProps.datasource}
- width={width}
- height={height}
- hooks={chartProps.hooks}
- />
- );
+ // Include error boundary unless disabled
+ return disableErrorBoundary === true ? (
+ wrappedChart
+ ) : (
+ <ErrorBoundary
+ FallbackComponent={props => (
+ <FallbackComponent
+ width={chartWidth}
+ height={chartHeight}
+ {...props}
+ />
+ )}
+ onError={onErrorBoundary}
+ >
+ {wrappedChart}
+ </ErrorBoundary>
+ );
+ }
- // Apply wrapper if provided
- const wrappedChart = Wrapper ? (
- <Wrapper width={width} height={height}>
- {matrixifyChart}
- </Wrapper>
- ) : (
- matrixifyChart
- );
+ // Check for no results only for non-matrixified charts
+ const noResultQueries =
+ enableNoResults &&
+ (!queriesData ||
+ queriesData
+ .slice(0, getQueryCount())
+ .every(
+ ({ data }) => !data || (Array.isArray(data) && data.length === 0),
+ ));
- // Include error boundary unless disabled
+ let chart: JSX.Element;
+ if (noResultQueries) {
+ chart = noResults ? (
+ <>{noResults}</>
+ ) : (
+ <NoResultsComponent
+ id={id}
+ className={className}
+ height={chartHeight}
+ width={chartWidth}
+ />
+ );
+ } else {
+ const chartWithoutWrapper = (
+ <SuperChartCore
+ ref={setRef}
+ id={id}
+ className={className}
+ chartType={chartType}
+ chartProps={chartProps}
+ preTransformProps={preTransformProps}
+ overrideTransformProps={overrideTransformProps}
+ postTransformProps={postTransformProps}
+ onRenderSuccess={onRenderSuccess}
+ onRenderFailure={onRenderFailure}
+ />
+ );
+ chart = Wrapper ? (
+ <Wrapper width={chartWidth} height={chartHeight}>
+ {chartWithoutWrapper}
+ </Wrapper>
+ ) : (
+ chartWithoutWrapper
+ );
+ }
+ // Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
- wrappedChart
+ chart
) : (
<ErrorBoundary
FallbackComponent={props => (
- <FallbackComponent width={width} height={height} {...props} />
+ <FallbackComponent
+ width={chartWidth}
+ height={chartHeight}
+ {...props}
+ />
)}
onError={onErrorBoundary}
>
- {wrappedChart}
+ {chart}
</ErrorBoundary>
);
- }
+ },
+ [
+ createChartProps,
+ rest,
+ queriesData,
+ theme,
+ Wrapper,
+ disableErrorBoundary,
+ FallbackComponent,
+ onErrorBoundary,
+ enableNoResults,
+ getQueryCount,
+ noResults,
+ id,
+ className,
+ setRef,
+ chartType,
+ preTransformProps,
+ overrideTransformProps,
+ postTransformProps,
+ onRenderSuccess,
+ onRenderFailure,
+ ],
+ );
- // Check for no results only for non-matrixified charts
- const noResultQueries =
- enableNoResults &&
- (!queriesData ||
- queriesData
- .slice(0, this.getQueryCount())
- .every(
- ({ data }) => !data || (Array.isArray(data) && data.length === 0),
- ));
+ const { heightInfo, widthInfo, BoundingBox } = parseDimension({
+ width,
+ height,
+ });
- let chart;
- if (noResultQueries) {
- chart = noResults || (
- <NoResultsComponent
- id={id}
- className={className}
- height={height}
- width={width}
- />
- );
- } else {
- const chartWithoutWrapper = (
- <SuperChartCore
- ref={this.setRef}
- id={id}
- className={className}
- chartType={chartType}
- chartProps={chartProps}
- preTransformProps={preTransformProps}
- overrideTransformProps={overrideTransformProps}
- postTransformProps={postTransformProps}
- onRenderSuccess={onRenderSuccess}
- onRenderFailure={onRenderFailure}
- />
- );
- chart = Wrapper ? (
- <Wrapper width={width} height={height}>
- {chartWithoutWrapper}
- </Wrapper>
- ) : (
- chartWithoutWrapper
- );
- }
- // Include the error boundary by default unless it is specifically disabled.
- return disableErrorBoundary === true ? (
- chart
- ) : (
- <ErrorBoundary
- FallbackComponent={props => (
- <FallbackComponent width={width} height={height} {...props} />
- )}
- onError={onErrorBoundary}
- >
- {chart}
- </ErrorBoundary>
+ // If any of the dimension is dynamic, get parent's dimension
+ if (widthInfo.isDynamic || heightInfo.isDynamic) {
+ return (
+ <BoundingBox>
+ <ParentSize debounceTime={debounceTime}>
+ {({ width: parentWidth, height: parentHeight }) =>
+ renderChart(
+ widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
+ heightInfo.isDynamic
+ ? Math.floor(parentHeight)
+ : heightInfo.value,
+ )
+ }
+ </ParentSize>
+ </BoundingBox>
);
}
- render() {
- const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
- this.props as PropsWithDefault,
- );
-
- // If any of the dimension is dynamic, get parent's dimension
- if (widthInfo.isDynamic || heightInfo.isDynamic) {
- const { debounceTime } = this.props;
-
- return (
- <BoundingBox>
- <ParentSize debounceTime={debounceTime}>
- {({ width, height }) =>
- this.renderChart(
- widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
- heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
- )
- }
- </ParentSize>
- </BoundingBox>
- );
- }
-
- return this.renderChart(widthInfo.value, heightInfo.value);
- }
+ return renderChart(widthInfo.value, heightInfo.value);
}
-export default withTheme(SuperChart);
+export default SuperChart;
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
index 9fea3c3153a..c999d1d3347 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
@@ -17,8 +17,13 @@
* under the License.
*/
-/* eslint-disable react/jsx-sort-default-props */
-import { PureComponent } from 'react';
+import {
+ forwardRef,
+ useCallback,
+ useImperativeHandle,
+ useMemo,
+ useRef,
+} from 'react';
import { t } from '@apache-superset/core';
import { createSelector } from 'reselect';
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
@@ -39,16 +44,6 @@ function IDENTITY<T>(x: T) {
const EMPTY = () => null;
-const defaultProps = {
- id: '',
- className: '',
- preTransformProps: IDENTITY,
- overrideTransformProps: undefined,
- postTransformProps: IDENTITY,
- onRenderSuccess() {},
- onRenderFailure() {},
-};
-
interface LoadingProps {
error: { toString(): string };
}
@@ -78,174 +73,231 @@ export type Props = {
onRenderFailure?: HandlerFunction;
};
-export default class SuperChartCore extends PureComponent<Props, {}> {
- /**
- * The HTML element that wraps all chart content
- */
- container?: HTMLElement | null;
+export interface SuperChartCoreRef {
+ container: HTMLElement | null;
+}
- /**
- * memoized function so it will not recompute and return previous value
- * unless one of
- * - preTransformProps
- * - chartProps
- * is changed.
- */
- preSelector = createSelector(
- [
- (input: {
- chartProps: ChartProps;
- preTransformProps?: PreTransformProps;
- }) => input.chartProps,
- input => input.preTransformProps,
- ],
- (chartProps, pre = IDENTITY) => pre(chartProps),
- );
+const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
+ function SuperChartCore(
+ {
+ id = '',
+ className = '',
+ chartProps = BLANK_CHART_PROPS,
+ chartType,
+ preTransformProps = IDENTITY,
+ overrideTransformProps,
+ postTransformProps = IDENTITY,
+ onRenderSuccess = () => {},
+ onRenderFailure = () => {},
+ },
+ ref,
+ ) {
+ const containerRef = useRef<HTMLElement | null>(null);
- /**
- * memoized function so it will not recompute and return previous value
- * unless one of the input arguments have changed.
- */
- transformSelector = createSelector(
- [
- (input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
- input.chartProps,
- input => input.transformProps,
- ],
- (preprocessedChartProps, transform = IDENTITY) =>
- transform(preprocessedChartProps),
- );
+ // Expose container via ref
+ useImperativeHandle(
+ ref,
+ () => ({
+ get container() {
+ return containerRef.current;
+ },
+ }),
+ [],
+ );
- /**
- * memoized function so it will not recompute and return previous value
- * unless one of the input arguments have changed.
- */
- postSelector = createSelector(
- [
- (input: {
- chartProps: ChartProps;
- postTransformProps?: PostTransformProps;
- }) => input.chartProps,
- input => input.postTransformProps,
- ],
- (transformedChartProps, post = IDENTITY) => post(transformedChartProps),
- );
+ /**
+ * memoized function so it will not recompute and return previous value
+ * unless one of
+ * - preTransformProps
+ * - chartProps
+ * is changed.
+ */
+ const preSelector = useMemo(
+ () =>
+ createSelector(
+ [
+ (input: {
+ chartProps: ChartProps;
+ preTransformProps?: PreTransformProps;
+ }) => input.chartProps,
+ input => input.preTransformProps,
+ ],
+ (inputChartProps, pre = IDENTITY) => pre(inputChartProps),
+ ),
+ [],
+ );
- /**
- * Using each memoized function to retrieve the computed chartProps
- */
- processChartProps = ({
- chartProps,
- preTransformProps,
- transformProps,
- postTransformProps,
- }: {
- chartProps: ChartProps;
- preTransformProps?: PreTransformProps;
- transformProps?: TransformProps;
- postTransformProps?: PostTransformProps;
- }) =>
- this.postSelector({
- chartProps: this.transformSelector({
- chartProps: this.preSelector({ chartProps, preTransformProps }),
- transformProps,
- }),
- postTransformProps,
- });
+ /**
+ * memoized function so it will not recompute and return previous value
+ * unless one of the input arguments have changed.
+ */
+ const transformSelector = useMemo(
+ () =>
+ createSelector(
+ [
+ (input: {
+ chartProps: ChartProps;
+ transformProps?: TransformProps;
+ }) => input.chartProps,
+ input => input.transformProps,
+ ],
+ (preprocessedChartProps, transform = IDENTITY) =>
+ transform(preprocessedChartProps),
+ ),
+ [],
+ );
- /**
- * memoized function so it will not recompute
- * and return previous value
- * unless one of
- * - chartType
- * - overrideTransformProps
- * is changed.
- */
- private createLoadableRenderer = createSelector(
- [
- (input: { chartType: string; overrideTransformProps?: TransformProps }) =>
- input.chartType,
- input => input.overrideTransformProps,
- ],
- (chartType, overrideTransformProps) => {
- if (chartType) {
- const Renderer = createLoadableRenderer({
- loader: {
- Chart: () => getChartComponentRegistry().getAsPromise(chartType),
- transformProps: overrideTransformProps
- ? () => Promise.resolve(overrideTransformProps)
- : () => getChartTransformPropsRegistry().getAsPromise(chartType),
- },
- loading: (loadingProps: LoadingProps) =>
- this.renderLoading(loadingProps, chartType),
- render: this.renderChart,
- });
+ /**
+ * memoized function so it will not recompute and return previous value
+ * unless one of the input arguments have changed.
+ */
+ const postSelector = useMemo(
+ () =>
+ createSelector(
+ [
+ (input: {
+ chartProps: ChartProps;
+ postTransformProps?: PostTransformProps;
+ }) => input.chartProps,
+ input => input.postTransformProps,
+ ],
+ (transformedChartProps, post = IDENTITY) =>
+ post(transformedChartProps),
+ ),
+ [],
+ );
- // Trigger preloading.
- Renderer.preload();
+ /**
+ * Using each memoized function to retrieve the computed chartProps
+ */
+ const processChartProps = useCallback(
+ ({
+ chartProps: inputChartProps,
+ preTransformProps: pre,
+ transformProps,
+ postTransformProps: post,
+ }: {
+ chartProps: ChartProps;
+ preTransformProps?: PreTransformProps;
+ transformProps?: TransformProps;
+ postTransformProps?: PostTransformProps;
+ }) =>
+ postSelector({
+ chartProps: transformSelector({
+ chartProps: preSelector({
+ chartProps: inputChartProps,
+ preTransformProps: pre,
+ }),
+ transformProps,
+ }),
+ postTransformProps: post,
+ }),
+ [preSelector, transformSelector, postSelector],
+ );
- return Renderer;
- }
+ const renderLoading = useCallback(
+ (loadingProps: LoadingProps, loadingChartType: string) => {
+ const { error } = loadingProps;
- return EMPTY;
- },
- );
+ if (error) {
+ return (
+ <div className="alert alert-warning" role="alert">
+ <strong>{t('ERROR')}</strong>
+ <code>chartType="{loadingChartType}"</code> —
+ {error.toString()}
+ </div>
+ );
+ }
- static defaultProps = defaultProps;
+ return null;
+ },
+ [],
+ );
- private renderChart = (loaded: LoadedModules, props: RenderProps) => {
- const { Chart, transformProps } = loaded;
- const { chartProps, preTransformProps, postTransformProps } = props;
+ const renderChart = useCallback(
+ (loaded: LoadedModules, props: RenderProps) => {
+ const { Chart, transformProps } = loaded;
+ const {
+ chartProps: renderChartProps,
+ preTransformProps: pre,
+ postTransformProps: post,
+ } = props;
- return (
- <Chart
- {...this.processChartProps({
- chartProps,
- preTransformProps,
- transformProps,
- postTransformProps,
- })}
- />
+ return (
+ <Chart
+ {...processChartProps({
+ chartProps: renderChartProps,
+ preTransformProps: pre,
+ transformProps,
+ postTransformProps: post,
+ })}
+ />
+ );
+ },
+ [processChartProps],
);
- };
- private renderLoading = (loadingProps: LoadingProps, chartType: string) => {
- const { error } = loadingProps;
+ /**
+ * memoized function so it will not recompute
+ * and return previous value
+ * unless one of
+ * - chartType
+ * - overrideTransformProps
+ * is changed.
+ */
+ const createLoadableRendererSelector = useMemo(
+ () =>
+ createSelector(
+ [
+ (input: {
+ chartType: string;
+ overrideTransformProps?: TransformProps;
+ }) => input.chartType,
+ input => input.overrideTransformProps,
+ ],
+ (selectorChartType, selectorOverrideTransformProps) => {
+ if (selectorChartType) {
+ const Renderer = createLoadableRenderer({
+ loader: {
+ Chart: () =>
+ getChartComponentRegistry().getAsPromise(selectorChartType),
+ transformProps: selectorOverrideTransformProps
+ ? () => Promise.resolve(selectorOverrideTransformProps)
+ : () =>
+ getChartTransformPropsRegistry().getAsPromise(
+ selectorChartType,
+ ),
+ },
+ loading: (loadingProps: LoadingProps) =>
+ renderLoading(loadingProps, selectorChartType),
+ render: renderChart,
+ });
- if (error) {
- return (
- <div className="alert alert-warning" role="alert">
- <strong>{t('ERROR')}</strong>
- <code>chartType="{chartType}"</code> —
- {error.toString()}
- </div>
- );
- }
+ // Trigger preloading.
+ Renderer.preload();
- return null;
- };
+ return Renderer;
+ }
- private setRef = (container: HTMLElement | null) => {
- this.container = container;
- };
+ return EMPTY;
+ },
+ ),
+ [renderLoading, renderChart],
+ );
- render() {
- const {
- id,
- className,
- preTransformProps,
- postTransformProps,
- chartProps = BLANK_CHART_PROPS,
- onRenderSuccess,
- onRenderFailure,
- } = this.props;
+ const setRef = useCallback((container: HTMLElement | null) => {
+ containerRef.current = container;
+ }, []);
// Create LoadableRenderer and start preloading
// the lazy-loaded Chart components
- const Renderer = this.createLoadableRenderer(this.props);
+ const Renderer = createLoadableRendererSelector({
+ chartType,
+ overrideTransformProps,
+ });
// Do not render if chartProps is set to null.
- // but the pre-loading has been started in this.createLoadableRenderer
+ // but the pre-loading has been started in createLoadableRendererSelector
// to prepare for rendering once chartProps becomes available.
if (chartProps === null) {
return null;
@@ -263,7 +315,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
}
return (
- <div {...containerProps} ref={this.setRef}>
+ <div {...containerProps} ref={setRef}>
<Renderer
preTransformProps={preTransformProps}
postTransformProps={postTransformProps}
@@ -273,5 +325,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
/>
</div>
);
- }
-}
+ },
+);
+
+export default SuperChartCore;
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
index c52f13d0de0..75636b7b2ff 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
@@ -17,8 +17,13 @@
* under the License.
*/
-// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
-import { Component, ComponentClass, WeakValidationMap } from 'react';
+import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
+import type {
+ WeakValidationMap,
+ ForwardRefExoticComponent,
+ PropsWithoutRef,
+ RefAttributes,
+} from 'react';
// TODO: Note that id and className can collide between Props and ReactifyProps
// leading to (likely) unexpected behaviors. We should either require Props to not
@@ -49,66 +54,78 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>;
}
+export interface ReactifiedComponentRef {
+ container?: HTMLDivElement;
+}
+
+type ReactifiedComponent<Props> = ForwardRefExoticComponent<
+ PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
+> & {
+ defaultProps?: Partial<Props & ReactifyProps>;
+ propTypes?: WeakValidationMap<Props & ReactifyProps>;
+};
+
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
-): ComponentClass<Props & ReactifyProps> {
- class ReactifiedComponent extends Component<Props & ReactifyProps> {
- container?: HTMLDivElement;
-
- constructor(props: Props & ReactifyProps) {
- super(props);
- this.setContainerRef = this.setContainerRef.bind(this);
- }
-
- componentDidMount() {
- this.execute();
- }
-
- componentDidUpdate() {
- this.execute();
- }
-
- componentWillUnmount() {
- this.container = undefined;
- if (callbacks?.componentWillUnmount) {
- callbacks.componentWillUnmount.bind(this)();
+): ReactifiedComponent<Props> {
+ const ReactifiedComponent = forwardRef<
+ ReactifiedComponentRef,
+ Props & ReactifyProps
+ >(function ReactifiedComponent(props, ref) {
+ const containerRef = useRef<HTMLDivElement>(null);
+
+ // Expose container via ref for external access
+ useImperativeHandle(
+ ref,
+ () => ({
+ get container() {
+ return containerRef.current ?? undefined;
+ },
+ }),
+ [],
+ );
+
+ // Execute renderFn on mount and every update (mimics componentDidMount + componentDidUpdate)
+ useEffect(() => {
+ if (containerRef.current) {
+ renderFn(containerRef.current, props);
}
- }
+ });
- setContainerRef(ref: HTMLDivElement) {
- this.container = ref;
- }
+ // Cleanup on unmount
+ useEffect(
+ () => () => {
+ if (callbacks?.componentWillUnmount) {
+ callbacks.componentWillUnmount();
+ }
+ },
+ [],
+ );
- execute() {
- if (this.container) {
- renderFn(this.container, this.props);
- }
- }
+ const { id, className } = props;
- render() {
- const { id, className } = this.props;
+ return <div ref={containerRef} id={id} className={className} />;
+ });
- return <div ref={this.setContainerRef} id={id} className={className} />;
- }
+ if (renderFn.displayName) {
+ ReactifiedComponent.displayName = renderFn.displayName;
}
- const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
- ReactifiedComponent;
+ // Cast to any to assign propTypes and defaultProps since forwardRef
+ // components have complex typing that makes direct assignment difficult
+ const result = ReactifiedComponent as any;
- if (renderFn.displayName) {
- ReactifiedClass.displayName = renderFn.displayName;
- }
- // eslint-disable-next-line react/forbid-foreign-prop-types
if (renderFn.propTypes) {
- ReactifiedClass.propTypes = {
- ...ReactifiedClass.propTypes,
+ result.propTypes = {
+ ...result.propTypes,
...renderFn.propTypes,
};
}
+
if (renderFn.defaultProps) {
- ReactifiedClass.defaultProps = renderFn.defaultProps;
+ result.defaultProps = renderFn.defaultProps;
}
- return ReactifiedComponent;
+ return result as ReactifiedComponent<Props>;
}
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx b/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
index e053f3c393d..809ff90564c 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/test/chart/components/SuperChartCore.test.tsx
@@ -227,15 +227,27 @@ describe('SuperChartCore', () => {
});
});
- describe('.processChartProps()', () => {
- test('use identity functions for unspecified transforms', () => {
- const chart = new SuperChartCore({
- chartType: ChartKeys.DILIGENT,
+ describe('processChartProps behavior', () => {
+ test('passes through chartProps unchanged when no transforms are specified', async () => {
+ // When no pre/post transform props are specified, the identity function is used
+ // which means chartProps should pass through to the chart unchanged.
+ // We verify this by checking that the chart renders correctly without transforms.
+ const chartProps2 = new ChartProps({
+ queriesData: [{ message: 'identity-test' }],
+ theme: supersetTheme,
});
- const chartProps2 = new ChartProps();
- expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
- chartProps2,
+
+ render(
+ <SuperChartCore
+ chartType={ChartKeys.DILIGENT}
+ chartProps={chartProps2}
+ overrideTransformProps={props => props.queriesData[0]}
+ />,
);
+
+ await waitFor(() => {
+ expect(screen.getByText('identity-test')).toBeInTheDocument();
+ });
});
});
});
diff --git a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
index b64e1989411..fddbf59289d 100644
--- a/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
+++ b/superset-frontend/packages/superset-ui-core/test/chart/components/reactify.test.tsx
@@ -19,9 +19,9 @@
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
-import { PureComponent } from 'react';
+import { useEffect, useState } from 'react';
import { reactify } from '@superset-ui/core';
-import { render, screen } from '@testing-library/react';
+import { render, screen, waitFor } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => {
@@ -52,48 +52,36 @@ describe('reactify(renderFn)', () => {
componentWillUnmount: willUnmountCb,
});
- class TestComponent extends PureComponent<{}, { content: string }> {
- constructor(props = {}) {
- super(props);
- this.state = { content: 'abc' };
- }
+ function TestComponent() {
+ const [content, setContent] = useState('abc');
- componentDidMount() {
- setTimeout(() => {
- this.setState({ content: 'def' });
+ useEffect(() => {
+ const timer = setTimeout(() => {
+ setContent('def');
}, 10);
- }
+ return () => clearTimeout(timer);
+ }, []);
- render() {
- const { content } = this.state;
-
- return <TheChart id="test" content={content} />;
- }
+ return <TheChart id="test" content={content} />;
}
- class AnotherTestComponent extends PureComponent<{}, {}> {
- render() {
- return <TheChartWithWillUnmountHook id="another_test" />;
- }
+ function AnotherTestComponent() {
+ return <TheChartWithWillUnmountHook id="another_test" />;
}
- test('returns a React component class', () =>
- new Promise(done => {
- render(<TestComponent />);
+ test('returns a React component and re-renders on prop changes', async () => {
+ render(<TestComponent />);
+
+ expect(renderFn).toHaveBeenCalledTimes(1);
+ expect(screen.getByText('abc')).toBeInTheDocument();
+ expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
- expect(renderFn).toHaveBeenCalledTimes(1);
- expect(screen.getByText('abc')).toBeInTheDocument();
- expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
- setTimeout(() => {
- expect(renderFn).toHaveBeenCalledTimes(2);
- expect(screen.getByText('def')).toBeInTheDocument();
- expect(screen.getByText('def').parentNode).toHaveAttribute(
- 'id',
- 'test',
- );
- done(undefined);
- }, 20);
- }));
+ await waitFor(() => {
+ expect(screen.getByText('def')).toBeInTheDocument();
+ });
+ expect(screen.getByText('def').parentNode).toHaveAttribute('id', 'test');
+ expect(renderFn).toHaveBeenCalledTimes(2);
+ });
describe('displayName', () => {
test('has displayName if renderFn.displayName is defined', () => {
expect(TheChart.displayName).toEqual('BoldText');
@@ -126,20 +114,16 @@ describe('reactify(renderFn)', () => {
expect(AnotherChart.defaultProps).toBeUndefined();
});
});
- test('does not try to render if not mounted', () => {
+ test('calls renderFn when container is set', () => {
const anotherRenderFn = jest.fn();
- const AnotherChart = reactify(anotherRenderFn); // enables valid new AnotherChart() call
- // @ts-expect-error
- new AnotherChart({ id: 'test' }).execute();
- expect(anotherRenderFn).not.toHaveBeenCalled();
+ const AnotherChart = reactify(anotherRenderFn);
+ const { unmount } = render(<AnotherChart id="test" />);
+ expect(anotherRenderFn).toHaveBeenCalled();
+ unmount();
+ });
+ test('calls willUnmount hook when it is provided', async () => {
+ const { unmount } = render(<AnotherTestComponent />);
+ unmount();
+ expect(willUnmountCb).toHaveBeenCalledTimes(1);
});
- test('calls willUnmount hook when it is provided', () =>
- new Promise(done => {
- const { unmount } = render(<AnotherTestComponent />);
- setTimeout(() => {
- unmount();
- expect(willUnmountCb).toHaveBeenCalledTimes(1);
- done(undefined);
- }, 20);
- }));
});
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
index 37b177f325b..cea87063bd7 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/Expandable.tsx
@@ -17,45 +17,32 @@
* under the License.
*/
-import { Component, ReactNode } from 'react';
+import { useState, useCallback, ReactNode } from 'react';
export type Props = {
children: ReactNode;
expandableWhat?: string;
};
-type State = {
- open: boolean;
-};
-
-export default class Expandable extends Component<Props, State> {
- constructor(props: Props) {
- super(props);
- this.state = { open: false };
- this.handleToggle = this.handleToggle.bind(this);
- }
-
- handleToggle() {
- this.setState(({ open }) => ({ open: !open }));
- }
+export default function Expandable({ children, expandableWhat }: Props) {
+ const [open, setOpen] = useState(false);
- render() {
- const { open } = this.state;
- const { children, expandableWhat } = this.props;
+ const handleToggle = useCallback(() => {
+ setOpen(prevOpen => !prevOpen);
+ }, []);
- return (
- <div>
- <button
- type="button"
- className="btn btn-primary btn-sm"
- onClick={this.handleToggle}
- >
- {`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
- </button>
- <br />
- <br />
- {open ? children : null}
- </div>
- );
- }
+ return (
+ <div>
+ <button
+ type="button"
+ className="btn btn-primary btn-sm"
+ onClick={handleToggle}
+ >
+ {`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
+ </button>
+ <br />
+ <br />
+ {open ? children : null}
+ </div>
+ );
}
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
index bbc3e115bf6..1803439bbb5 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/shared/components/VerifyCORS.tsx
@@ -17,7 +17,7 @@
* under the License.
*/
-import { Component, ReactNode } from 'react';
+import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { t } from '@apache-superset/core';
import {
SupersetClient,
@@ -36,12 +36,6 @@ export type Props = {
postPayload?: string;
};
-type State = {
- didVerify: boolean;
- error?: Error | SupersetApiError;
- payload?: object;
-};
-
export const renderError = (error: Error) => (
<div>
The following error occurred, make sure you have <br />
@@ -54,29 +48,37 @@ export const renderError = (error: Error) => (
</div>
);
-export default class VerifyCORS extends Component<Props, State> {
- constructor(props: Props) {
- super(props);
- this.state = { didVerify: false };
- this.handleVerify = this.handleVerify.bind(this);
- }
+export default function VerifyCORS({
+ children,
+ endpoint,
+ host,
+ method,
+ postPayload,
+}: Props): JSX.Element {
+ const [didVerify, setDidVerify] = useState(false);
+ const [error, setError] = useState<Error | SupersetApiError | undefined>(
+ undefined,
+ );
+ const [payload, setPayload] = useState<object | undefined>(undefined);
- componentDidUpdate(prevProps: Props) {
- const { endpoint, host, postPayload, method } = this.props;
+ const prevPropsRef = useRef({ endpoint, host, postPayload, method });
+
+ useEffect(() => {
+ const prevProps = prevPropsRef.current;
if (
- (this.state.didVerify || this.state.error) &&
+ (didVerify || error) &&
(prevProps.endpoint !== endpoint ||
prevProps.host !== host ||
prevProps.postPayload !== postPayload ||
prevProps.method !== method)
) {
- // eslint-disable-next-line react/no-did-update-set-state
- this.setState({ didVerify: false, error: undefined });
+ setDidVerify(false);
+ setError(undefined);
}
- }
+ prevPropsRef.current = { endpoint, host, postPayload, method };
+ }, [endpoint, host, postPayload, method, didVerify, error]);
- handleVerify() {
- const { endpoint, host, postPayload, method } = this.props;
+ const handleVerify = useCallback(() => {
SupersetClient.reset();
SupersetClient.configure({
credentials: 'include',
@@ -94,43 +96,40 @@ export default class VerifyCORS extends Component<Props, State> {
}
return { error: 'Must provide valid endpoint and payload.' };
})
- .then(result =>
- this.setState({ didVerify: true, error: undefined, payload: result }),
- )
- .catch(error => this.setState({ error }));
- }
+ .then(result => {
+ setDidVerify(true);
+ setError(undefined);
+ setPayload(result);
+ })
+ .catch(err => setError(err));
+ }, [endpoint, host, method, postPayload]);
- render() {
- const { didVerify, error, payload } = this.state;
- const { children } = this.props;
+ return didVerify ? (
+ <>{children({ payload })}</>
+ ) : (
+ <div className="row">
+ <div className="col-md-10">
+ This example requires CORS requests from this domain. <br />
+ <br />
+ 1) enable CORS requests in your Superset App from{' '}
+ {`${window.location.origin}`}
+ <br />
+ 2) configure your Superset App host name below <br />
+ 3) click below to verify authentication. You may debug CORS further
+ using the `@superset-ui/connection` story. <br />
+ <br />
+ <Button type="primary" size="small" onClick={handleVerify}>
+ {t('Verify')}
+ </Button>
+ <br />
+ <br />
+ </div>
- return didVerify ? (
- children({ payload })
- ) : (
- <div className="row">
- <div className="col-md-10">
- This example requires CORS requests from this domain. <br />
- <br />
- 1) enable CORS requests in your Superset App from{' '}
- {`${window.location.origin}`}
- <br />
- 2) configure your Superset App host name below <br />
- 3) click below to verify authentication. You may debug CORS further
- using the `@superset-ui/connection` story. <br />
- <br />
- <Button type="primary" size="small" onClick={this.handleVerify}>
- {t('Verify')}
- </Button>
- <br />
- <br />
+ {error && (
+ <div className="col-md-8">
+ <ErrorMessage error={error} />
</div>
-
- {error && (
- <div className="col-md-8">
- <ErrorMessage error={error} />
- </div>
- )}
- </div>
- );
- }
+ )}
+ </div>
+ );
}
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
index 718a1f17cfb..f473a8f8df2 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-number-format/NumberFormat.stories.tsx
@@ -17,126 +17,108 @@
* under the License.
*/
-import { PureComponent } from 'react';
+import { useState, useCallback } from 'react';
import { formatNumber } from '@superset-ui/core';
-interface NumberFormatValidatorState {
- formatString: string;
- testValues: (number | null | undefined)[];
-}
-
-class NumberFormatValidator extends PureComponent<
- Record<string, never>,
- NumberFormatValidatorState
-> {
- state: NumberFormatValidatorState = {
- formatString: '.3~s',
- testValues: [
- 987654321,
- 12345.6789,
- 3000,
- 400.14,
- 70.00002,
- 1,
- 0,
- -1,
- -70.00002,
- -400.14,
- -3000,
- -12345.6789,
- -987654321,
- Number.POSITIVE_INFINITY,
- Number.NEGATIVE_INFINITY,
- NaN,
- null,
- undefined,
- ],
- };
-
- constructor(props: Record<string, never>) {
- super(props);
-
- this.handleFormatChange = this.handleFormatChange.bind(this);
- }
+const testValues: (number | null | undefined)[] = [
+ 987654321,
+ 12345.6789,
+ 3000,
+ 400.14,
+ 70.00002,
+ 1,
+ 0,
+ -1,
+ -70.00002,
+ -400.14,
+ -3000,
+ -12345.6789,
+ -987654321,
+ Number.POSITIVE_INFINITY,
+ Number.NEGATIVE_INFINITY,
+ NaN,
+ null,
+ undefined,
+];
- handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
- this.setState({
- formatString: event.target.value,
- });
- }
+function NumberFormatValidator() {
+ const [formatString, setFormatString] = useState('.3~s');
- render() {
- const { formatString, testValues } = this.state;
+ const handleFormatChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ setFormatString(event.target.value);
+ },
+ [],
+ );
- return (
- <div className="container">
- <div className="row" style={{ margin: '40px 20px 0 20px' }}>
- <div className="col-sm">
- <p>
- This <code>@superset-ui/number-format</code> package enriches{' '}
- <code>d3-format</code>
- to handle invalid formats as well as edge case values. Use the
- validator below to preview outputs from the specified format
- string. See
- <a
- href="https://github.com/d3/d3-format#locale_format"
- target="_blank"
- rel="noopener noreferrer"
- >
- D3 Format Reference
- </a>
- for how to write a D3 format string.
- </p>
- </div>
+ return (
+ <div className="container">
+ <div className="row" style={{ margin: '40px 20px 0 20px' }}>
+ <div className="col-sm">
+ <p>
+ This <code>@superset-ui/number-format</code> package enriches{' '}
+ <code>d3-format</code>
+ to handle invalid formats as well as edge case values. Use the
+ validator below to preview outputs from the specified format string.
+ See
+ <a
+ href="https://github.com/d3/d3-format#locale_format"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ D3 Format Reference
+ </a>
+ for how to write a D3 format string.
+ </p>
</div>
- <div className="row" style={{ margin: '10px 0 30px 0' }}>
- <div className="col-sm" />
- <div className="col-sm-8">
- <div className="form">
- <div className="form-group">
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
- <label>
- Enter D3 format string:
- <input
- id="formatString"
- className="form-control form-control-lg"
- type="text"
- value={formatString}
- onChange={this.handleFormatChange}
- />
- </label>
- </div>
+ </div>
+ <div className="row" style={{ margin: '10px 0 30px 0' }}>
+ <div className="col-sm" />
+ <div className="col-sm-8">
+ <div className="form">
+ <div className="form-group">
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+ <label>
+ Enter D3 format string:
+ <input
+ id="formatString"
+ className="form-control form-control-lg"
+ type="text"
+ value={formatString}
+ onChange={handleFormatChange}
+ />
+ </label>
</div>
</div>
- <div className="col-sm" />
</div>
- <div className="row">
- <div className="col-sm">
- <table className="table table-striped table-sm">
- <thead>
- <tr>
- <th>Input (number)</th>
- <th>Formatted output (string)</th>
+ <div className="col-sm" />
+ </div>
+ <div className="row">
+ <div className="col-sm">
+ <table className="table table-striped table-sm">
+ <thead>
+ <tr>
+ <th>Input (number)</th>
+ <th>Formatted output (string)</th>
+ </tr>
+ </thead>
+ <tbody>
+ {testValues.map((v, index) => (
+ <tr key={index}>
+ <td>
+ <code>{`${v}`}</code>
+ </td>
+ <td>
+ <code>"{formatNumber(formatString, v)}"</code>
+ </td>
</tr>
- </thead>
- <tbody>
- {testValues.map((v, index) => (
- <tr key={index}>
- <td>
- <code>{`${v}`}</code>
- </td>
- <td>
- <code>"{formatNumber(formatString, v)}"</code>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
+ ))}
+ </tbody>
+ </table>
</div>
</div>
- );
- }
+ </div>
+ );
}
export default {
diff --git a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
index 2798113b587..34ee21dee46 100644
--- a/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
+++ b/superset-frontend/packages/superset-ui-demo/storybook/stories/superset-ui-time-format/TimeFormat.stories.tsx
@@ -17,115 +17,96 @@
* under the License.
*/
-import { PureComponent } from 'react';
+import { useState, useCallback } from 'react';
import { formatTime } from '@superset-ui/core';
-interface TimeFormatValidatorState {
- formatString: string;
- testValues: (Date | number | null | undefined)[];
-}
-
-class TimeFormatValidator extends PureComponent<
- Record<string, never>,
- TimeFormatValidatorState
-> {
- state: TimeFormatValidatorState = {
- formatString: '%Y-%m-%d %H:%M:%S',
- testValues: [
- new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
- new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
- new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
- new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
- 0,
- null,
- undefined,
- ],
- };
+const testValues: (Date | number | null | undefined)[] = [
+ new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
+ new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
+ new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
+ new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
+ 0,
+ null,
+ undefined,
+];
- constructor(props: Record<string, never>) {
- super(props);
- this.handleFormatChange = this.handleFormatChange.bind(this);
- }
+function TimeFormatValidator() {
+ const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S');
- handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
- this.setState({
- formatString: event.target.value,
- });
- }
+ const handleFormatChange = useCallback(
+ (event: React.ChangeEvent<HTMLInputElement>) => {
+ setFormatString(event.target.value);
+ },
+ [],
+ );
- render() {
- const { formatString, testValues } = this.state;
-
- return (
- <div className="container">
- <div className="row" style={{ margin: '40px 20px 0 20px' }}>
- <div className="col-sm">
- <p>
- This <code>@superset-ui/time-format</code> package enriches
- <code>d3-time-format</code> to handle invalid formats as well as
- edge case values. Use the validator below to preview outputs from
- the specified format string. See
- <a
- href="https://github.com/d3/d3-time-format#locale_format"
- target="_blank"
- rel="noopener noreferrer"
- >
- D3 Time Format Reference
- </a>
- for how to write a D3 time format string.
- </p>
- </div>
+ return (
+ <div className="container">
+ <div className="row" style={{ margin: '40px 20px 0 20px' }}>
+ <div className="col-sm">
+ <p>
+ This <code>@superset-ui/time-format</code> package enriches
+ <code>d3-time-format</code> to handle invalid formats as well as
+ edge case values. Use the validator below to preview outputs from
+ the specified format string. See
+ <a
+ href="https://github.com/d3/d3-time-format#locale_format"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
+ D3 Time Format Reference
+ </a>
+ for how to write a D3 time format string.
+ </p>
</div>
- <div className="row" style={{ margin: '10px 0 30px 0' }}>
- <div className="col-sm" />
- <div className="col-sm-8">
- <div className="form">
- <div className="form-group">
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
- <label>
- Enter D3 time format string:
- <input
- id="formatString"
- className="form-control form-control-lg"
- type="text"
- value={formatString}
- onChange={this.handleFormatChange}
- />
- </label>
- </div>
+ </div>
+ <div className="row" style={{ margin: '10px 0 30px 0' }}>
+ <div className="col-sm" />
+ <div className="col-sm-8">
+ <div className="form">
+ <div className="form-group">
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
+ <label>
+ Enter D3 time format string:
+ <input
+ id="formatString"
+ className="form-control form-control-lg"
+ type="text"
+ value={formatString}
+ onChange={handleFormatChange}
+ />
+ </label>
</div>
</div>
- <div className="col-sm" />
</div>
- <div className="row">
- <div className="col-sm">
- <table className="table table-striped table-sm">
- <thead>
- <tr>
- <th>Input (time)</th>
- <th>Formatted output (string)</th>
+ <div className="col-sm" />
+ </div>
+ <div className="row">
+ <div className="col-sm">
+ <table className="table table-striped table-sm">
+ <thead>
+ <tr>
+ <th>Input (time)</th>
+ <th>Formatted output (string)</th>
+ </tr>
+ </thead>
+ <tbody>
+ {testValues.map((v, index) => (
+ <tr key={index}>
+ <td>
+ <code>{v instanceof Date ? v.toUTCString() : `${v}`}</code>
+ </td>
+ <td>
+ <code>"{formatTime(formatString, v)}"</code>
+ </td>
</tr>
- </thead>
- <tbody>
- {testValues.map((v, index) => (
- <tr key={index}>
- <td>
- <code>
- {v instanceof Date ? v.toUTCString() : `${v}`}
- </code>
- </td>
- <td>
- <code>"{formatTime(formatString, v)}"</code>
- </td>
- </tr>
- ))}
- </tbody>
- </table>
- </div>
+ ))}
+ </tbody>
+ </table>
</div>
</div>
- );
- }
+ </div>
+ );
}
export default {
diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
index 0ec77b51b89..2ef267ef336 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
-import { PureComponent } from 'react';
+import { memo, useMemo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { ensureIsArray } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
@@ -45,18 +44,6 @@ interface HorizonChartProps {
offsetX?: number;
}
-const defaultProps: Partial<HorizonChartProps> = {
- className: '',
- width: 800,
- height: 600,
- seriesHeight: 20,
- bands: Math.floor(DEFAULT_COLORS.length / 2),
- colors: DEFAULT_COLORS,
- colorScale: 'series',
- mode: 'offset',
- offsetX: 0,
-};
-
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-horizon {
@@ -80,24 +67,19 @@ const StyledDiv = styled.div`
`}
`;
-class HorizonChart extends PureComponent<HorizonChartProps> {
- static defaultProps = defaultProps;
-
- render() {
- const {
- className,
- width,
- height,
- data,
- seriesHeight,
- bands,
- colors,
- colorScale,
- mode,
- offsetX,
- } = this.props;
-
- let yDomain: [number, number] | undefined;
+function HorizonChart({
+ className = '',
+ width = 800,
+ height = 600,
+ seriesHeight = 20,
+ data,
+ bands = Math.floor(DEFAULT_COLORS.length / 2),
+ colors = DEFAULT_COLORS,
+ colorScale = 'series',
+ mode = 'offset',
+ offsetX = 0,
+}: HorizonChartProps) {
+ const yDomain = useMemo((): [number, number] | undefined => {
if (colorScale === 'overall') {
const allValues = data.reduce<DataValue[]>(
(acc, current) => acc.concat(current.values),
@@ -106,35 +88,36 @@ class HorizonChart extends PureComponent<HorizonChartProps> {
const rawExtent = d3Extent(allValues, d => d.y);
// Only set yDomain if we have valid min and max values
if (rawExtent[0] != null && rawExtent[1] != null) {
- yDomain = [rawExtent[0], rawExtent[1]];
+ return [rawExtent[0], rawExtent[1]];
}
}
+ return undefined;
+ }, [colorScale, data]);
- return (
- <StyledDiv>
- <div
- className={`superset-legacy-chart-horizon ${className}`}
- style={{ height }}
- >
- {data.map(row => (
- <HorizonRow
- key={row.key.join(',')}
- width={width}
- height={seriesHeight}
- title={ensureIsArray(row.key).join(', ')}
- data={row.values}
- bands={bands}
- colors={colors}
- colorScale={colorScale}
- mode={mode}
- offsetX={offsetX}
- yDomain={yDomain}
- />
- ))}
- </div>
- </StyledDiv>
- );
- }
+ return (
+ <StyledDiv>
+ <div
+ className={`superset-legacy-chart-horizon ${className}`}
+ style={{ height }}
+ >
+ {data.map(row => (
+ <HorizonRow
+ key={row.key.join(',')}
+ width={width}
+ height={seriesHeight}
+ title={ensureIsArray(row.key).join(', ')}
+ data={row.values}
+ bands={bands}
+ colors={colors}
+ colorScale={colorScale}
+ mode={mode}
+ offsetX={offsetX}
+ yDomain={yDomain}
+ />
+ ))}
+ </div>
+ </StyledDiv>
+ );
}
-export default HorizonChart;
+export default memo(HorizonChart);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
index f399d850d7c..7e988865fdc 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
@@ -17,9 +17,7 @@
* under the License.
*/
/* eslint-disable no-continue, no-bitwise */
-/* eslint-disable react/jsx-sort-default-props */
-/* eslint-disable react/sort-prop-types */
-import { PureComponent } from 'react';
+import { useRef, useEffect, useCallback, memo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { scaleLinear } from 'd3-scale';
@@ -52,162 +50,140 @@ interface HorizonRowProps {
yDomain?: [number, number];
}
-const defaultProps: Partial<HorizonRowProps> = {
- className: '',
- width: 800,
- height: 20,
- bands: DEFAULT_COLORS.length >> 1,
- colors: DEFAULT_COLORS,
- colorScale: 'series',
- mode: 'offset',
- offsetX: 0,
- title: '',
- yDomain: undefined,
-};
-
-class HorizonRow extends PureComponent<HorizonRowProps> {
- static defaultProps = defaultProps;
-
- private canvas: HTMLCanvasElement | null = null;
-
- componentDidMount() {
- this.drawChart();
- }
-
- componentDidUpdate() {
- this.drawChart();
- }
-
- componentWillUnmount() {
- this.canvas = null;
- }
-
- drawChart() {
- if (this.canvas) {
- const {
- data: rawData,
- yDomain,
- width = 800,
- height = 20,
- bands = DEFAULT_COLORS.length >> 1,
- colors = DEFAULT_COLORS,
- colorScale,
- offsetX = 0,
- mode,
- } = this.props;
-
- const data =
- colorScale === 'change'
- ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
- : rawData;
-
- const context = this.canvas.getContext('2d');
- if (!context) return;
- context.imageSmoothingEnabled = false;
- context.clearRect(0, 0, width, height);
- // Reset transform
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.translate(0.5, 0.5);
-
- const step = width / data.length;
- // the data frame currently being shown:
- const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
- const endIndex = Math.floor(
- Math.min(data.length, startIndex + width / step),
- );
-
- // skip drawing if there's no data to be drawn
- if (startIndex > data.length) {
- return;
+function HorizonRow({
+ className = '',
+ width = 800,
+ height = 20,
+ data: rawData,
+ bands = DEFAULT_COLORS.length >> 1,
+ colors = DEFAULT_COLORS,
+ colorScale = 'series',
+ mode = 'offset',
+ offsetX = 0,
+ title = '',
+ yDomain,
+}: HorizonRowProps) {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+
+ const drawChart = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const data =
+ colorScale === 'change'
+ ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
+ : rawData;
+
+ const context = canvas.getContext('2d');
+ if (!context) return;
+ context.imageSmoothingEnabled = false;
+ context.clearRect(0, 0, width, height);
+ // Reset transform
+ context.setTransform(1, 0, 0, 1, 0, 0);
+ context.translate(0.5, 0.5);
+
+ const step = width / data.length;
+ // the data frame currently being shown:
+ const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
+ const endIndex = Math.floor(
+ Math.min(data.length, startIndex + width / step),
+ );
+
+ // skip drawing if there's no data to be drawn
+ if (startIndex > data.length) {
+ return;
+ }
+
+ // Create y-scale
+ const [min, max] =
+ yDomain || (d3Extent(data, d => d.y) as [number, number]);
+ const y = scaleLinear()
+ .domain([0, Math.max(-min, max)])
+ .range([0, height]);
+
+ // we are drawing positive & negative bands separately to avoid mutating canvas state
+ // http://www.html5rocks.com/en/tutorials/canvas/performance/
+ let hasNegative = false;
+ // draw positive bands
+ let value: number;
+ let bExtents: number;
+ for (let b = 0; b < bands; b += 1) {
+ context.fillStyle = colors[bands + b];
+
+ // Adjust the range based on the current band index.
+ bExtents = (b + 1 - bands) * height;
+ y.range([bands * height + bExtents, bExtents]);
+
+ // only the current data frame is being drawn i.e. what's visible:
+ for (let i = startIndex; i < endIndex; i += 1) {
+ value = data[i].y;
+ if (value <= 0) {
+ hasNegative = true;
+ continue;
+ }
+ if (value !== undefined) {
+ context.fillRect(
+ offsetX + i * step,
+ y(value)!,
+ step + 1,
+ y(0)! - y(value)!,
+ );
+ }
+ }
+ }
+
+ // draw negative bands
+ if (hasNegative) {
+ // mirror the negative bands, by flipping the canvas
+ if (mode === 'offset') {
+ context.translate(0, height);
+ context.scale(1, -1);
}
- // Create y-scale
- const [min, max] =
- yDomain || (d3Extent(data, d => d.y) as [number, number]);
- const y = scaleLinear()
- .domain([0, Math.max(-min, max)])
- .range([0, height]);
-
- // we are drawing positive & negative bands separately to avoid mutating canvas state
- // http://www.html5rocks.com/en/tutorials/canvas/performance/
- let hasNegative = false;
- // draw positive bands
- let value: number;
- let bExtents: number;
for (let b = 0; b < bands; b += 1) {
- context.fillStyle = colors[bands + b];
+ context.fillStyle = colors[bands - b - 1];
// Adjust the range based on the current band index.
bExtents = (b + 1 - bands) * height;
y.range([bands * height + bExtents, bExtents]);
// only the current data frame is being drawn i.e. what's visible:
- for (let i = startIndex; i < endIndex; i += 1) {
- value = data[i].y;
- if (value <= 0) {
- hasNegative = true;
+ for (let ii = startIndex; ii < endIndex; ii += 1) {
+ value = data[ii].y;
+ if (value >= 0) {
continue;
}
- if (value !== undefined) {
- context.fillRect(
- offsetX + i * step,
- y(value)!,
- step + 1,
- y(0)! - y(value)!,
- );
- }
- }
- }
-
- // draw negative bands
- if (hasNegative) {
- // mirror the negative bands, by flipping the canvas
- if (mode === 'offset') {
- context.translate(0, height);
- context.scale(1, -1);
- }
-
- for (let b = 0; b < bands; b += 1) {
- context.fillStyle = colors[bands - b - 1];
-
- // Adjust the range based on the current band index.
- bExtents = (b + 1 - bands) * height;
- y.range([bands * height + bExtents, bExtents]);
-
- // only the current data frame is being drawn i.e. what's visible:
- for (let ii = startIndex; ii < endIndex; ii += 1) {
- value = data[ii].y;
- if (value >= 0) {
- continue;
- }
- context.fillRect(
- offsetX + ii * step,
- y(-value)!,
- step + 1,
- y(0)! - y(-value)!,
- );
- }
+ context.fillRect(
+ offsetX + ii * step,
+ y(-value)!,
+ step + 1,
+ y(0)! - y(-value)!,
+ );
}
}
}
- }
-
- render() {
- const { className, title, width, height } = this.props;
-
- return (
- <div className={`horizon-row ${className}`}>
- <span className="title">{title}</span>
- <canvas
- ref={c => {
- this.canvas = c;
- }}
- width={width}
- height={height}
- />
- </div>
- );
- }
+ }, [
+ rawData,
+ yDomain,
+ width,
+ height,
+ bands,
+ colors,
+ colorScale,
+ offsetX,
+ mode,
+ ]);
+
+ useEffect(() => {
+ drawChart();
+ }, [drawChart]);
+
+ return (
+ <div className={`horizon-row ${className}`}>
+ <span className="title">{title}</span>
+ <canvas ref={canvasRef} width={width} height={height} />
+ </div>
+ );
}
-export default HorizonRow;
+export default memo(HorizonRow);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
index 361a27610c9..62e522f3ede 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/MapBox.tsx
@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
-/* eslint-disable react/forbid-prop-types, react/require-default-props */
-import { Component } from 'react';
+import { useState, useCallback, useMemo, memo } from 'react';
import MapGL from 'react-map-gl';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
@@ -63,30 +61,24 @@ interface MapBoxProps {
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
}
-interface MapBoxState {
- viewport: Viewport;
-}
-
-const defaultProps: Partial<MapBoxProps> = {
- width: 400,
- height: 400,
- globalOpacity: 1,
- onViewportChange: NOOP,
- pointRadius: DEFAULT_POINT_RADIUS,
- pointRadiusUnit: 'Pixels',
-};
-
-class MapBox extends Component<MapBoxProps, MapBoxState> {
- static defaultProps = defaultProps;
-
- constructor(props: MapBoxProps) {
- super(props);
-
- const { width = 400, height = 400, bounds } = this.props;
- // Get a viewport that fits the given bounds, which all marks to be clustered.
- // Derive lat, lon and zoom from this viewport. This is only done on initial
- // render as the bounds don't update as we pan/zoom in the current design.
-
+function MapBox({
+ width = 400,
+ height = 400,
+ aggregatorName,
+ clusterer,
+ globalOpacity = 1,
+ hasCustomMetric,
+ mapStyle,
+ mapboxApiKey,
+ onViewportChange = NOOP,
+ pointRadius = DEFAULT_POINT_RADIUS,
+ pointRadiusUnit = 'Pixels',
+ renderWhileDragging,
+ rgb,
+ bounds,
+}: MapBoxProps) {
+ // Compute initial viewport from bounds
+ const initialViewport = useMemo((): Viewport => {
let latitude = 0;
let longitude = 0;
let zoom = 1;
@@ -100,92 +92,72 @@ class MapBox extends Component<MapBoxProps, MapBoxState> {
({ latitude, longitude, zoom } = mercator);
}
- this.state = {
- viewport: {
- longitude,
- latitude,
- zoom,
- },
- };
- this.handleViewportChange = this.handleViewportChange.bind(this);
- }
-
- handleViewportChange(viewport: Viewport) {
- this.setState({ viewport });
- const { onViewportChange } = this.props;
- onViewportChange!(viewport);
- }
-
- render() {
- const {
- width,
- height,
- aggregatorName,
- clusterer,
- globalOpacity,
- mapStyle,
- mapboxApiKey,
- pointRadius,
- pointRadiusUnit,
- renderWhileDragging,
- rgb,
- hasCustomMetric,
- bounds,
- } = this.props;
- const { viewport } = this.state;
- const isDragging =
- viewport.isDragging === undefined ? false : viewport.isDragging;
-
- // Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
- // to an area outside of the original bounds, no additional queries are made to the backend to
- // retrieve additional data.
- // add this variable to widen the visible area
- const offsetHorizontal = ((width ?? 400) * 0.5) / 100;
- const offsetVertical = ((height ?? 400) * 0.5) / 100;
-
- // Guard against empty datasets where bounds may be undefined
- const bbox =
- bounds && bounds[0] && bounds[1]
- ? [
- bounds[0][0] - offsetHorizontal,
- bounds[0][1] - offsetVertical,
- bounds[1][0] + offsetHorizontal,
- bounds[1][1] + offsetVertical,
- ]
- : [-180, -90, 180, 90]; // Default to world bounds
-
- const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
-
- return (
- <MapGL
+ return { longitude, latitude, zoom };
+ }, []); // Only compute once on mount - bounds don't update as we pan/zoom
+
+ const [viewport, setViewport] = useState<Viewport>(initialViewport);
+
+ const handleViewportChange = useCallback(
+ (newViewport: Viewport) => {
+ setViewport(newViewport);
+ onViewportChange(newViewport);
+ },
+ [onViewportChange],
+ );
+
+ const isDragging =
+ viewport.isDragging === undefined ? false : viewport.isDragging;
+
+ // Compute the clusters based on the original bounds and current zoom level. Note when zoom/pan
+ // to an area outside of the original bounds, no additional queries are made to the backend to
+ // retrieve additional data.
+ // add this variable to widen the visible area
+ const offsetHorizontal = (width * 0.5) / 100;
+ const offsetVertical = (height * 0.5) / 100;
+
+ // Guard against empty datasets where bounds may be undefined
+ const bbox =
+ bounds && bounds[0] && bounds[1]
+ ? [
+ bounds[0][0] - offsetHorizontal,
+ bounds[0][1] - offsetVertical,
+ bounds[1][0] + offsetHorizontal,
+ bounds[1][1] + offsetVertical,
+ ]
+ : [-180, -90, 180, 90]; // Default to world bounds
+
+ const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
+
+ const lngLatAccessor = useCallback((location: GeoJSONLocation) => {
+ const { coordinates } = location.geometry;
+ return [coordinates[0], coordinates[1]] as [number, number];
+ }, []);
+
+ return (
+ <MapGL
+ {...viewport}
+ mapStyle={mapStyle}
+ width={width}
+ height={height}
+ mapboxApiAccessToken={mapboxApiKey}
+ onViewportChange={handleViewportChange}
+ preserveDrawingBuffer
+ >
+ <ScatterPlotGlowOverlay
{...viewport}
- mapStyle={mapStyle}
- width={width}
- height={height}
- mapboxApiAccessToken={mapboxApiKey}
- onViewportChange={this.handleViewportChange}
- preserveDrawingBuffer
- >
- <ScatterPlotGlowOverlay
- {...viewport}
- isDragging={isDragging}
- locations={clusters}
- dotRadius={pointRadius}
- pointRadiusUnit={pointRadiusUnit}
- rgb={rgb}
- globalOpacity={globalOpacity}
- compositeOperation="screen"
- renderWhileDragging={renderWhileDragging}
- aggregation={hasCustomMetric ? aggregatorName : undefined}
- lngLatAccessor={(location: GeoJSONLocation) => {
- const { coordinates } = location.geometry;
-
- return [coordinates[0], coordinates[1]];
- }}
- />
- </MapGL>
- );
- }
+ isDragging={isDragging}
+ locations={clusters}
+ dotRadius={pointRadius}
+ pointRadiusUnit={pointRadiusUnit}
+ rgb={rgb}
+ globalOpacity={globalOpacity}
+ compositeOperation="screen"
+ renderWhileDragging={renderWhileDragging}
+ aggregation={hasCustomMetric ? aggregatorName : undefined}
+ lngLatAccessor={lngLatAccessor}
+ />
+ </MapGL>
+ );
}
-export default MapBox;
+export default memo(MapBox);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
index c946d3f2825..37ff8bcf009 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-map-box/src/ScatterPlotGlowOverlay.tsx
@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-/* eslint-disable react/require-default-props */
-import { PureComponent } from 'react';
+import { memo, useCallback } from 'react';
import { CanvasOverlay } from 'react-map-gl';
import { kmToPixels, MILES_PER_KM } from './utils/geo';
import roundDecimal from './utils/roundDecimal';
@@ -61,16 +60,10 @@ interface ScatterPlotGlowOverlayProps {
isDragging?: boolean;
}
-const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
- // Same as browser default.
- compositeOperation: 'source-over',
- dotRadius: 4,
- lngLatAccessor: (location: GeoJSONLocation) => [
- location.geometry.coordinates[0],
- location.geometry.coordinates[1],
- ],
- renderWhileDragging: true,
-};
+const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
+ location.geometry.coordinates[0],
+ location.geometry.coordinates[1],
+];
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
@@ -101,300 +94,304 @@ const computeClusterLabel = (
return count;
};
-class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
- static defaultProps = defaultProps;
-
- constructor(props: ScatterPlotGlowOverlayProps) {
- super(props);
- this.redraw = this.redraw.bind(this);
- }
-
- drawText(
- ctx: CanvasRenderingContext2D,
- pixel: [number, number],
- options: DrawTextOptions = {},
- ) {
- const IS_DARK_THRESHOLD = 110;
- const {
- fontHeight = 0,
- label = '',
- radius = 0,
- rgb = [0, 0, 0],
- shadow = false,
- } = options;
- const maxWidth = radius * 1.8;
- const luminance = luminanceFromRGB(
- rgb[1] as number,
- rgb[2] as number,
- rgb[3] as number,
- );
-
- ctx.globalCompositeOperation = 'source-over';
- ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
- ctx.font = `${fontHeight}px sans-serif`;
- ctx.textAlign = 'center';
- ctx.textBaseline = 'middle';
- if (shadow) {
- ctx.shadowBlur = 15;
- ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
- }
-
- const textWidth = ctx.measureText(String(label)).width;
- if (textWidth > maxWidth) {
- const scale = fontHeight / textWidth;
- ctx.font = `${scale * maxWidth}px sans-serif`;
- }
-
- const { compositeOperation } = this.props;
-
- ctx.fillText(String(label), pixel[0], pixel[1]);
- ctx.globalCompositeOperation = (compositeOperation ??
- 'source-over') as GlobalCompositeOperation;
- ctx.shadowBlur = 0;
- ctx.shadowColor = '';
- }
-
- // Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js
- redraw({ width, height, ctx, isDragging, project }: RedrawParams) {
- const {
- aggregation,
- compositeOperation,
- dotRadius,
- globalOpacity,
- lngLatAccessor,
- locations,
- pointRadiusUnit,
- renderWhileDragging,
- rgb,
- zoom,
- } = this.props;
-
- const radius = dotRadius ?? 4;
- const clusterLabelMap: (number | string)[] = [];
-
- locations.forEach((location, i) => {
- if (location.properties.cluster) {
- clusterLabelMap[i] = computeClusterLabel(
- location.properties,
- aggregation,
- );
+function ScatterPlotGlowOverlay({
+ aggregation,
+ compositeOperation = 'source-over',
+ dotRadius = 4,
+ globalOpacity,
+ lngLatAccessor = defaultLngLatAccessor,
+ locations,
+ pointRadiusUnit,
+ renderWhileDragging = true,
+ rgb,
+ zoom,
+}: ScatterPlotGlowOverlayProps) {
+ const drawText = useCallback(
+ (
+ ctx: CanvasRenderingContext2D,
+ pixel: [number, number],
+ options: DrawTextOptions = {},
+ ) => {
+ const IS_DARK_THRESHOLD = 110;
+ const {
+ fontHeight = 0,
+ label = '',
+ radius = 0,
+ rgb: rgbOption = [0, 0, 0],
+ shadow = false,
+ } = options;
+ const maxWidth = radius * 1.8;
+ const luminance = luminanceFromRGB(
+ rgbOption[1] as number,
+ rgbOption[2] as number,
+ rgbOption[3] as number,
+ );
+
+ ctx.globalCompositeOperation = 'source-over';
+ ctx.fillStyle = luminance <= IS_DARK_THRESHOLD ? 'white' : 'black';
+ ctx.font = `${fontHeight}px sans-serif`;
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ if (shadow) {
+ ctx.shadowBlur = 15;
+ ctx.shadowColor = luminance <= IS_DARK_THRESHOLD ? 'black' : '';
}
- });
- const filteredLabels = clusterLabelMap.filter(
- v => !Number.isNaN(v),
- ) as number[];
- // Guard against empty array or zero max to prevent NaN from division
- const maxLabel =
- filteredLabels.length > 0 ? Math.max(...filteredLabels) : 1;
- const safeMaxLabel = maxLabel > 0 ? maxLabel : 1;
+ const textWidth = ctx.measureText(String(label)).width;
+ if (textWidth > maxWidth) {
+ const scale = fontHeight / textWidth;
+ ctx.font = `${scale * maxWidth}px sans-serif`;
+ }
- // Calculate min/max radius values for Pixels mode scaling
- let minRadiusValue = Infinity;
- let maxRadiusValue = -Infinity;
- if (pointRadiusUnit === 'Pixels') {
- locations.forEach(location => {
- // Accept both null and undefined as "no value" and coerce potential numeric strings
- if (
- !location.properties.cluster &&
- location.properties.radius != null
- ) {
- const radiusValueRaw = location.properties.radius;
- const radiusValue = Number(radiusValueRaw);
- if (Number.isFinite(radiusValue)) {
- minRadiusValue = Math.min(minRadiusValue, radiusValue);
- maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
- }
+ ctx.fillText(String(label), pixel[0], pixel[1]);
+ ctx.globalCompositeOperation = (compositeOperation ??
+ 'source-over') as GlobalCompositeOperation;
+ ctx.shadowBlur = 0;
+ ctx.shadowColor = '';
+ },
+ [compositeOperation],
+ );
+
+ const redraw = useCallback(
+ ({ width, height, ctx, isDragging, project }: RedrawParams) => {
+ const radius = dotRadius ?? 4;
+ const clusterLabelMap: (number | string)[] = [];
+
+ locations.forEach((location, i) => {
+ if (location.properties.cluster) {
+ clusterLabelMap[i] = computeClusterLabel(
+ location.properties,
+ aggregation,
+ );
}
});
- }
-
- ctx.clearRect(0, 0, width, height);
- ctx.globalCompositeOperation = (compositeOperation ??
- 'source-over') as GlobalCompositeOperation;
-
- if ((renderWhileDragging || !isDragging) && locations) {
- locations.forEach(function _forEach(
- this: ScatterPlotGlowOverlay,
- location: GeoJSONLocation,
- i: number,
- ) {
- const pixel = project(lngLatAccessor!(location)) as [number, number];
- const pixelRounded: [number, number] = [
- roundDecimal(pixel[0], 1),
- roundDecimal(pixel[1], 1),
- ];
- if (
- pixelRounded[0] + radius >= 0 &&
- pixelRounded[0] - radius < width &&
- pixelRounded[1] + radius >= 0 &&
- pixelRounded[1] - radius < height
- ) {
- ctx.beginPath();
- if (location.properties.cluster) {
- let clusterLabel = clusterLabelMap[i];
- // Validate clusterLabel is a finite number before using it for radius calculation
- const numericLabel = Number(clusterLabel);
- const safeNumericLabel = Number.isFinite(numericLabel)
- ? numericLabel
- : 0;
- const scaledRadius = roundDecimal(
- (safeNumericLabel / safeMaxLabel) ** 0.5 * radius,
- 1,
- );
- const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
- const [x, y] = pixelRounded;
- const gradient = ctx.createRadialGradient(
- x,
- y,
- scaledRadius,
- x,
- y,
- 0,
- );
-
- gradient.addColorStop(
- 1,
- `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
- );
- gradient.addColorStop(
- 0,
- `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
- );
- ctx.arc(
- pixelRounded[0],
- pixelRounded[1],
- scaledRadius,
- 0,
- Math.PI * 2,
- );
- ctx.fillStyle = gradient;
- ctx.fill();
-
- if (Number.isFinite(safeNumericLabel)) {
- let label: string | number = clusterLabel;
- if (safeNumericLabel >= 10000) {
- label = `${Math.round(safeNumericLabel / 1000)}k`;
- } else if (safeNumericLabel >= 1000) {
- label = `${Math.round(safeNumericLabel / 100) / 10}k`;
- }
- this.drawText(ctx, pixelRounded, {
- fontHeight,
- label,
- radius: scaledRadius,
- rgb,
- shadow: true,
- });
+ const filteredLabels = clusterLabelMap.filter(
+ v => !Number.isNaN(v),
+ ) as number[];
+ // Guard against empty array or zero max to prevent NaN from division
+ const maxLabel =
+ filteredLabels.length > 0 ? Math.max(...filteredLabels) : 1;
+ const safeMaxLabel = maxLabel > 0 ? maxLabel : 1;
+
+ // Calculate min/max radius values for Pixels mode scaling
+ let minRadiusValue = Infinity;
+ let maxRadiusValue = -Infinity;
+ if (pointRadiusUnit === 'Pixels') {
+ locations.forEach(location => {
+ // Accept both null and undefined as "no value" and coerce potential numeric strings
+ if (
+ !location.properties.cluster &&
+ location.properties.radius != null
+ ) {
+ const radiusValueRaw = location.properties.radius;
+ const radiusValue = Number(radiusValueRaw);
+ if (Number.isFinite(radiusValue)) {
+ minRadiusValue = Math.min(minRadiusValue, radiusValue);
+ maxRadiusValue = Math.max(maxRadiusValue, radiusValue);
}
- } else {
- const defaultRadius = radius / 6;
- const rawRadius = location.properties.radius;
- const radiusProperty =
- typeof rawRadius === 'number' ? rawRadius : null;
- const pointMetric = location.properties.metric ?? null;
- let pointRadius: number = radiusProperty ?? defaultRadius;
- let pointLabel: string | number | undefined;
-
- if (radiusProperty != null) {
- const pointLatitude = lngLatAccessor!(location)[1];
- if (pointRadiusUnit === 'Kilometers') {
- pointLabel = `${roundDecimal(pointRadius, 2)}km`;
- pointRadius = kmToPixels(pointRadius, pointLatitude, zoom ?? 0);
- } else if (pointRadiusUnit === 'Miles') {
- pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
- pointRadius = kmToPixels(
- pointRadius * MILES_PER_KM,
- pointLatitude,
- zoom ?? 0,
- );
- } else if (pointRadiusUnit === 'Pixels') {
- // Scale pixel values to a reasonable range (radius/6 to radius/3)
- // This ensures points are visible and proportional to their values
- const MIN_POINT_RADIUS = radius / 6;
- const MAX_POINT_RADIUS = radius / 3;
+ }
+ });
+ }
- if (
- Number.isFinite(minRadiusValue) &&
- Number.isFinite(maxRadiusValue) &&
- maxRadiusValue > minRadiusValue
- ) {
- // Normalize the value to 0-1 range, then scale to pixel range
- const numericPointRadius = Number(pointRadius);
- if (!Number.isFinite(numericPointRadius)) {
- // fallback to minimum visible size when the value is not a finite number
- pointRadius = MIN_POINT_RADIUS;
+ ctx.clearRect(0, 0, width, height);
+ ctx.globalCompositeOperation = (compositeOperation ??
+ 'source-over') as GlobalCompositeOperation;
+
+ if ((renderWhileDragging || !isDragging) && locations) {
+ locations.forEach((location: GeoJSONLocation, i: number) => {
+ const pixel = project(lngLatAccessor(location)) as [number, number];
+ const pixelRounded: [number, number] = [
+ roundDecimal(pixel[0], 1),
+ roundDecimal(pixel[1], 1),
+ ];
+
+ if (
+ pixelRounded[0] + radius >= 0 &&
+ pixelRounded[0] - radius < width &&
+ pixelRounded[1] + radius >= 0 &&
+ pixelRounded[1] - radius < height
+ ) {
+ ctx.beginPath();
+ if (location.properties.cluster) {
+ const clusterLabel = clusterLabelMap[i];
+ // Validate clusterLabel is a finite number before using it for radius calculation
+ const numericLabel = Number(clusterLabel);
+ const safeNumericLabel = Number.isFinite(numericLabel)
+ ? numericLabel
+ : 0;
+ const scaledRadius = roundDecimal(
+ (safeNumericLabel / safeMaxLabel) ** 0.5 * radius,
+ 1,
+ );
+ const fontHeight = roundDecimal(scaledRadius * 0.5, 1);
+ const [x, y] = pixelRounded;
+ const gradient = ctx.createRadialGradient(
+ x,
+ y,
+ scaledRadius,
+ x,
+ y,
+ 0,
+ );
+
+ gradient.addColorStop(
+ 1,
+ `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${0.8 * (globalOpacity ?? 1)})`,
+ );
+ gradient.addColorStop(
+ 0,
+ `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, 0)`,
+ );
+ ctx.arc(
+ pixelRounded[0],
+ pixelRounded[1],
+ scaledRadius,
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fillStyle = gradient;
+ ctx.fill();
+
+ if (Number.isFinite(safeNumericLabel)) {
+ let label: string | number = clusterLabel;
+ if (safeNumericLabel >= 10000) {
+ label = `${Math.round(safeNumericLabel / 1000)}k`;
+ } else if (safeNumericLabel >= 1000) {
+ label = `${Math.round(safeNumericLabel / 100) / 10}k`;
+ }
+ drawText(ctx, pixelRounded, {
+ fontHeight,
+ label,
+ radius: scaledRadius,
+ rgb,
+ shadow: true,
+ });
+ }
+ } else {
+ const defaultRadius = radius / 6;
+ const rawRadius = location.properties.radius;
+ const radiusProperty =
+ typeof rawRadius === 'number' ? rawRadius : null;
+ const pointMetric = location.properties.metric ?? null;
+ let pointRadius: number = radiusProperty ?? defaultRadius;
+ let pointLabel: string | number | undefined;
+
+ if (radiusProperty != null) {
+ const pointLatitude = lngLatAccessor(location)[1];
+ if (pointRadiusUnit === 'Kilometers') {
+ pointLabel = `${roundDecimal(pointRadius, 2)}km`;
+ pointRadius = kmToPixels(
+ pointRadius,
+ pointLatitude,
+ zoom ?? 0,
+ );
+ } else if (pointRadiusUnit === 'Miles') {
+ pointLabel = `${roundDecimal(pointRadius, 2)}mi`;
+ pointRadius = kmToPixels(
+ pointRadius * MILES_PER_KM,
+ pointLatitude,
+ zoom ?? 0,
+ );
+ } else if (pointRadiusUnit === 'Pixels') {
+ // Scale pixel values to a reasonable range (radius/6 to radius/3)
+ // This ensures points are visible and proportional to their values
+ const MIN_POINT_RADIUS = radius / 6;
+ const MAX_POINT_RADIUS = radius / 3;
+
+ if (
+ Number.isFinite(minRadiusValue) &&
+ Number.isFinite(maxRadiusValue) &&
+ maxRadiusValue > minRadiusValue
+ ) {
+ // Normalize the value to 0-1 range, then scale to pixel range
+ const numericPointRadius = Number(pointRadius);
+ if (!Number.isFinite(numericPointRadius)) {
+ // fallback to minimum visible size when the value is not a finite number
+ pointRadius = MIN_POINT_RADIUS;
+ } else {
+ const normalizedValueRaw =
+ (numericPointRadius - minRadiusValue) /
+ (maxRadiusValue - minRadiusValue);
+ const normalizedValue = Math.max(
+ 0,
+ Math.min(1, normalizedValueRaw),
+ );
+ pointRadius =
+ MIN_POINT_RADIUS +
+ normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
+ }
+ pointLabel = `${roundDecimal(radiusProperty, 2)}`;
+ } else if (
+ Number.isFinite(minRadiusValue) &&
+ minRadiusValue === maxRadiusValue
+ ) {
+ // All values are the same, use a fixed medium size
+ pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
+ pointLabel = `${roundDecimal(radiusProperty, 2)}`;
} else {
- const normalizedValueRaw =
- (numericPointRadius - minRadiusValue) /
- (maxRadiusValue - minRadiusValue);
- const normalizedValue = Math.max(
- 0,
- Math.min(1, normalizedValueRaw),
+ // Use raw pixel values if they're already in a reasonable range
+ pointRadius = Math.max(
+ MIN_POINT_RADIUS,
+ Math.min(pointRadius, MAX_POINT_RADIUS),
);
- pointRadius =
- MIN_POINT_RADIUS +
- normalizedValue * (MAX_POINT_RADIUS - MIN_POINT_RADIUS);
+ pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
- pointLabel = `${roundDecimal(radiusProperty, 2)}`;
- } else if (
- Number.isFinite(minRadiusValue) &&
- minRadiusValue === maxRadiusValue
- ) {
- // All values are the same, use a fixed medium size
- pointRadius = (MIN_POINT_RADIUS + MAX_POINT_RADIUS) / 2;
- pointLabel = `${roundDecimal(radiusProperty, 2)}`;
- } else {
- // Use raw pixel values if they're already in a reasonable range
- pointRadius = Math.max(
- MIN_POINT_RADIUS,
- Math.min(pointRadius, MAX_POINT_RADIUS),
- );
- pointLabel = `${roundDecimal(radiusProperty, 2)}`;
}
}
- }
-
- if (pointMetric !== null) {
- const numericMetric = parseFloat(String(pointMetric));
- pointLabel = Number.isFinite(numericMetric)
- ? roundDecimal(numericMetric, 2)
- : String(pointMetric);
- }
- // Fall back to default points if pointRadius wasn't a numerical column
- if (!pointRadius) {
- pointRadius = defaultRadius;
- }
+ if (pointMetric !== null) {
+ const numericMetric = parseFloat(String(pointMetric));
+ pointLabel = Number.isFinite(numericMetric)
+ ? roundDecimal(numericMetric, 2)
+ : String(pointMetric);
+ }
- ctx.arc(
- pixelRounded[0],
- pixelRounded[1],
- roundDecimal(pointRadius, 1),
- 0,
- Math.PI * 2,
- );
- ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
- ctx.fill();
+ // Fall back to default points if pointRadius wasn't a numerical column
+ if (!pointRadius) {
+ pointRadius = defaultRadius;
+ }
- if (pointLabel !== undefined) {
- this.drawText(ctx, pixelRounded, {
- fontHeight: roundDecimal(pointRadius, 1),
- label: pointLabel,
- radius: pointRadius,
- rgb,
- shadow: false,
- });
+ ctx.arc(
+ pixelRounded[0],
+ pixelRounded[1],
+ roundDecimal(pointRadius, 1),
+ 0,
+ Math.PI * 2,
+ );
+ ctx.fillStyle = `rgba(${rgb![1]}, ${rgb![2]}, ${rgb![3]}, ${globalOpacity})`;
+ ctx.fill();
+
+ if (pointLabel !== undefined) {
+ drawText(ctx, pixelRounded, {
+ fontHeight: roundDecimal(pointRadius, 1),
+ label: pointLabel,
+ radius: pointRadius,
+ rgb,
+ shadow: false,
+ });
+ }
}
}
- }
- }, this);
- }
- }
+ });
+ }
+ },
+ [
+ aggregation,
+ compositeOperation,
+ dotRadius,
+ drawText,
+ globalOpacity,
+ lngLatAccessor,
+ locations,
+ pointRadiusUnit,
+ renderWhileDragging,
+ rgb,
+ zoom,
+ ],
+ );
- render() {
- return <CanvasOverlay redraw={this.redraw} />;
- }
+ return <CanvasOverlay redraw={redraw} />;
}
-export default ScatterPlotGlowOverlay;
+export default memo(ScatterPlotGlowOverlay);
diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
index 65f94ea66b7..8c2aa161750 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx
@@ -17,27 +17,19 @@
* under the License.
*/
/* eslint-disable react/no-array-index-key */
-import { PureComponent } from 'react';
import { styled } from '@apache-superset/core/ui';
import TTestTable, { DataEntry } from './TTestTable';
interface PairedTTestProps {
- alpha: number;
- className: string;
+ alpha?: number;
+ className?: string;
data: Record<string, DataEntry[]>;
groups: string[];
- liftValPrec: number;
+ liftValPrec?: number;
metrics: string[];
- pValPrec: number;
+ pValPrec?: number;
}
-const defaultProps = {
- alpha: 0.05,
- className: '',
- liftValPrec: 4,
- pValPrec: 6,
-};
-
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-paired_ttest .scrollbar-container {
@@ -114,35 +106,36 @@ const StyledDiv = styled.div`
`}
`;
-class PairedTTest extends PureComponent<PairedTTestProps> {
- static defaultProps = defaultProps;
-
- render() {
- const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } =
- this.props;
-
- return (
- <StyledDiv>
- <div className={`superset-legacy-chart-paired-t-test ${className}`}>
- <div className="paired-ttest-table">
- <div className="scrollbar-content">
- {metrics.map((metric, i) => (
- <TTestTable
- key={i}
- metric={metric}
- groups={groups}
- data={data[metric]}
- alpha={alpha}
- pValPrec={Math.min(pValPrec, 32)}
- liftValPrec={Math.min(liftValPrec, 32)}
- />
- ))}
- </div>
+function PairedTTest({
+ alpha = 0.05,
+ className = '',
+ data,
+ groups,
+ liftValPrec = 4,
+ metrics,
+ pValPrec = 6,
+}: PairedTTestProps) {
+ return (
+ <StyledDiv>
+ <div className={`superset-legacy-chart-paired-t-test ${className}`}>
+ <div className="paired-ttest-table">
+ <div className="scrollbar-content">
+ {metrics.map((metric, i) => (
+ <TTestTable
+ key={i}
+ metric={metric}
+ groups={groups}
+ data={data[metric]}
+ alpha={alpha}
+ pValPrec={Math.min(pValPrec, 32)}
+ liftValPrec={Math.min(liftValPrec, 32)}
+ />
+ ))}
</div>
</div>
- </StyledDiv>
- );
- }
+ </div>
+ </StyledDiv>
+ );
}
export default PairedTTest;
diff --git a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
index abc2a2ccff2..36210009277 100644
--- a/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
+++ b/superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
@@ -18,7 +18,7 @@
*/
/* eslint-disable react/no-array-index-key, react/jsx-no-bind */
import dist from 'distributions';
-import { Component } from 'react';
+import { useState, useEffect, useCallback, useMemo } from 'react';
import { Table, Tr, Td, Thead, Th } from 'reactable';
interface DataPointValue {
@@ -32,279 +32,295 @@ export interface DataEntry {
}
interface TTestTableProps {
- alpha: number;
+ alpha?: number;
data: DataEntry[];
groups: string[];
- liftValPrec: number;
+ liftValPrec?: number;
metric: string;
- pValPrec: number;
+ pValPrec?: number;
}
-interface TTestTableState {
- control: number;
- liftValues: (string | number)[];
- pValues: (string | number)[];
-}
-
-const defaultProps = {
- alpha: 0.05,
- liftValPrec: 4,
- pValPrec: 6,
-};
+function TTestTable({
+ alpha = 0.05,
+ data,
+ groups,
+ liftValPrec = 4,
+ metric,
+ pValPrec = 6,
+}: TTestTableProps) {
+ const [control, setControl] = useState(0);
+ const [liftValues, setLiftValues] = useState<(string | number)[]>([]);
+ const [pValues, setPValues] = useState<(string | number)[]>([]);
-class TTestTable extends Component<TTestTableProps, TTestTableState> {
- static defaultProps = defaultProps;
+ const computeLift = useCallback(
+ (values: DataPointValue[], controlValues: DataPointValue[]): string => {
+ // Compute the lift value between two time series
+ let sumValues = 0;
+ let sumControl = 0;
+ values.forEach((value, i) => {
+ sumValues += value.y;
+ sumControl += controlValues[i].y;
+ });
- constructor(props: TTestTableProps) {
- super(props);
- this.state = {
- control: 0,
- liftValues: [],
- pValues: [],
- };
- }
+ return (((sumValues - sumControl) / sumControl) * 100).toFixed(
+ liftValPrec,
+ );
+ },
+ [liftValPrec],
+ );
- componentDidMount() {
- const { control } = this.state;
- this.computeTTest(control); // initially populate table
- }
+ const computePValue = useCallback(
+ (
+ values: DataPointValue[],
+ controlValues: DataPointValue[],
+ ): string | number => {
+ // Compute the p-value from Student's t-test
+ // between two time series
+ let diffSum = 0;
+ let diffSqSum = 0;
+ let finiteCount = 0;
+ values.forEach((value, i) => {
+ const diff = controlValues[i].y - value.y;
+ /* eslint-disable-next-line */
+ if (isFinite(diff)) {
+ finiteCount += 1;
+ diffSum += diff;
+ diffSqSum += diff * diff;
+ }
+ });
+ const tvalue = -Math.abs(
+ diffSum *
+ Math.sqrt(
+ (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
+ ),
+ );
+ try {
+ return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
+ pValPrec,
+ ); // two-sided test
+ } catch (error) {
+ return NaN;
+ }
+ },
+ [pValPrec],
+ );
- getLiftStatus(row: number): string {
- const { control, liftValues } = this.state;
- // Get a css class name for coloring
- if (row === control) {
- return 'control';
- }
- const liftVal = liftValues[row];
- if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
- return 'invalid'; // infinite or NaN values
- }
+ const computeTTest = useCallback(
+ (controlIndex: number) => {
+ // Compute lift and p-values for each row
+ // against the selected control
+ const newPValues: (string | number)[] = [];
+ const newLiftValues: (string | number)[] = [];
+ if (!data) {
+ return;
+ }
+ for (let i = 0; i < data.length; i += 1) {
+ if (i === controlIndex) {
+ newPValues.push('control');
+ newLiftValues.push('control');
+ } else {
+ newPValues.push(
+ computePValue(data[i].values, data[controlIndex].values),
+ );
+ newLiftValues.push(
+ computeLift(data[i].values, data[controlIndex].values),
+ );
+ }
+ }
+ setControl(controlIndex);
+ setLiftValues(newLiftValues);
+ setPValues(newPValues);
+ },
+ [data, computeLift, computePValue],
+ );
- return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false
- }
+ // Initially populate table on mount
+ useEffect(() => {
+ computeTTest(control);
+ }, [computeTTest, control]);
- getPValueStatus(row: number): string {
- const { control, pValues } = this.state;
- if (row === control) {
- return 'control';
- }
- const pVal = pValues[row];
- if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
- return 'invalid';
- }
+ const getLiftStatus = useCallback(
+ (row: number): string => {
+ // Get a css class name for coloring
+ if (row === control) {
+ return 'control';
+ }
+ const liftVal = liftValues[row];
+ if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
+ return 'invalid'; // infinite or NaN values
+ }
- return ''; // p-values won't normally be colored
- }
+ return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false
+ },
+ [control, liftValues],
+ );
- getSignificance(row: number): string | boolean {
- const { control, pValues } = this.state;
- const { alpha } = this.props;
- // Color significant as green, else red
- if (row === control) {
- return 'control';
- }
+ const getPValueStatus = useCallback(
+ (row: number): string => {
+ if (row === control) {
+ return 'control';
+ }
+ const pVal = pValues[row];
+ if (Number.isNaN(pVal) || !Number.isFinite(pVal)) {
+ return 'invalid';
+ }
- // p-values significant below set threshold
- return Number(pValues[row]) <= alpha;
- }
+ return ''; // p-values won't normally be colored
+ },
+ [control, pValues],
+ );
- computeLift(values: DataPointValue[], control: DataPointValue[]): string {
- const { liftValPrec } = this.props;
- // Compute the lift value between two time series
- let sumValues = 0;
- let sumControl = 0;
- values.forEach((value, i) => {
- sumValues += value.y;
- sumControl += control[i].y;
- });
+ const getSignificance = useCallback(
+ (row: number): string | boolean => {
+ // Color significant as green, else red
+ if (row === control) {
+ return 'control';
+ }
- return (((sumValues - sumControl) / sumControl) * 100).toFixed(liftValPrec);
- }
+ // p-values significant below set threshold
+ return Number(pValues[row]) <= alpha;
+ },
+ [control, pValues, alpha],
+ );
- computePValue(
- values: DataPointValue[],
- control: DataPointValue[],
- ): string | number {
- const { pValPrec } = this.props;
- // Compute the p-value from Student's t-test
- // between two time series
- let diffSum = 0;
- let diffSqSum = 0;
- let finiteCount = 0;
- values.forEach((value, i) => {
- const diff = control[i].y - value.y;
- /* eslint-disable-next-line */
- if (isFinite(diff)) {
- finiteCount += 1;
- diffSum += diff;
- diffSqSum += diff * diff;
- }
- });
- const tvalue = -Math.abs(
- diffSum *
- Math.sqrt(
- (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
- ),
- );
- try {
- return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
- pValPrec,
- ); // two-sided test
- } catch (error) {
- return NaN;
- }
- }
+ const handleRowClick = useCallback(
+ (rowIndex: number) => {
+ computeTTest(rowIndex);
+ },
+ [computeTTest],
+ );
- computeTTest(control: number) {
- // Compute lift and p-values for each row
- // against the selected control
- const { data } = this.props;
- const pValues: (string | number)[] = [];
- const liftValues: (string | number)[] = [];
- if (!data) {
- return;
- }
- for (let i = 0; i < data.length; i += 1) {
- if (i === control) {
- pValues.push('control');
- liftValues.push('control');
- } else {
- pValues.push(this.computePValue(data[i].values, data[control].values));
- liftValues.push(this.computeLift(data[i].values, data[control].values));
- }
- }
- this.setState({ control, liftValues, pValues });
+ if (!Array.isArray(groups) || groups.length === 0) {
+ throw Error('Group by param is required');
}
- render() {
- const { data, metric, groups } = this.props;
- const { control, liftValues, pValues } = this.state;
-
- if (!Array.isArray(groups) || groups.length === 0) {
- throw Error('Group by param is required');
- }
+ // Render column header for each group
+ const columns = groups.map((group, i) => (
+ <Th key={i} column={group}>
+ {group}
+ </Th>
+ ));
+ const numGroups = groups.length;
+ // Columns for p-value, lift-value, and significance (true/false)
+ columns.push(
+ <Th key={numGroups + 1} column="pValue">
+ p-value
+ </Th>,
+ );
+ columns.push(
+ <Th key={numGroups + 2} column="liftValue">
+ Lift %
+ </Th>,
+ );
+ columns.push(
+ <Th key={numGroups + 3} column="significant">
+ Significant
+ </Th>,
+ );
- // Render column header for each group
- const columns = groups.map((group, i) => (
- <Th key={i} column={group}>
- {group}
- </Th>
- ));
- const numGroups = groups.length;
- // Columns for p-value, lift-value, and significance (true/false)
- columns.push(
- <Th key={numGroups + 1} column="pValue">
- p-value
- </Th>,
+ const rows = data.map((entry, i) => {
+ const values = groups.map(
+ (
+ group,
+ j, // group names
+ ) => <Td key={j} column={group} data={entry.group[j]} />,
);
- columns.push(
- <Th key={numGroups + 2} column="liftValue">
- Lift %
- </Th>,
+ values.push(
+ <Td
+ key={numGroups + 1}
+ className={getPValueStatus(i)}
+ column="pValue"
+ data={pValues[i]}
+ />,
);
- columns.push(
- <Th key={numGroups + 3} column="significant">
- Significant
- </Th>,
+ values.push(
+ <Td
+ key={numGroups + 2}
+ className={getLiftStatus(i)}
+ column="liftValue"
+ data={liftValues[i]}
+ />,
+ );
+ values.push(
+ <Td
+ key={numGroups + 3}
+ className={getSignificance(i).toString()}
+ column="significant"
+ data={getSignificance(i)}
+ />,
);
- const rows = data.map((entry, i) => {
- const values = groups.map(
- (
- group,
- j, // group names
- ) => <Td key={j} column={group} data={entry.group[j]} />,
- );
- values.push(
- <Td
- key={numGroups + 1}
- className={this.getPValueStatus(i)}
- column="pValue"
- data={pValues[i]}
- />,
- );
- values.push(
- <Td
- key={numGroups + 2}
- className={this.getLiftStatus(i)}
- column="liftValue"
- data={liftValues[i]}
- />,
- );
- values.push(
- <Td
- key={numGroups + 3}
- className={this.getSignificance(i).toString()}
- column="significant"
- data={this.getSignificance(i)}
- />,
- );
- return (
- <Tr
- key={i}
- className={i === control ? 'control' : ''}
- onClick={this.computeTTest.bind(this, i)}
- >
- {values}
- </Tr>
- );
- });
- // When sorted ascending, 'control' will always be at top
- type SortConfigItem =
- | string
- | { column: string; sortFunction: (a: string, b: string) => number };
- const sortConfig: SortConfigItem[] = (groups as SortConfigItem[]).concat([
- {
- column: 'pValue',
- sortFunction: (a: string, b: string) => {
- if (a === 'control') {
- return -1;
- }
- if (b === 'control') {
- return 1;
- }
+ return (
+ <Tr
+ key={i}
+ className={i === control ? 'control' : ''}
+ onClick={() => handleRowClick(i)}
+ >
+ {values}
+ </Tr>
+ );
+ });
+
+ // When sorted ascending, 'control' will always be at top
+ type SortConfigItem =
+ | string
+ | { column: string; sortFunction: (a: string, b: string) => number };
- return a > b ? 1 : -1; // p-values ascending
+ const sortConfig: SortConfigItem[] = useMemo(
+ () =>
+ (groups as SortConfigItem[]).concat([
+ {
+ column: 'pValue',
+ sortFunction: (a: string, b: string) => {
+ if (a === 'control') {
+ return -1;
+ }
+ if (b === 'control') {
+ return 1;
+ }
+
+ return a > b ? 1 : -1; // p-values ascending
+ },
},
- },
- {
- column: 'liftValue',
- sortFunction: (a: string, b: string) => {
- if (a === 'control') {
- return -1;
- }
- if (b === 'control') {
- return 1;
- }
+ {
+ column: 'liftValue',
+ sortFunction: (a: string, b: string) => {
+ if (a === 'control') {
+ return -1;
+ }
+ if (b === 'control') {
+ return 1;
+ }
- return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
+ return parseFloat(a) > parseFloat(b) ? -1 : 1; // lift values descending
+ },
},
- },
- {
- column: 'significant',
- sortFunction: (a: string, b: string) => {
- if (a === 'control') {
- return -1;
- }
- if (b === 'control') {
- return 1;
- }
+ {
+ column: 'significant',
+ sortFunction: (a: string, b: string) => {
+ if (a === 'control') {
+ return -1;
+ }
+ if (b === 'control') {
+ return 1;
+ }
- return a > b ? -1 : 1; // significant values first
+ return a > b ? -1 : 1; // significant values first
+ },
},
- },
- ]);
+ ]),
+ [groups],
+ );
- return (
- <div>
- <h3>{metric}</h3>
- <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
- <Thead>{columns}</Thead>
- {rows}
- </Table>
- </div>
- );
- }
+ return (
+ <div>
+ <h3>{metric}</h3>
+ <Table className="table" id={`table_${metric}`} sortable={sortConfig}>
+ <Thead>{columns}</Thead>
+ {rows}
+ </Table>
+ </div>
+ );
}
export default TTestTable;
diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
index 9e5565b9f26..1a6716294ae 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/PivotTable.tsx
@@ -17,16 +17,14 @@
* under the License.
*/
-import { PureComponent } from 'react';
+import { memo } from 'react';
import { TableRenderer } from './TableRenderers';
import type { ComponentProps } from 'react';
type PivotTableProps = ComponentProps<typeof TableRenderer>;
-class PivotTable extends PureComponent<PivotTableProps> {
- render() {
- return <TableRenderer {...this.props} />;
- }
+function PivotTable(props: PivotTableProps) {
+ return <TableRenderer {...props} />;
}
-export default PivotTable;
+export default memo(PivotTable);
diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
index 6e536641c39..c786dda7502 100644
--- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
+++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
@@ -17,7 +17,15 @@
* under the License.
*/
-import { Component, ReactNode, MouseEvent } from 'react';
+import {
+ ReactNode,
+ MouseEvent,
+ useState,
+ useCallback,
+ useRef,
+ useMemo,
+ useEffect,
+} from 'react';
import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/ui';
import PropTypes from 'prop-types';
@@ -80,7 +88,7 @@ interface TableRendererProps {
cols: string[];
rows: string[];
aggregatorName: string;
- tableOptions: TableOptions;
+ tableOptions?: TableOptions;
subtotalOptions?: SubtotalOptions;
namesMapping?: Record<string, string>;
onContextMenu: (
@@ -93,13 +101,6 @@ interface TableRendererProps {
[key: string]: unknown;
}
-interface TableRendererState {
- collapsedRows: Record<string, boolean>;
- collapsedCols: Record<string, boolean>;
- sortingOrder: string[];
- activeSortColumn?: number | null;
-}
-
interface PivotSettings {
pivotData: InstanceType<typeof PivotData>;
colAttrs: string[];
@@ -272,68 +273,318 @@ function convertToArray(
return result;
}
-export class TableRenderer extends Component<
- TableRendererProps,
- TableRendererState
-> {
- sortCache: Map<string, string[][]>;
- cachedProps: TableRendererProps | null;
- cachedBasePivotSettings: PivotSettings | null;
-
- static propTypes: Record<string, unknown>;
- static defaultProps: Record<string, unknown>;
-
- constructor(props: TableRendererProps) {
- super(props);
-
- // We need state to record which entries are collapsed and which aren't.
- // This is an object with flat-keys indicating if the corresponding rows
- // should be collapsed.
- this.state = { collapsedRows: {}, collapsedCols: {}, sortingOrder: [] };
- this.sortCache = new Map();
- this.cachedProps = null;
- this.cachedBasePivotSettings = null;
- this.clickHeaderHandler = this.clickHeaderHandler.bind(this);
- this.clickHandler = this.clickHandler.bind(this);
- }
+export function TableRenderer({
+ cols,
+ rows,
+ aggregatorName,
+ tableOptions = {},
+ subtotalOptions,
+ namesMapping: namesMappingProp,
+ onContextMenu,
+ allowRenderHtml,
+ ...restProps
+}: TableRendererProps) {
+ const [collapsedRows, setCollapsedRows] = useState<Record<string, boolean>>(
+ {},
+ );
+ const [collapsedCols, setCollapsedCols] = useState<Record<string, boolean>>(
+ {},
+ );
+ const [sortingOrder, setSortingOrder] = useState<string[]>([]);
+ const [activeSortColumn, setActiveSortColumn] = useState<number | null>(null);
+ const [sortedRowKeys, setSortedRowKeys] = useState<string[][] | null>(null);
+
+ const sortCacheRef = useRef(new Map<string, string[][]>());
+
+ // Memoize props object to maintain referential stability
+ const props = useMemo<TableRendererProps>(
+ () => ({
+ cols,
+ rows,
+ aggregatorName,
+ tableOptions,
+ subtotalOptions,
+ namesMapping: namesMappingProp,
+ onContextMenu,
+ allowRenderHtml,
+ ...restProps,
+ }),
+ [
+ cols,
+ rows,
+ aggregatorName,
+ tableOptions,
+ subtotalOptions,
+ namesMappingProp,
+ onContextMenu,
+ allowRenderHtml,
+ restProps,
+ ],
+ );
+
+ const clickHandler = useCallback(
+ (
+ pivotData: InstanceType<typeof PivotData>,
+ rowValues: string[],
+ colValues: string[],
+ ) => {
+ const colAttrs = cols;
+ const rowAttrs = rows;
+ const value = pivotData.getAggregator(rowValues, colValues).value();
+ const filters: Record<string, string> = {};
+ const colLimit = Math.min(colAttrs.length, colValues.length);
+ for (let i = 0; i < colLimit; i += 1) {
+ const attr = colAttrs[i];
+ if (colValues[i] !== null) {
+ filters[attr] = colValues[i];
+ }
+ }
+ const rowLimit = Math.min(rowAttrs.length, rowValues.length);
+ for (let i = 0; i < rowLimit; i += 1) {
+ const attr = rowAttrs[i];
+ if (rowValues[i] !== null) {
+ filters[attr] = rowValues[i];
+ }
+ }
+ const { clickCallback } = tableOptions;
+ return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData);
+ },
+ [cols, rows, tableOptions],
+ );
- getBasePivotSettings(): PivotSettings {
+ const clickHeaderHandler = useCallback(
+ (
+ pivotData: InstanceType<typeof PivotData>,
+ values: string[],
+ attrs: string[],
+ attrIdx: number,
+ callback: HeaderClickCallback | undefined,
+ isSubtotal = false,
+ isGrandTotal = false,
+ ) => {
+ const filters: Record<string, string> = {};
+ for (let i = 0; i <= attrIdx; i += 1) {
+ const attr = attrs[i];
+ filters[attr] = values[i];
+ }
+ return (e: MouseEvent) =>
+ callback?.(
+ e,
+ values[attrIdx],
+ filters,
+ pivotData,
+ isSubtotal,
+ isGrandTotal,
+ );
+ },
+ [],
+ );
+
+ const collapseAttr = useCallback(
+ (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) =>
+ (e: MouseEvent<HTMLSpanElement>) => {
+ // Collapse an entire attribute.
+ e.stopPropagation();
+ const keyLen = attrIdx + 1;
+ const collapsed = allKeys
+ .filter((k: string[]) => k.length === keyLen)
+ .map(flatKey);
+
+ const updates: Record<string, boolean> = {};
+ collapsed.forEach((k: string) => {
+ updates[k] = true;
+ });
+
+ if (rowOrCol) {
+ setCollapsedRows(state => ({ ...state, ...updates }));
+ } else {
+ setCollapsedCols(state => ({ ...state, ...updates }));
+ }
+ },
+ [],
+ );
+
+ const expandAttr = useCallback(
+ (rowOrCol: boolean, attrIdx: number, allKeys: string[][]) =>
+ (e: MouseEvent<HTMLSpanElement>) => {
+ // Expand an entire attribute. This implicitly implies expanding all of the
+ // parents as well. It's a bit inefficient but ah well...
+ e.stopPropagation();
+ const updates: Record<string, boolean> = {};
+ allKeys.forEach((k: string[]) => {
+ for (let i = 0; i <= attrIdx; i += 1) {
+ updates[flatKey(k.slice(0, i + 1))] = false;
+ }
+ });
+
+ if (rowOrCol) {
+ setCollapsedRows(state => ({ ...state, ...updates }));
+ } else {
+ setCollapsedCols(state => ({ ...state, ...updates }));
+ }
+ },
+ [],
+ );
+
+ const toggleRowKey = useCallback(
+ (flatRowKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
+ e.stopPropagation();
+ setCollapsedRows(state => ({
+ ...state,
+ [flatRowKey]: !state[flatRowKey],
+ }));
+ },
+ [],
+ );
+
+ const toggleColKey = useCallback(
+ (flatColKey: string) => (e: MouseEvent<HTMLSpanElement>) => {
+ e.stopPropagation();
+ setCollapsedCols(state => ({
+ ...state,
+ [flatColKey]: !state[flatColKey],
+ }));
+ },
+ [],
+ );
+
+ const calcAttrSpans = useCallback((attrArr: string[][], numAttrs: number) => {
+ // Given an array of attribute values (i.e. each element is another array with
+ // the value at every level), compute the spans for every attribute value at
+ // every level. The return value is a nested array of the same shape. It has
+ // -1's for repeated values and the span number otherwise.
+
+ const spans = [];
+ // Index of the last new value
+ const li = Array(numAttrs).map(() => 0);
+ let lv: (string | null)[] = Array(numAttrs).map(() => null);
+ for (let i = 0; i < attrArr.length; i += 1) {
+ // Keep increasing span values as long as the last keys are the same. For
+ // the rest, record spans of 1. Update the indices too.
+ const cv = attrArr[i];
+ const ent = [];
+ let depth = 0;
+ const limit = Math.min(lv.length, cv.length);
+ while (depth < limit && lv[depth] === cv[depth]) {
+ ent.push(-1);
+ spans[li[depth]][depth] += 1;
+ depth += 1;
+ }
+ while (depth < cv.length) {
+ li[depth] = i;
+ ent.push(1);
+ depth += 1;
+ }
+ spans.push(ent);
+ lv = cv;
+ }
+ return spans;
+ }, []);
+
+ const getAggregatedData = useCallback(
+ (
+ pivotData: InstanceType<typeof PivotData>,
+ visibleColName: string[],
+ rowPartialOnTop: boolean | undefined,
+ ) => {
+ // Transforms flat row keys into a hierarchical group structure where each level
+ // represents a grouping dimension. For each row key path, it calculates the
+ // aggregated value for the specified column and builds a nested object that
+ // preserves the hierarchy while storing aggregation values at each level.
+ const groups: Record<string, HierarchicalNode> = {};
+ const rowsData = pivotData.rowKeys;
+ rowsData.forEach(rowKey => {
+ const aggValue =
+ pivotData.getAggregator(rowKey, visibleColName).value() ?? 0;
+
+ if (rowPartialOnTop) {
+ const parent = rowKey
+ .slice(0, -1)
+ .reduce(
+ (acc: Record<string, HierarchicalNode>, key: string) =>
+ (acc[key] ??= {}) as Record<string, HierarchicalNode>,
+ groups,
+ );
+ parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
+ } else {
+ rowKey.reduce(
+ (acc: Record<string, HierarchicalNode>, key: string) => {
+ acc[key] = acc[key] || { currentVal: 0 };
+ (acc[key] as HierarchicalNode).currentVal = aggValue as number;
+ return acc[key] as Record<string, HierarchicalNode>;
+ },
+ groups,
+ );
+ }
+ });
+ return groups;
+ },
+ [],
+ );
+
+ const sortAndCacheData = useCallback(
+ (
+ groups: Record<string, HierarchicalNode>,
+ sortOrder: string,
+ rowEnabled: boolean | undefined,
+ rowPartialOnTop: boolean | undefined,
+ maxRowIndex: number,
+ ) => {
+ // Processes hierarchical data by first sorting it according to the specified order
+ // and then converting the sorted structure into a flat array format. This function
+ // serves as an intermediate step between hierarchical data representation and
+ // flat array representation needed for rendering.
+ const sortedGroups = sortHierarchicalObject(
+ groups,
+ sortOrder,
+ rowPartialOnTop,
+ );
+ return convertToArray(
+ sortedGroups,
+ rowEnabled,
+ rowPartialOnTop,
+ maxRowIndex,
+ );
+ },
+ [],
+ );
+
+ const getBasePivotSettings = useCallback((): PivotSettings => {
// One-time extraction of pivot settings that we'll use throughout the render.
- const { props } = this;
- const colAttrs = props.cols;
- const rowAttrs = props.rows;
+ const colAttrs = cols;
+ const rowAttrs = rows;
- const tableOptions: TableOptions = {
+ const mergedTableOptions: TableOptions = {
rowTotals: true,
colTotals: true,
- ...props.tableOptions,
+ ...tableOptions,
};
- const rowTotals = tableOptions.rowTotals || colAttrs.length === 0;
- const colTotals = tableOptions.colTotals || rowAttrs.length === 0;
+ const rowTotals = mergedTableOptions.rowTotals || colAttrs.length === 0;
+ const colTotals = mergedTableOptions.colTotals || rowAttrs.length === 0;
- const namesMapping = props.namesMapping || {};
- const subtotalOptions: Required<
+ const namesMapping = namesMappingProp || {};
+ const mergedSubtotalOptions: Required<
Pick<SubtotalOptions, 'arrowCollapsed' | 'arrowExpanded'>
> &
SubtotalOptions = {
arrowCollapsed: '\u25B2',
arrowExpanded: '\u25BC',
- ...props.subtotalOptions,
+ ...subtotalOptions,
};
const colSubtotalDisplay: SubtotalDisplay = {
displayOnTop: false,
- enabled: tableOptions.colSubTotals,
+ enabled: mergedTableOptions.colSubTotals,
hideOnExpand: false,
- ...subtotalOptions.colSubtotalDisplay,
+ ...mergedSubtotalOptions.colSubtotalDisplay,
};
const rowSubtotalDisplay: SubtotalDisplay = {
displayOnTop: false,
- enabled: tableOptions.rowSubTotals,
+ enabled: mergedTableOptions.rowSubTotals,
hideOnExpand: false,
- ...subtotalOptions.rowSubtotalDisplay,
+ ...mergedSubtotalOptions.rowSubtotalDisplay,
};
const pivotData = new PivotData(props as Record<string, unknown>, {
@@ -354,14 +605,14 @@ export class TableRenderer extends Component<
const rowTotalCallbacks: Record<string, (e: MouseEvent) => void> = {};
const colTotalCallbacks: Record<string, (e: MouseEvent) => void> = {};
let grandTotalCallback: ((e: MouseEvent) => void) | null = null;
- if (tableOptions.clickCallback) {
+ if (mergedTableOptions.clickCallback) {
rowKeys.forEach(rowKey => {
const flatRowKey = flatKey(rowKey);
if (!(flatRowKey in cellCallbacks)) {
cellCallbacks[flatRowKey] = {};
}
colKeys.forEach(colKey => {
- cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler(
+ cellCallbacks[flatRowKey][flatKey(colKey)] = clickHandler(
pivotData,
rowKey,
colKey,
@@ -372,7 +623,7 @@ export class TableRenderer extends Component<
// Add in totals as well.
if (rowTotals) {
rowKeys.forEach(rowKey => {
- rowTotalCallbacks[flatKey(rowKey)] = this.clickHandler(
+ rowTotalCallbacks[flatKey(rowKey)] = clickHandler(
pivotData,
rowKey,
[],
@@ -381,7 +632,7 @@ export class TableRenderer extends Component<
}
if (colTotals) {
colKeys.forEach(colKey => {
- colTotalCallbacks[flatKey(colKey)] = this.clickHandler(
+ colTotalCallbacks[flatKey(colKey)] = clickHandler(
pivotData,
[],
colKey,
@@ -389,7 +640,7 @@ export class TableRenderer extends Component<
});
}
if (rowTotals && colTotals) {
- grandTotalCallback = this.clickHandler(pivotData, [], []);
+ grandTotalCallback = clickHandler(pivotData, [], []);
}
}
@@ -401,8 +652,8 @@ export class TableRenderer extends Component<
rowKeys,
rowTotals,
colTotals,
- arrowCollapsed: subtotalOptions.arrowCollapsed,
- arrowExpanded: subtotalOptions.arrowExpanded,
+ arrowCollapsed: mergedSubtotalOptions.arrowCollapsed,
+ arrowExpanded: mergedSubtotalOptions.arrowExpanded,
colSubtotalDisplay,
rowSubtotalDisplay,
cellCallbacks,
@@ -410,242 +661,113 @@ export class TableRenderer extends Component<
colTotalCallbacks,
grandTotalCallback,
namesMapping,
- allowRenderHtml: props.allowRenderHtml,
- };
- }
-
- clickHandler(
- pivotData: InstanceType<typeof PivotData>,
- rowValues: string[],
- colValues: string[],
- ) {
- const colAttrs = this.props.cols;
- const rowAttrs = this.props.rows;
- const value = pivotData.getAggregator(rowValues, colValues).value();
- const filters: Record<string, string> = {};
- const colLimit = Math.min(colAttrs.length, colValues.length);
- for (let i = 0; i < colLimit; i += 1) {
- const attr = colAttrs[i];
- if (colValues[i] !== null) {
- filters[attr] = colValues[i];
- }
- }
- const rowLimit = Math.min(rowAttrs.length, rowValues.length);
- for (let i = 0; i < rowLimit; i += 1) {
- const attr = rowAttrs[i];
- if (rowValues[i] !== null) {
- filters[attr] = rowValues[i];
- }
- }
- const { clickCallback } = this.props.tableOptions;
- return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData);
- }
-
- clickHeaderHandler(
- pivotData: InstanceType<typeof PivotData>,
- values: string[],
- attrs: string[],
- attrIdx: number,
- callback: HeaderClickCallback | undefined,
- isSubtotal = false,
- isGrandTotal = false,
- ) {
- const filters: Record<string, string> = {};
- for (let i = 0; i <= attrIdx; i += 1) {
- const attr = attrs[i];
- filters[attr] = values[i];
- }
- return (e: MouseEvent) =>
- callback?.(
- e,
- values[attrIdx],
- filters,
- pivotData,
- isSubtotal,
- isGrandTotal,
- );
- }
-
- collapseAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- // Collapse an entire attribute.
- e.stopPropagation();
- const keyLen = attrIdx + 1;
- const collapsed = allKeys
- .filter((k: string[]) => k.length === keyLen)
- .map(flatKey);
-
- const updates: Record<string, boolean> = {};
- collapsed.forEach((k: string) => {
- updates[k] = true;
- });
-
- if (rowOrCol) {
- this.setState(state => ({
- collapsedRows: { ...state.collapsedRows, ...updates },
- }));
- } else {
- this.setState(state => ({
- collapsedCols: { ...state.collapsedCols, ...updates },
- }));
- }
- };
- }
-
- expandAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- // Expand an entire attribute. This implicitly implies expanding all of the
- // parents as well. It's a bit inefficient but ah well...
- e.stopPropagation();
- const updates: Record<string, boolean> = {};
- allKeys.forEach((k: string[]) => {
- for (let i = 0; i <= attrIdx; i += 1) {
- updates[flatKey(k.slice(0, i + 1))] = false;
- }
- });
-
- if (rowOrCol) {
- this.setState(state => ({
- collapsedRows: { ...state.collapsedRows, ...updates },
- }));
- } else {
- this.setState(state => ({
- collapsedCols: { ...state.collapsedCols, ...updates },
- }));
- }
- };
- }
-
- toggleRowKey(flatRowKey: string) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- e.stopPropagation();
- this.setState(state => ({
- collapsedRows: {
- ...state.collapsedRows,
- [flatRowKey]: !state.collapsedRows[flatRowKey],
- },
- }));
- };
- }
-
- toggleColKey(flatColKey: string) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- e.stopPropagation();
- this.setState(state => ({
- collapsedCols: {
- ...state.collapsedCols,
- [flatColKey]: !state.collapsedCols[flatColKey],
- },
- }));
+ allowRenderHtml,
};
- }
-
- calcAttrSpans(attrArr: string[][], numAttrs: number) {
- // Given an array of attribute values (i.e. each element is another array with
- // the value at every level), compute the spans for every attribute value at
- // every level. The return value is a nested array of the same shape. It has
- // -1's for repeated values and the span number otherwise.
-
- const spans = [];
- // Index of the last new value
- const li = Array(numAttrs).map(() => 0);
- let lv: (string | null)[] = Array(numAttrs).map(() => null);
- for (let i = 0; i < attrArr.length; i += 1) {
- // Keep increasing span values as long as the last keys are the same. For
- // the rest, record spans of 1. Update the indices too.
- const cv = attrArr[i];
- const ent = [];
- let depth = 0;
- const limit = Math.min(lv.length, cv.length);
- while (depth < limit && lv[depth] === cv[depth]) {
- ent.push(-1);
- spans[li[depth]][depth] += 1;
- depth += 1;
- }
- while (depth < cv.length) {
- li[depth] = i;
- ent.push(1);
- depth += 1;
- }
- spans.push(ent);
- lv = cv;
- }
- return spans;
- }
+ }, [
+ cols,
+ rows,
+ tableOptions,
+ namesMappingProp,
+ subtotalOptions,
+ props,
+ allowRenderHtml,
+ clickHandler,
+ ]);
+
+ const visibleKeys = useCallback(
+ (
+ keys: string[][],
+ collapsed: Record<string, boolean>,
+ numAttrs: number,
+ subtotalDisplay: SubtotalDisplay,
+ ) =>
+ keys.filter(
+ (key: string[]) =>
+ // Is the key hidden by one of its parents?
+ !key.some(
+ (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))],
+ ) &&
+ // Leaf key.
+ (key.length === numAttrs ||
+ // Children hidden. Must show total.
+ flatKey(key) in collapsed ||
+ // Don't hide totals.
+ !subtotalDisplay.hideOnExpand),
+ ),
+ [],
+ );
- getAggregatedData(
- pivotData: InstanceType<typeof PivotData>,
- visibleColName: string[],
- rowPartialOnTop: boolean | undefined,
- ) {
- // Transforms flat row keys into a hierarchical group structure where each level
- // represents a grouping dimension. For each row key path, it calculates the
- // aggregated value for the specified column and builds a nested object that
- // preserves the hierarchy while storing aggregation values at each level.
- const groups: Record<string, HierarchicalNode> = {};
- const rows = pivotData.rowKeys;
- rows.forEach(rowKey => {
- const aggValue =
- pivotData.getAggregator(rowKey, visibleColName).value() ?? 0;
-
- if (rowPartialOnTop) {
- const parent = rowKey
- .slice(0, -1)
- .reduce(
- (acc: Record<string, HierarchicalNode>, key: string) =>
- (acc[key] ??= {}) as Record<string, HierarchicalNode>,
- groups,
- );
- parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
- } else {
- rowKey.reduce((acc: Record<string, HierarchicalNode>, key: string) => {
- acc[key] = acc[key] || { currentVal: 0 };
- (acc[key] as HierarchicalNode).currentVal = aggValue as number;
- return acc[key] as Record<string, HierarchicalNode>;
- }, groups);
- }
- });
- return groups;
- }
+ const isDashboardEditMode = useCallback(
+ () => document.contains(document.querySelector('.dashboard--editing')),
+ [],
+ );
- sortAndCacheData(
- groups: Record<string, HierarchicalNode>,
- sortOrder: string,
- rowEnabled: boolean | undefined,
- rowPartialOnTop: boolean | undefined,
- maxRowIndex: number,
- ) {
- // Processes hierarchical data by first sorting it according to the specified order
- // and then converting the sorted structure into a flat array format. This function
- // serves as an intermediate step between hierarchical data representation and
- // flat array representation needed for rendering.
- const sortedGroups = sortHierarchicalObject(
- groups,
- sortOrder,
- rowPartialOnTop,
- );
- return convertToArray(
- sortedGroups,
- rowEnabled,
- rowPartialOnTop,
- maxRowIndex,
- );
- }
+ // Compute base pivot settings, memoized based on relevant props
+ const basePivotSettings = useMemo(() => {
+ // Clear sort cache when props change
+ sortCacheRef.current.clear();
+ return getBasePivotSettings();
+ }, [getBasePivotSettings]);
+
+ // Reset sort state when props change
+ useEffect(() => {
+ setSortingOrder([]);
+ setActiveSortColumn(null);
+ setSortedRowKeys(null);
+ }, [props]);
+
+ // Use sorted row keys if available, otherwise use base row keys
+ const effectiveRowKeys = sortedRowKeys ?? basePivotSettings.rowKeys;
+
+ const {
+ colAttrs,
+ rowAttrs,
+ colKeys,
+ colTotals,
+ rowSubtotalDisplay,
+ colSubtotalDisplay,
+ } = basePivotSettings;
+
+ const rowKeys = effectiveRowKeys;
+
+ // Need to account for exclusions to compute the effective row
+ // and column keys.
+ const visibleRowKeys = visibleKeys(
+ rowKeys,
+ collapsedRows,
+ rowAttrs.length,
+ rowSubtotalDisplay,
+ );
+ const visibleColKeys = visibleKeys(
+ colKeys,
+ collapsedCols,
+ colAttrs.length,
+ colSubtotalDisplay,
+ );
- sortData(
- columnIndex: number,
- visibleColKeys: string[][],
- pivotData: InstanceType<typeof PivotData>,
- maxRowIndex: number,
- ) {
- // Handles column sorting with direction toggling (asc/desc) and implements
- // caching mechanism to avoid redundant sorting operations. When sorting the same
- // column multiple times, it cycles through sorting directions. Uses composite
- // cache keys based on sorting parameters for optimal performance.
- this.setState(state => {
- const { sortingOrder, activeSortColumn } = state;
-
- const newSortingOrder = [];
+ const pivotSettings: PivotSettings = {
+ visibleRowKeys,
+ maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)),
+ visibleColKeys,
+ maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
+ rowAttrSpans: calcAttrSpans(visibleRowKeys, rowAttrs.length),
+ colAttrSpans: calcAttrSpans(visibleColKeys, colAttrs.length),
+ allowRenderHtml,
+ ...basePivotSettings,
+ };
+
+ const sortData = useCallback(
+ (
+ columnIndex: number,
+ visColKeys: string[][],
+ pivotData: InstanceType<typeof PivotData>,
+ maxRowIndex: number,
+ ) => {
+ // Handles column sorting with direction toggling (asc/desc) and implements
+ // caching mechanism to avoid redundant sorting operations. When sorting the same
+ // column multiple times, it cycles through sorting directions. Uses composite
+ // cache keys based on sorting parameters for optimal performance.
+ const newSortingOrder: string[] = [];
let newDirection = 'asc';
if (activeSortColumn === columnIndex) {
@@ -658,702 +780,674 @@ export class TableRenderer extends Component<
};
newSortingOrder[columnIndex] = newDirection;
- const cacheKey = `${columnIndex}-${visibleColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`;
+ const cacheKey = `${columnIndex}-${visColKeys.length}-${rowEnabled}-${rowPartialOnTop}-${newDirection}`;
let newRowKeys;
- if (this.sortCache.has(cacheKey)) {
- const cachedRowKeys = this.sortCache.get(cacheKey);
+ if (sortCacheRef.current.has(cacheKey)) {
+ const cachedRowKeys = sortCacheRef.current.get(cacheKey);
newRowKeys = cachedRowKeys;
} else {
- const groups = this.getAggregatedData(
+ const groups = getAggregatedData(
pivotData,
- visibleColKeys[columnIndex],
+ visColKeys[columnIndex],
rowPartialOnTop,
);
- const sortedRowKeys = this.sortAndCacheData(
+ const computedSortedRowKeys = sortAndCacheData(
groups,
newDirection,
rowEnabled,
rowPartialOnTop,
maxRowIndex,
);
- this.sortCache.set(cacheKey, sortedRowKeys);
- newRowKeys = sortedRowKeys;
+ sortCacheRef.current.set(cacheKey, computedSortedRowKeys);
+ newRowKeys = computedSortedRowKeys;
}
- this.cachedBasePivotSettings = {
- ...this.cachedBasePivotSettings!,
- rowKeys: newRowKeys!,
- };
- return {
- sortingOrder: newSortingOrder,
- activeSortColumn: columnIndex,
- };
- });
- }
+ setSortedRowKeys(newRowKeys!);
+ setSortingOrder(newSortingOrder);
+ setActiveSortColumn(columnIndex);
+ },
+ [activeSortColumn, sortingOrder, getAggregatedData, sortAndCacheData],
+ );
- renderColHeaderRow(
- attrName: string,
- attrIdx: number,
- pivotSettings: PivotSettings,
- ) {
- // Render a single row in the column header at the top of the pivot table.
+ const renderColHeaderRow = useCallback(
+ (attrName: string, attrIdx: number, settings: PivotSettings) => {
+ // Render a single row in the column header at the top of the pivot table.
+
+ const {
+ rowAttrs: settingsRowAttrs,
+ colAttrs: settingsColAttrs,
+ colKeys: settingsColKeys,
+ visibleColKeys: settingsVisibleColKeys,
+ colAttrSpans,
+ rowTotals,
+ arrowExpanded,
+ arrowCollapsed,
+ colSubtotalDisplay: settingsColSubtotalDisplay,
+ maxColVisible,
+ pivotData,
+ namesMapping,
+ allowRenderHtml: settingsAllowRenderHtml,
+ } = settings;
+ const {
+ highlightHeaderCellsOnHover,
+ omittedHighlightHeaderGroups = [],
+ highlightedHeaderCells,
+ dateFormatters,
+ } = tableOptions;
+
+ if (!settingsVisibleColKeys || !colAttrSpans) {
+ return null;
+ }
- const {
- rowAttrs,
- colAttrs,
- colKeys,
- visibleColKeys,
- colAttrSpans,
- rowTotals,
- arrowExpanded,
- arrowCollapsed,
- colSubtotalDisplay,
- maxColVisible,
- pivotData,
- namesMapping,
- allowRenderHtml,
- } = pivotSettings;
- const {
- highlightHeaderCellsOnHover,
- omittedHighlightHeaderGroups = [],
- highlightedHeaderCells,
- dateFormatters,
- } = this.props.tableOptions;
-
- if (!visibleColKeys || !colAttrSpans) {
- return null;
- }
+ const spaceCell =
+ attrIdx === 0 && settingsRowAttrs.length !== 0 ? (
+ <th
+ key="padding"
+ colSpan={settingsRowAttrs.length}
+ rowSpan={settingsColAttrs.length}
+ aria-hidden="true"
+ />
+ ) : null;
+
+ const needToggle =
+ settingsColSubtotalDisplay.enabled === true &&
+ attrIdx !== settingsColAttrs.length - 1;
+ let arrowClickHandle = null;
+ let subArrow = null;
+ if (needToggle) {
+ arrowClickHandle =
+ attrIdx + 1 < maxColVisible!
+ ? collapseAttr(false, attrIdx, settingsColKeys)
+ : expandAttr(false, attrIdx, settingsColKeys);
+ subArrow =
+ attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed;
+ }
+ const attrNameCell = (
+ <th key="label" className="pvtAxisLabel">
+ {displayHeaderCell(
+ needToggle,
+ subArrow,
+ arrowClickHandle,
+ attrName,
+ namesMapping,
+ settingsAllowRenderHtml,
+ )}
+ </th>
+ );
- const spaceCell =
- attrIdx === 0 && rowAttrs.length !== 0 ? (
- <th
- key="padding"
- colSpan={rowAttrs.length}
- rowSpan={colAttrs.length}
- aria-hidden="true"
- />
- ) : null;
-
- const needToggle =
- colSubtotalDisplay.enabled === true && attrIdx !== colAttrs.length - 1;
- let arrowClickHandle = null;
- let subArrow = null;
- if (needToggle) {
- arrowClickHandle =
- attrIdx + 1 < maxColVisible!
- ? this.collapseAttr(false, attrIdx, colKeys)
- : this.expandAttr(false, attrIdx, colKeys);
- subArrow = attrIdx + 1 < maxColVisible! ? arrowExpanded : arrowCollapsed;
- }
- const attrNameCell = (
- <th key="label" className="pvtAxisLabel">
- {displayHeaderCell(
- needToggle,
- subArrow,
- arrowClickHandle,
- attrName,
- namesMapping,
- allowRenderHtml,
- )}
- </th>
- );
-
- const attrValueCells = [];
- const rowIncrSpan = rowAttrs.length !== 0 ? 1 : 0;
- // Iterate through columns. Jump over duplicate values.
- let i = 0;
- while (i < visibleColKeys.length) {
- let handleContextMenu: ((e: MouseEvent) => void) | undefined;
- const colKey = visibleColKeys[i];
- const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1;
- let colLabelClass = 'pvtColLabel';
- if (attrIdx < colKey.length) {
- if (!omittedHighlightHeaderGroups.includes(colAttrs[attrIdx])) {
- if (highlightHeaderCellsOnHover) {
- colLabelClass += ' hoverable';
+ const attrValueCells = [];
+ const rowIncrSpan = settingsRowAttrs.length !== 0 ? 1 : 0;
+ // Iterate through columns. Jump over duplicate values.
+ let i = 0;
+ while (i < settingsVisibleColKeys.length) {
+ let handleContextMenu: ((e: MouseEvent) => void) | undefined;
+ const colKey = settingsVisibleColKeys[i];
+ const colSpan = attrIdx < colKey.length ? colAttrSpans[i][attrIdx] : 1;
+ let colLabelClass = 'pvtColLabel';
+ if (attrIdx < colKey.length) {
+ if (
+ !omittedHighlightHeaderGroups.includes(settingsColAttrs[attrIdx])
+ ) {
+ if (highlightHeaderCellsOnHover) {
+ colLabelClass += ' hoverable';
+ }
+ handleContextMenu = (e: MouseEvent) =>
+ onContextMenu(e, colKey, undefined, {
+ [attrName]: colKey[attrIdx],
+ });
}
- handleContextMenu = (e: MouseEvent) =>
- this.props.onContextMenu(e, colKey, undefined, {
- [attrName]: colKey[attrIdx],
- });
- }
- if (
- highlightedHeaderCells &&
- Array.isArray(highlightedHeaderCells[colAttrs[attrIdx]]) &&
- highlightedHeaderCells[colAttrs[attrIdx]].includes(colKey[attrIdx])
- ) {
- colLabelClass += ' active';
- }
- const maxRowIndex = pivotSettings.maxRowVisible!;
- const mColVisible = pivotSettings.maxColVisible!;
- const visibleSortIcon = mColVisible - 1 === attrIdx;
- const columnName = colKey[mColVisible - 1];
-
- const rowSpan = 1 + (attrIdx === colAttrs.length - 1 ? rowIncrSpan : 0);
- const flatColKey = flatKey(colKey.slice(0, attrIdx + 1));
- const onArrowClick = needToggle ? this.toggleColKey(flatColKey) : null;
- const getSortIcon = (key: number) => {
- const { activeSortColumn, sortingOrder } = this.state;
-
- if (activeSortColumn !== key) {
+ if (
+ highlightedHeaderCells &&
+ Array.isArray(highlightedHeaderCells[settingsColAttrs[attrIdx]]) &&
+ highlightedHeaderCells[settingsColAttrs[attrIdx]].includes(
+ colKey[attrIdx],
+ )
+ ) {
+ colLabelClass += ' active';
+ }
+ const maxRowIndex = settings.maxRowVisible!;
+ const mColVisible = settings.maxColVisible!;
+ const visibleSortIcon = mColVisible - 1 === attrIdx;
+ const columnName = colKey[mColVisible - 1];
+
+ const rowSpan =
+ 1 + (attrIdx === settingsColAttrs.length - 1 ? rowIncrSpan : 0);
+ const flatColKey = flatKey(colKey.slice(0, attrIdx + 1));
+ const onArrowClick = needToggle ? toggleColKey(flatColKey) : null;
+ const getSortIcon = (key: number) => {
+ if (activeSortColumn !== key) {
+ return (
+ <FaSort
+ onClick={() =>
+ sortData(
+ key,
+ settingsVisibleColKeys,
+ pivotData,
+ maxRowIndex,
+ )
+ }
+ />
+ );
+ }
+
+ const SortIcon =
+ sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
return (
- <FaSort
+ <SortIcon
onClick={() =>
- this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
+ sortData(key, settingsVisibleColKeys, pivotData, maxRowIndex)
}
/>
);
- }
-
- const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc;
- return (
- <SortIcon
- onClick={() =>
- this.sortData(key, visibleColKeys, pivotData, maxRowIndex)
- }
- />
+ };
+ const headerCellFormattedValue =
+ dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
+ attrValueCells.push(
+ <th
+ className={colLabelClass}
+ key={`colKey-${flatColKey}`}
+ colSpan={colSpan}
+ rowSpan={rowSpan}
+ role="columnheader button"
+ onClick={clickHeaderHandler(
+ pivotData,
+ colKey,
+ cols,
+ attrIdx,
+ tableOptions.clickColumnHeaderCallback,
+ )}
+ onContextMenu={handleContextMenu}
+ >
+ {displayHeaderCell(
+ needToggle,
+ collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded,
+ onArrowClick,
+ headerCellFormattedValue,
+ namesMapping,
+ settingsAllowRenderHtml,
+ )}
+ <span
+ role="columnheader"
+ tabIndex={0}
+ // Prevents event bubbling to avoid conflict with column header click handlers
+ // Ensures sort operation executes without triggering cross-filtration
+ onClick={e => {
+ e.stopPropagation();
+ }}
+ aria-label={
+ activeSortColumn === i
+ ? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
+ : undefined
+ }
+ >
+ {visibleSortIcon && getSortIcon(i)}
+ </span>
+ </th>,
);
- };
- const headerCellFormattedValue =
- dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
- attrValueCells.push(
+ } else if (attrIdx === colKey.length) {
+ const rowSpan = settingsColAttrs.length - colKey.length + rowIncrSpan;
+ attrValueCells.push(
+ <th
+ className={`${colLabelClass} pvtSubtotalLabel`}
+ key={`colKeyBuffer-${flatKey(colKey)}`}
+ colSpan={colSpan}
+ rowSpan={rowSpan}
+ role="columnheader button"
+ onClick={clickHeaderHandler(
+ pivotData,
+ colKey,
+ cols,
+ attrIdx,
+ tableOptions.clickColumnHeaderCallback,
+ true,
+ )}
+ >
+ {t('Subtotal')}
+ </th>,
+ );
+ }
+ // The next colSpan columns will have the same value anyway...
+ i += colSpan;
+ }
+
+ const totalCell =
+ attrIdx === 0 && rowTotals ? (
<th
- className={colLabelClass}
- key={`colKey-${flatColKey}`}
- colSpan={colSpan}
- rowSpan={rowSpan}
+ key="total"
+ className="pvtTotalLabel"
+ rowSpan={
+ settingsColAttrs.length + Math.min(settingsRowAttrs.length, 1)
+ }
role="columnheader button"
- onClick={this.clickHeaderHandler(
+ onClick={clickHeaderHandler(
pivotData,
- colKey,
- this.props.cols,
+ [],
+ cols,
attrIdx,
- this.props.tableOptions.clickColumnHeaderCallback,
+ tableOptions.clickColumnHeaderCallback,
+ false,
+ true,
)}
- onContextMenu={handleContextMenu}
>
- {displayHeaderCell(
- needToggle,
- this.state.collapsedCols[flatColKey]
- ? arrowCollapsed
- : arrowExpanded,
- onArrowClick,
- headerCellFormattedValue,
- namesMapping,
- allowRenderHtml,
- )}
- <span
- role="columnheader"
- tabIndex={0}
- // Prevents event bubbling to avoid conflict with column header click handlers
- // Ensures sort operation executes without triggering cross-filtration
- onClick={e => {
- e.stopPropagation();
- }}
- aria-label={
- this.state.activeSortColumn === i
- ? `Sorted by ${columnName} ${this.state.sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}`
- : undefined
- }
- >
- {visibleSortIcon && getSortIcon(i)}
- </span>
- </th>,
- );
- } else if (attrIdx === colKey.length) {
- const rowSpan = colAttrs.length - colKey.length + rowIncrSpan;
- attrValueCells.push(
+ {t('Total (%(aggregatorName)s)', {
+ aggregatorName: t(aggregatorName),
+ })}
+ </th>
+ ) : null;
+
+ const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
+ return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
+ },
+ [
+ tableOptions,
+ onContextMenu,
+ collapseAttr,
+ expandAttr,
+ toggleColKey,
+ clickHeaderHandler,
+ cols,
+ aggregatorName,
+ activeSortColumn,
+ sortingOrder,
+ collapsedCols,
+ sortData,
+ ],
+ );
+
+ const renderRowHeaderRow = useCallback(
+ (settings: PivotSettings) => {
+ // Render just the attribute names of the rows (the actual attribute values
+ // will show up in the individual rows).
+
+ const {
+ rowAttrs: settingsRowAttrs,
+ colAttrs: settingsColAttrs,
+ rowKeys: settingsRowKeys,
+ arrowCollapsed,
+ arrowExpanded,
+ rowSubtotalDisplay: settingsRowSubtotalDisplay,
+ maxRowVisible,
+ pivotData,
+ namesMapping,
+ allowRenderHtml: settingsAllowRenderHtml,
+ } = settings;
+ return (
+ <tr key="rowHdr">
+ {settingsRowAttrs.map((r, i) => {
+ const needLabelToggle =
+ settingsRowSubtotalDisplay.enabled === true &&
+ i !== settingsRowAttrs.length - 1;
+ let arrowClickHandle = null;
+ let subArrow = null;
+ if (needLabelToggle) {
+ arrowClickHandle =
+ i + 1 < maxRowVisible!
+ ? collapseAttr(true, i, settingsRowKeys)
+ : expandAttr(true, i, settingsRowKeys);
+ subArrow =
+ i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed;
+ }
+ return (
+ <th className="pvtAxisLabel" key={`rowAttr-${i}`}>
+ {displayHeaderCell(
+ needLabelToggle,
+ subArrow,
+ arrowClickHandle,
+ r,
+ namesMapping,
+ settingsAllowRenderHtml,
+ )}
+ </th>
+ );
+ })}
<th
- className={`${colLabelClass} pvtSubtotalLabel`}
- key={`colKeyBuffer-${flatKey(colKey)}`}
- colSpan={colSpan}
- rowSpan={rowSpan}
+ className="pvtTotalLabel"
+ key="padding"
role="columnheader button"
- onClick={this.clickHeaderHandler(
+ onClick={clickHeaderHandler(
pivotData,
- colKey,
- this.props.cols,
- attrIdx,
- this.props.tableOptions.clickColumnHeaderCallback,
+ [],
+ rows,
+ 0,
+ tableOptions.clickRowHeaderCallback,
+ false,
true,
)}
>
- {t('Subtotal')}
- </th>,
- );
- }
- // The next colSpan columns will have the same value anyway...
- i += colSpan;
- }
-
- const totalCell =
- attrIdx === 0 && rowTotals ? (
- <th
- key="total"
- className="pvtTotalLabel"
- rowSpan={colAttrs.length + Math.min(rowAttrs.length, 1)}
- role="columnheader button"
- onClick={this.clickHeaderHandler(
- pivotData,
- [],
- this.props.cols,
- attrIdx,
- this.props.tableOptions.clickColumnHeaderCallback,
- false,
- true,
- )}
- >
- {t('Total (%(aggregatorName)s)', {
- aggregatorName: t(this.props.aggregatorName),
- })}
- </th>
- ) : null;
-
- const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell];
- return <tr key={`colAttr-${attrIdx}`}>{cells}</tr>;
- }
+ {settingsColAttrs.length === 0
+ ? t('Total (%(aggregatorName)s)', {
+ aggregatorName: t(aggregatorName),
+ })
+ : null}
+ </th>
+ </tr>
+ );
+ },
+ [
+ collapseAttr,
+ expandAttr,
+ clickHeaderHandler,
+ rows,
+ tableOptions.clickRowHeaderCallback,
+ aggregatorName,
+ ],
+ );
- renderRowHeaderRow(pivotSettings: PivotSettings) {
- // Render just the attribute names of the rows (the actual attribute values
- // will show up in the individual rows).
+ const renderTableRow = useCallback(
+ (rowKey: string[], rowIdx: number, settings: PivotSettings) => {
+ // Render a single row in the pivot table.
- const {
- rowAttrs,
- colAttrs,
- rowKeys,
- arrowCollapsed,
- arrowExpanded,
- rowSubtotalDisplay,
- maxRowVisible,
- pivotData,
- namesMapping,
- allowRenderHtml,
- } = pivotSettings;
- return (
- <tr key="rowHdr">
- {rowAttrs.map((r, i) => {
- const needLabelToggle =
- rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1;
- let arrowClickHandle = null;
- let subArrow = null;
- if (needLabelToggle) {
- arrowClickHandle =
- i + 1 < maxRowVisible!
- ? this.collapseAttr(true, i, rowKeys)
- : this.expandAttr(true, i, rowKeys);
- subArrow = i + 1 < maxRowVisible! ? arrowExpanded : arrowCollapsed;
+ const {
+ rowAttrs: settingsRowAttrs,
+ colAttrs: settingsColAttrs,
+ rowAttrSpans,
+ visibleColKeys: settingsVisibleColKeys,
+ pivotData,
+ rowTotals,
+ rowSubtotalDisplay: settingsRowSubtotalDisplay,
+ arrowExpanded,
+ arrowCollapsed,
+ cellCallbacks,
+ rowTotalCallbacks,
+ namesMapping,
+ allowRenderHtml: settingsAllowRenderHtml,
+ } = settings;
+
+ const {
+ highlightHeaderCellsOnHover,
+ omittedHighlightHeaderGroups = [],
+ highlightedHeaderCells,
+ cellColorFormatters,
+ dateFormatters,
+ } = tableOptions;
+ const flatRowKey = flatKey(rowKey);
+
+ const colIncrSpan = settingsColAttrs.length !== 0 ? 1 : 0;
+ const attrValueCells = rowKey.map((r: string, i: number) => {
+ let handleContextMenu: ((e: MouseEvent) => void) | undefined;
+ let valueCellClassName = 'pvtRowLabel';
+ if (!omittedHighlightHeaderGroups.includes(settingsRowAttrs[i])) {
+ if (highlightHeaderCellsOnHover) {
+ valueCellClassName += ' hoverable';
}
+ handleContextMenu = (e: MouseEvent) =>
+ onContextMenu(e, undefined, rowKey, {
+ [settingsRowAttrs[i]]: r,
+ });
+ }
+ if (
+ highlightedHeaderCells &&
+ Array.isArray(highlightedHeaderCells[settingsRowAttrs[i]]) &&
+ highlightedHeaderCells[settingsRowAttrs[i]].includes(r)
+ ) {
+ valueCellClassName += ' active';
+ }
+ const rowSpan = rowAttrSpans![rowIdx][i];
+ if (rowSpan > 0) {
+ const flatRowKeySlice = flatKey(rowKey.slice(0, i + 1));
+ const colSpan =
+ 1 + (i === settingsRowAttrs.length - 1 ? colIncrSpan : 0);
+ const needRowToggle =
+ settingsRowSubtotalDisplay.enabled === true &&
+ i !== settingsRowAttrs.length - 1;
+ const onArrowClick = needRowToggle
+ ? toggleRowKey(flatRowKeySlice)
+ : null;
+
+ const headerCellFormattedValue =
+ dateFormatters?.[settingsRowAttrs[i]]?.(r) ?? r;
return (
- <th className="pvtAxisLabel" key={`rowAttr-${i}`}>
+ <th
+ key={`rowKeyLabel-${i}`}
+ className={valueCellClassName}
+ rowSpan={rowSpan}
+ colSpan={colSpan}
+ role="columnheader button"
+ onClick={clickHeaderHandler(
+ pivotData,
+ rowKey,
+ rows,
+ i,
+ tableOptions.clickRowHeaderCallback,
+ )}
+ onContextMenu={handleContextMenu}
+ >
{displayHeaderCell(
- needLabelToggle,
- subArrow,
- arrowClickHandle,
- r,
+ needRowToggle,
+ collapsedRows[flatRowKeySlice] ? arrowCollapsed : arrowExpanded,
+ onArrowClick,
+ headerCellFormattedValue,
namesMapping,
- allowRenderHtml,
+ settingsAllowRenderHtml,
)}
</th>
);
- })}
- <th
- className="pvtTotalLabel"
- key="padding"
- role="columnheader button"
- onClick={this.clickHeaderHandler(
- pivotData,
- [],
- this.props.rows,
- 0,
- this.props.tableOptions.clickRowHeaderCallback,
- false,
- true,
- )}
- >
- {colAttrs.length === 0
- ? t('Total (%(aggregatorName)s)', {
- aggregatorName: t(this.props.aggregatorName),
- })
- : null}
- </th>
- </tr>
- );
- }
-
- renderTableRow(
- rowKey: string[],
- rowIdx: number,
- pivotSettings: PivotSettings,
- ) {
- // Render a single row in the pivot table.
-
- const {
- rowAttrs,
- colAttrs,
- rowAttrSpans,
- visibleColKeys,
- pivotData,
- rowTotals,
- rowSubtotalDisplay,
- arrowExpanded,
- arrowCollapsed,
- cellCallbacks,
- rowTotalCallbacks,
- namesMapping,
- allowRenderHtml,
- } = pivotSettings;
-
- const {
- highlightHeaderCellsOnHover,
- omittedHighlightHeaderGroups = [],
- highlightedHeaderCells,
- cellColorFormatters,
- dateFormatters,
- } = this.props.tableOptions;
- const flatRowKey = flatKey(rowKey);
-
- const colIncrSpan = colAttrs.length !== 0 ? 1 : 0;
- const attrValueCells = rowKey.map((r: string, i: number) => {
- let handleContextMenu: ((e: MouseEvent) => void) | undefined;
- let valueCellClassName = 'pvtRowLabel';
- if (!omittedHighlightHeaderGroups.includes(rowAttrs[i])) {
- if (highlightHeaderCellsOnHover) {
- valueCellClassName += ' hoverable';
}
- handleContextMenu = (e: MouseEvent) =>
- this.props.onContextMenu(e, undefined, rowKey, {
- [rowAttrs[i]]: r,
- });
- }
- if (
- highlightedHeaderCells &&
- Array.isArray(highlightedHeaderCells[rowAttrs[i]]) &&
- highlightedHeaderCells[rowAttrs[i]].includes(r)
- ) {
- valueCellClassName += ' active';
- }
- const rowSpan = rowAttrSpans![rowIdx][i];
- if (rowSpan > 0) {
- const flatRowKey = flatKey(rowKey.slice(0, i + 1));
- const colSpan = 1 + (i === rowAttrs.length - 1 ? colIncrSpan : 0);
- const needRowToggle =
- rowSubtotalDisplay.enabled === true && i !== rowAttrs.length - 1;
- const onArrowClick = needRowToggle
- ? this.toggleRowKey(flatRowKey)
- : null;
-
- const headerCellFormattedValue =
- dateFormatters?.[rowAttrs[i]]?.(r) ?? r;
- return (
+ return null;
+ });
+
+ const attrValuePaddingCell =
+ rowKey.length < settingsRowAttrs.length ? (
<th
- key={`rowKeyLabel-${i}`}
- className={valueCellClassName}
- rowSpan={rowSpan}
- colSpan={colSpan}
+ className="pvtRowLabel pvtSubtotalLabel"
+ key="rowKeyBuffer"
+ colSpan={settingsRowAttrs.length - rowKey.length + colIncrSpan}
+ rowSpan={1}
role="columnheader button"
- onClick={this.clickHeaderHandler(
+ onClick={clickHeaderHandler(
pivotData,
rowKey,
- this.props.rows,
- i,
- this.props.tableOptions.clickRowHeaderCallback,
+ rows,
+ rowKey.length,
+ tableOptions.clickRowHeaderCallback,
+ true,
)}
- onContextMenu={handleContextMenu}
>
- {displayHeaderCell(
- needRowToggle,
- this.state.collapsedRows[flatRowKey]
- ? arrowCollapsed
- : arrowExpanded,
- onArrowClick,
- headerCellFormattedValue,
- namesMapping,
- allowRenderHtml,
- )}
+ {t('Subtotal')}
</th>
- );
- }
- return null;
- });
-
- const attrValuePaddingCell =
- rowKey.length < rowAttrs.length ? (
- <th
- className="pvtRowLabel pvtSubtotalLabel"
- key="rowKeyBuffer"
- colSpan={rowAttrs.length - rowKey.length + colIncrSpan}
- rowSpan={1}
- role="columnheader button"
- onClick={this.clickHeaderHandler(
- pivotData,
- rowKey,
- this.props.rows,
- rowKey.length,
- this.props.tableOptions.clickRowHeaderCallback,
- true,
- )}
- >
- {t('Subtotal')}
- </th>
- ) : null;
+ ) : null;
- if (!visibleColKeys) {
- return null;
- }
-
- const rowClickHandlers = cellCallbacks[flatRowKey] || {};
- const valueCells = visibleColKeys.map((colKey: string[]) => {
- const flatColKey = flatKey(colKey);
- const agg = pivotData.getAggregator(rowKey, colKey);
- const aggValue = agg.value();
-
- const keys = [...rowKey, ...colKey];
- let backgroundColor: string | undefined;
- if (cellColorFormatters) {
- Object.values(cellColorFormatters).forEach(cellColorFormatter => {
- if (Array.isArray(cellColorFormatter)) {
- keys.forEach(key => {
- if (backgroundColor) {
- return;
- }
- cellColorFormatter
- .filter(formatter => formatter.column === key)
- .forEach(formatter => {
- const formatterResult = formatter.getColorFromValue(aggValue);
- if (formatterResult) {
- backgroundColor = formatterResult;
- }
- });
- });
- }
- });
+ if (!settingsVisibleColKeys) {
+ return null;
}
- const style = agg.isSubtotal
- ? { fontWeight: 'bold' }
- : { backgroundColor };
-
- return (
- <td
- role="gridcell"
- className="pvtVal"
- key={`pvtVal-${flatColKey}`}
- onClick={rowClickHandlers[flatColKey]}
- onContextMenu={e => this.props.onContextMenu(e, colKey, rowKey)}
- style={style}
- >
- {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
- </td>
- );
- });
-
- let totalCell = null;
- if (rowTotals) {
- const agg = pivotData.getAggregator(rowKey, []);
- const aggValue = agg.value();
- totalCell = (
- <td
- role="gridcell"
- key="total"
- className="pvtTotal"
- onClick={rowTotalCallbacks[flatRowKey]}
- onContextMenu={e => this.props.onContextMenu(e, undefined, rowKey)}
- >
- {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
- </td>
- );
- }
+ const rowClickHandlers = cellCallbacks[flatRowKey] || {};
+ const valueCells = settingsVisibleColKeys.map((colKey: string[]) => {
+ const flatColKey = flatKey(colKey);
+ const agg = pivotData.getAggregator(rowKey, colKey);
+ const aggValue = agg.value();
+
+ const keys = [...rowKey, ...colKey];
+ let backgroundColor: string | undefined;
+ if (cellColorFormatters) {
+ Object.values(cellColorFormatters).forEach(cellColorFormatter => {
+ if (Array.isArray(cellColorFormatter)) {
+ keys.forEach(key => {
+ if (backgroundColor) {
+ return;
+ }
+ cellColorFormatter
+ .filter(formatter => formatter.column === key)
+ .forEach(formatter => {
+ const formatterResult =
+ formatter.getColorFromValue(aggValue);
+ if (formatterResult) {
+ backgroundColor = formatterResult;
+ }
+ });
+ });
+ }
+ });
+ }
- const rowCells = [
- ...attrValueCells,
- attrValuePaddingCell,
- ...valueCells,
- totalCell,
- ];
+ const style = agg.isSubtotal
+ ? { fontWeight: 'bold' }
+ : { backgroundColor };
- return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
- }
-
- renderTotalsRow(pivotSettings: PivotSettings) {
- // Render the final totals rows that has the totals for all the columns.
+ return (
+ <td
+ role="gridcell"
+ className="pvtVal"
+ key={`pvtVal-${flatColKey}`}
+ onClick={rowClickHandlers[flatColKey]}
+ onContextMenu={e => onContextMenu(e, colKey, rowKey)}
+ style={style}
+ >
+ {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
+ </td>
+ );
+ });
- const {
- rowAttrs,
- colAttrs,
- visibleColKeys,
- rowTotals,
- pivotData,
- colTotalCallbacks,
- grandTotalCallback,
- } = pivotSettings;
+ let totalCell = null;
+ if (rowTotals) {
+ const agg = pivotData.getAggregator(rowKey, []);
+ const aggValue = agg.value();
+ totalCell = (
+ <td
+ role="gridcell"
+ key="total"
+ className="pvtTotal"
+ onClick={rowTotalCallbacks[flatRowKey]}
+ onContextMenu={e => onContextMenu(e, undefined, rowKey)}
+ >
+ {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)}
+ </td>
+ );
+ }
- if (!visibleColKeys) {
- return null;
- }
+ const rowCells = [
+ ...attrValueCells,
+ attrValuePaddingCell,
+ ...valueCells,
+ totalCell,
+ ];
+
+ return <tr key={`keyRow-${flatRowKey}`}>{rowCells}</tr>;
+ },
+ [
+ tableOptions,
+ onContextMenu,
+ toggleRowKey,
+ clickHeaderHandler,
+ rows,
+ collapsedRows,
+ ],
+ );
- const totalLabelCell = (
- <th
- key="label"
- className="pvtTotalLabel pvtRowTotalLabel"
- colSpan={rowAttrs.length + Math.min(colAttrs.length, 1)}
- role="columnheader button"
- onClick={this.clickHeaderHandler(
- pivotData,
- [],
- this.props.rows,
- 0,
- this.props.tableOptions.clickRowHeaderCallback,
- false,
- true,
- )}
- >
- {t('Total (%(aggregatorName)s)', {
- aggregatorName: t(this.props.aggregatorName),
- })}
- </th>
- );
+ const renderTotalsRow = useCallback(
+ (settings: PivotSettings) => {
+ // Render the final totals rows that has the totals for all the columns.
- const totalValueCells = visibleColKeys.map((colKey: string[]) => {
- const flatColKey = flatKey(colKey);
- const agg = pivotData.getAggregator([], colKey);
- const aggValue = agg.value();
+ const {
+ rowAttrs: settingsRowAttrs,
+ colAttrs: settingsColAttrs,
+ visibleColKeys: settingsVisibleColKeys,
+ rowTotals,
+ pivotData,
+ colTotalCallbacks,
+ grandTotalCallback,
+ } = settings;
- return (
- <td
- role="gridcell"
- className="pvtTotal pvtRowTotal"
- key={`total-${flatColKey}`}
- onClick={colTotalCallbacks[flatColKey]}
- onContextMenu={e => this.props.onContextMenu(e, colKey, undefined)}
- style={{ padding: '5px' }}
- >
- {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
- </td>
- );
- });
+ if (!settingsVisibleColKeys) {
+ return null;
+ }
- let grandTotalCell = null;
- if (rowTotals) {
- const agg = pivotData.getAggregator([], []);
- const aggValue = agg.value();
- grandTotalCell = (
- <td
- role="gridcell"
- key="total"
- className="pvtGrandTotal pvtRowTotal"
- onClick={grandTotalCallback || undefined}
- onContextMenu={e => this.props.onContextMenu(e, undefined, undefined)}
+ const totalLabelCell = (
+ <th
+ key="label"
+ className="pvtTotalLabel pvtRowTotalLabel"
+ colSpan={
+ settingsRowAttrs.length + Math.min(settingsColAttrs.length, 1)
+ }
+ role="columnheader button"
+ onClick={clickHeaderHandler(
+ pivotData,
+ [],
+ rows,
+ 0,
+ tableOptions.clickRowHeaderCallback,
+ false,
+ true,
+ )}
>
- {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)}
- </td>
+ {t('Total (%(aggregatorName)s)', {
+ aggregatorName: t(aggregatorName),
+ })}
+ </th>
);
- }
- const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
+ const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => {
+ const flatColKey = flatKey(colKey);
+ const agg = pivotData.getAggregator([], colKey);
+ const aggValue = agg.value();
- return (
- <tr key="total" className="pvtRowTotals">
- {totalCells}
- </tr>
- );
- }
-
- visibleKeys(
- keys: string[][],
- collapsed: Record<string, boolean>,
- numAttrs: number,
- subtotalDisplay: SubtotalDisplay,
- ) {
- return keys.filter(
- (key: string[]) =>
- // Is the key hidden by one of its parents?
- !key.some(
- (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))],
- ) &&
- // Leaf key.
- (key.length === numAttrs ||
- // Children hidden. Must show total.
- flatKey(key) in collapsed ||
- // Don't hide totals.
- !subtotalDisplay.hideOnExpand),
- );
- }
-
- isDashboardEditMode() {
- return document.contains(document.querySelector('.dashboard--editing'));
- }
+ return (
+ <td
+ role="gridcell"
+ className="pvtTotal pvtRowTotal"
+ key={`total-${flatColKey}`}
+ onClick={colTotalCallbacks[flatColKey]}
+ onContextMenu={e => onContextMenu(e, colKey, undefined)}
+ style={{ padding: '5px' }}
+ >
+ {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
+ </td>
+ );
+ });
- componentWillUnmount() {
- this.sortCache.clear();
- }
+ let grandTotalCell = null;
+ if (rowTotals) {
+ const agg = pivotData.getAggregator([], []);
+ const aggValue = agg.value();
+ grandTotalCell = (
+ <td
+ role="gridcell"
+ key="total"
+ className="pvtGrandTotal pvtRowTotal"
+ onClick={grandTotalCallback || undefined}
+ onContextMenu={e => onContextMenu(e, undefined, undefined)}
+ >
+ {displayCell(agg.format(aggValue, agg), allowRenderHtml)}
+ </td>
+ );
+ }
- render() {
- if (this.cachedProps !== this.props) {
- this.sortCache.clear();
- // Reset sort state without using setState to avoid re-render during render.
- // This is safe because the state is being synchronized with new props.
- (this.state as TableRendererState).sortingOrder = [];
- (this.state as TableRendererState).activeSortColumn = null;
- this.cachedProps = this.props;
- this.cachedBasePivotSettings = this.getBasePivotSettings();
- }
- const basePivotSettings = this.cachedBasePivotSettings!;
- const {
- colAttrs,
- rowAttrs,
- rowKeys,
- colKeys,
- colTotals,
- rowSubtotalDisplay,
- colSubtotalDisplay,
- allowRenderHtml,
- } = basePivotSettings;
+ const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell];
- // Need to account for exclusions to compute the effective row
- // and column keys.
- const visibleRowKeys = this.visibleKeys(
- rowKeys,
- this.state.collapsedRows,
- rowAttrs.length,
- rowSubtotalDisplay,
- );
- const visibleColKeys = this.visibleKeys(
- colKeys,
- this.state.collapsedCols,
- colAttrs.length,
- colSubtotalDisplay,
- );
-
- const pivotSettings: PivotSettings = {
- visibleRowKeys,
- maxRowVisible: Math.max(...visibleRowKeys.map((k: string[]) => k.length)),
- visibleColKeys,
- maxColVisible: Math.max(...visibleColKeys.map((k: string[]) => k.length)),
- rowAttrSpans: this.calcAttrSpans(visibleRowKeys, rowAttrs.length),
- colAttrSpans: this.calcAttrSpans(visibleColKeys, colAttrs.length),
+ return (
+ <tr key="total" className="pvtRowTotals">
+ {totalCells}
+ </tr>
+ );
+ },
+ [
+ clickHeaderHandler,
+ rows,
+ tableOptions.clickRowHeaderCallback,
+ aggregatorName,
+ onContextMenu,
allowRenderHtml,
- ...basePivotSettings,
- };
+ ],
+ );
- return (
- <Styles isDashboardEditMode={this.isDashboardEditMode()}>
- <table className="pvtTable" role="grid">
- <thead>
- {colAttrs.map((c: string, j: number) =>
- this.renderColHeaderRow(c, j, pivotSettings),
- )}
- {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)}
- </thead>
- <tbody>
- {visibleRowKeys.map((r: string[], i: number) =>
- this.renderTableRow(r, i, pivotSettings),
- )}
- {colTotals && this.renderTotalsRow(pivotSettings)}
- </tbody>
- </table>
- </Styles>
- );
- }
+ return (
+ <Styles isDashboardEditMode={isDashboardEditMode()}>
+ <table className="pvtTable" role="grid">
+ <thead>
+ {colAttrs.map((c: string, j: number) =>
+ renderColHeaderRow(c, j, pivotSettings),
+ )}
+ {rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)}
+ </thead>
+ <tbody>
+ {visibleRowKeys.map((r: string[], i: number) =>
+ renderTableRow(r, i, pivotSettings),
+ )}
+ {colTotals && renderTotalsRow(pivotSettings)}
+ </tbody>
+ </table>
+ </Styles>
+ );
}
TableRenderer.propTypes = {
diff --git a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
index 6d1abedb774..e3398b84bf0 100644
--- a/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
+++ b/superset-frontend/plugins/plugin-chart-word-cloud/src/chart/WordCloud.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import cloudLayout from 'd3-cloud';
import { scaleLinear } from 'd3-scale';
import { seed, CategoricalColorNamespace } from '@superset-ui/core';
@@ -81,18 +81,7 @@ export interface WordCloudProps extends WordCloudVisualProps {
colorScheme: string;
}
-export interface WordCloudState {
- words: Word[];
- scaleFactor: number;
-}
-
-const defaultProps: Required<WordCloudVisualProps> = {
- encoding: {},
- rotation: 'flat',
-};
-
-type FullWordCloudProps = WordCloudProps &
- typeof defaultProps & { theme: SupersetTheme };
+type FullWordCloudProps = WordCloudProps & { theme: SupersetTheme };
const SCALE_FACTOR_STEP = 0.5;
const MAX_SCALE_FACTOR = 3;
@@ -196,61 +185,80 @@ class SimpleEncoder {
}
}
-class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
- static defaultProps = defaultProps;
-
- isComponentMounted = false;
-
- createEncoder = (encoding?: Partial<WordCloudEncoding>): SimpleEncoder =>
- new SimpleEncoder(encoding ?? {}, {
- color: this.props.theme.colorTextLabel,
- fontFamily: this.props.theme.fontFamily,
- fontSize: 20,
- fontWeight: 'bold',
- text: '',
- });
-
- constructor(props: FullWordCloudProps) {
- super(props);
- this.state = {
- words: [],
- scaleFactor: 1,
- };
- this.setWords = this.setWords.bind(this);
- }
-
- componentDidMount() {
- this.isComponentMounted = true;
- this.update();
- }
-
- componentDidUpdate(prevProps: WordCloudProps) {
- const { data, encoding, width, height, rotation } = this.props;
- if (
- !isEqual(prevProps.data, data) ||
- !isEqual(prevProps.encoding, encoding) ||
- prevProps.width !== width ||
- prevProps.height !== height ||
- prevProps.rotation !== rotation
- ) {
- this.update();
- }
- }
-
- componentWillUnmount() {
- this.isComponentMounted = false;
- }
-
- setWords(words: Word[]) {
- if (this.isComponentMounted) {
- this.setState({ words });
+function WordCloud({
+ data,
+ encoding = {},
+ width,
+ height,
+ rotation = 'flat',
+ sliceId,
+ colorScheme,
+ theme,
+}: FullWordCloudProps) {
+ const [words, setWords] = useState<Word[]>([]);
+ const [scaleFactor] = useState(1);
+ const isMountedRef = useRef(true);
+
+ // Store previous props for comparison
+ const prevPropsRef = useRef<{
+ data: PlainObject[];
+ encoding: Partial<WordCloudEncoding>;
+ width: number;
+ height: number;
+ rotation: RotationType;
+ } | null>(null);
+
+ const createEncoder = useCallback(
+ (enc?: Partial<WordCloudEncoding>): SimpleEncoder =>
+ new SimpleEncoder(enc ?? {}, {
+ color: theme.colorTextLabel,
+ fontFamily: theme.fontFamily,
+ fontSize: 20,
+ fontWeight: 'bold',
+ text: '',
+ }),
+ [theme.colorTextLabel, theme.fontFamily],
+ );
+
+ const setWordsIfMounted = useCallback((newWords: Word[]) => {
+ if (isMountedRef.current) {
+ setWords(newWords);
}
- }
-
- update() {
- const { data, encoding } = this.props;
+ }, []);
+
+ const generateCloud = useCallback(
+ (
+ encoder: SimpleEncoder,
+ currentScaleFactor: number,
+ isValid: (word: Word[]) => boolean,
+ ) => {
+ cloudLayout()
+ .size([width * currentScaleFactor, height * currentScaleFactor])
+ .words(data.map((d: Word) => ({ ...d })))
+ .padding(5)
+ .rotate(ROTATION[rotation] || ROTATION.flat)
+ .text((d: PlainObject) => encoder.getText(d))
+ .font((d: PlainObject) => encoder.getFontFamily(d))
+ .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
+ .fontSize((d: PlainObject) => encoder.getFontSize(d))
+ .on('end', (cloudWords: Word[]) => {
+ if (isValid(cloudWords) || currentScaleFactor > MAX_SCALE_FACTOR) {
+ setWordsIfMounted(cloudWords);
+ } else {
+ generateCloud(
+ encoder,
+ currentScaleFactor + SCALE_FACTOR_STEP,
+ isValid,
+ );
+ }
+ })
+ .start();
+ },
+ [data, width, height, rotation, setWordsIfMounted],
+ );
- const encoder = this.createEncoder(encoding);
+ const update = useCallback(() => {
+ const encoder = createEncoder(encoding);
encoder.setDomainFromDataset(data);
const sortedData = [...data].sort(
@@ -262,73 +270,71 @@ class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
);
const topResults = sortedData.slice(0, topResultsCount);
- this.generateCloud(encoder, 1, (words: Word[]) =>
+ generateCloud(encoder, 1, (cloudWords: Word[]) =>
topResults.every((d: PlainObject) =>
- words.find(({ text }) => encoder.getText(d) === text),
+ cloudWords.find(({ text }) => encoder.getText(d) === text),
),
);
- }
+ }, [data, encoding, createEncoder, generateCloud]);
- generateCloud(
- encoder: SimpleEncoder,
- scaleFactor: number,
- isValid: (word: Word[]) => boolean,
- ) {
- const { data, width, height, rotation } = this.props;
-
- cloudLayout()
- .size([width * scaleFactor, height * scaleFactor])
- .words(data.map((d: Word) => ({ ...d })))
- .padding(5)
- .rotate(ROTATION[rotation] || ROTATION.flat)
- .text((d: PlainObject) => encoder.getText(d))
- .font((d: PlainObject) => encoder.getFontFamily(d))
- .fontWeight((d: PlainObject) => encoder.getFontWeight(d))
- .fontSize((d: PlainObject) => encoder.getFontSize(d))
- .on('end', (words: Word[]) => {
- if (isValid(words) || scaleFactor > MAX_SCALE_FACTOR) {
- this.setWords(words);
- } else {
- this.generateCloud(encoder, scaleFactor + SCALE_FACTOR_STEP, isValid);
- }
- })
- .start();
- }
+ // Component mount/unmount tracking
+ useEffect(() => {
+ isMountedRef.current = true;
+ return () => {
+ isMountedRef.current = false;
+ };
+ }, []);
- render() {
- const { scaleFactor, words } = this.state;
- const { width, height, encoding, sliceId, colorScheme } = this.props;
-
- const encoder = this.createEncoder(encoding);
-
- const colorFn = CategoricalColorNamespace.getScale(colorScheme);
- const viewBoxWidth = width * scaleFactor;
- const viewBoxHeight = height * scaleFactor;
-
- return (
- <svg
- width={width}
- height={height}
- viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
- >
- <g>
- {words.map(w => (
- <text
- key={w.text}
- fontSize={`${w.size}px`}
- fontWeight={w.weight}
- fontFamily={w.font}
- fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
- textAnchor="middle"
- transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
- >
- {w.text}
- </text>
- ))}
- </g>
- </svg>
- );
- }
+ // Initial update on mount and when dependencies change
+ useEffect(() => {
+ const prevProps = prevPropsRef.current;
+ const shouldUpdate =
+ !prevProps ||
+ !isEqual(prevProps.data, data) ||
+ !isEqual(prevProps.encoding, encoding) ||
+ prevProps.width !== width ||
+ prevProps.height !== height ||
+ prevProps.rotation !== rotation;
+
+ if (shouldUpdate) {
+ update();
+ }
+
+ prevPropsRef.current = { data, encoding, width, height, rotation };
+ }, [data, encoding, width, height, rotation, update]);
+
+ const encoder = useMemo(
+ () => createEncoder(encoding),
+ [createEncoder, encoding],
+ );
+
+ const colorFn = CategoricalColorNamespace.getScale(colorScheme);
+ const viewBoxWidth = width * scaleFactor;
+ const viewBoxHeight = height * scaleFactor;
+
+ return (
+ <svg
+ width={width}
+ height={height}
+ viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
+ >
+ <g>
+ {words.map(w => (
+ <text
+ key={w.text}
+ fontSize={`${w.size}px`}
+ fontWeight={w.weight}
+ fontFamily={w.font}
+ fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
+ textAnchor="middle"
+ transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
+ >
+ {w.text}
+ </text>
+ ))}
+ </g>
+ </svg>
+ );
}
export default withTheme(WordCloud);
diff --git a/superset-frontend/src/SqlLab/components/App/App.test.tsx b/superset-frontend/src/SqlLab/components/App/App.test.tsx
index 4d1f922add4..67675c59128 100644
--- a/superset-frontend/src/SqlLab/components/App/App.test.tsx
+++ b/superset-frontend/src/SqlLab/components/App/App.test.tsx
@@ -92,7 +92,7 @@ describe('SqlLab App', () => {
useRedux: true,
store: storeExceedLocalStorage,
});
- rerender(<App updated />);
+ rerender(<App />);
expect(storeExceedLocalStorage.getActions()).toContainEqual(
expect.objectContaining({
type: LOG_EVENT,
@@ -118,7 +118,7 @@ describe('SqlLab App', () => {
useRedux: true,
store: storeExceedLocalStorage,
});
- rerender(<App updated />);
+ rerender(<App />);
expect(storeExceedLocalStorage.getActions()).toContainEqual(
expect.objectContaining({
type: LOG_EVENT,
diff --git a/superset-frontend/src/SqlLab/components/App/index.tsx b/superset-frontend/src/SqlLab/components/App/index.tsx
index 83200616524..337ab1a870b 100644
--- a/superset-frontend/src/SqlLab/components/App/index.tsx
+++ b/superset-frontend/src/SqlLab/components/App/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import Mousetrap from 'mousetrap';
@@ -103,59 +103,85 @@ const SqlLabStyles = styled.div`
`};
`;
-type PureProps = {
- // add this for testing componentDidUpdate spec
- updated?: boolean;
-};
-
-type AppProps = ReturnType<typeof mergeProps> & PureProps;
-
-interface AppState {
- hash: string;
-}
-
-class App extends PureComponent<AppProps, AppState> {
- hasLoggedLocalStorageUsage: boolean;
-
- private boundOnHashChanged: () => void;
-
- constructor(props: AppProps) {
- super(props);
- this.state = {
- hash: window.location.hash,
- };
-
- this.boundOnHashChanged = this.onHashChanged.bind(this);
+type AppProps = ReturnType<typeof mergeProps>;
+
+function App({
+ actions,
+ localStorageUsageInKilobytes,
+ queries,
+ queriesLastUpdate,
+}: AppProps) {
+ const [hash, setHash] = useState(window.location.hash);
+ const hasLoggedLocalStorageUsageRef = useRef(false);
+
+ const showLocalStorageUsageWarning = useMemo(
+ () =>
+ throttle(
+ (currentUsage: number, queryCount: number) => {
+ actions.addDangerToast(
+ t(
+ "SQL Lab uses your browser's local storage to store queries and results." +
+ '\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
+ '\nTo keep SQL Lab from crashing, please delete some query tabs.' +
+ '\nYou can re-access these queries by using the Save feature before you delete the tab.' +
+ '\nNote that you will need to close other SQL Lab windows before you do this.',
+ {
+ currentUsage: currentUsage.toFixed(2),
+ maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
+ },
+ ),
+ );
+ const eventData = {
+ current_usage: currentUsage,
+ query_count: queryCount,
+ };
+ actions.logEvent(
+ LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
+ eventData,
+ );
+ },
+ LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
+ { trailing: false },
+ ),
+ [actions],
+ );
- this.showLocalStorageUsageWarning = throttle(
- this.showLocalStorageUsageWarning,
- LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS,
- { trailing: false },
- );
- }
+ const onHashChanged = useCallback(() => {
+ setHash(window.location.hash);
+ }, []);
- componentDidMount() {
- window.addEventListener('hashchange', this.boundOnHashChanged);
+ // componentDidMount and componentWillUnmount
+ useEffect(() => {
+ window.addEventListener('hashchange', onHashChanged);
// Horrible hack to disable side swipe navigation when in SQL Lab. Even though the
// docs say setting this style on any div will prevent it, turns out it only works
// when set on the body element.
document.body.style.overscrollBehaviorX = 'none';
- }
- componentDidUpdate() {
- const { localStorageUsageInKilobytes, actions, queries } = this.props;
+ return () => {
+ window.removeEventListener('hashchange', onHashChanged);
+
+ // And we need to reset the overscroll behavior back to the default.
+ document.body.style.overscrollBehaviorX = 'auto';
+
+ Mousetrap.reset();
+ };
+ }, [onHashChanged]);
+
+ // componentDidUpdate - check local storage usage
+ useEffect(() => {
const queryCount = Object.keys(queries || {}).length || 0;
if (
localStorageUsageInKilobytes >=
LOCALSTORAGE_WARNING_THRESHOLD * LOCALSTORAGE_MAX_USAGE_KB
) {
- this.showLocalStorageUsageWarning(
- localStorageUsageInKilobytes,
- queryCount,
- );
+ showLocalStorageUsageWarning(localStorageUsageInKilobytes, queryCount);
}
- if (localStorageUsageInKilobytes > 0 && !this.hasLoggedLocalStorageUsage) {
+ if (
+ localStorageUsageInKilobytes > 0 &&
+ !hasLoggedLocalStorageUsageRef.current
+ ) {
const eventData = {
current_usage: localStorageUsageInKilobytes,
query_count: queryCount,
@@ -164,72 +190,38 @@ class App extends PureComponent<AppProps, AppState> {
LOG_ACTIONS_SQLLAB_MONITOR_LOCAL_STORAGE_USAGE,
eventData,
);
- this.hasLoggedLocalStorageUsage = true;
+ hasLoggedLocalStorageUsageRef.current = true;
}
- }
-
- componentWillUnmount() {
- window.removeEventListener('hashchange', this.boundOnHashChanged);
-
- // And now we need to reset the overscroll behavior back to the default.
- document.body.style.overscrollBehaviorX = 'auto';
-
- Mousetrap.reset();
- }
-
- onHashChanged() {
- this.setState({ hash: window.location.hash });
- }
-
- showLocalStorageUsageWarning(currentUsage: number, queryCount: number) {
- this.props.actions.addDangerToast(
- t(
- "SQL Lab uses your browser's local storage to store queries and results." +
- '\nCurrently, you are using %(currentUsage)s KB out of %(maxStorage)d KB storage space.' +
- '\nTo keep SQL Lab from crashing, please delete some query tabs.' +
- '\nYou can re-access these queries by using the Save feature before you delete the tab.' +
- '\nNote that you will need to close other SQL Lab windows before you do this.',
- {
- currentUsage: currentUsage.toFixed(2),
- maxStorage: LOCALSTORAGE_MAX_USAGE_KB,
- },
- ),
- );
- const eventData = {
- current_usage: currentUsage,
- query_count: queryCount,
- };
- this.props.actions.logEvent(
- LOG_ACTIONS_SQLLAB_WARN_LOCAL_STORAGE_USAGE,
- eventData,
- );
- }
+ }, [
+ localStorageUsageInKilobytes,
+ queries,
+ actions,
+ showLocalStorageUsageWarning,
+ ]);
- render() {
- const { queries, queriesLastUpdate } = this.props;
- if (this.state.hash && this.state.hash === '#search') {
- return (
- <Redirect
- to={{
- pathname: '/sqllab/history/',
- }}
- />
- );
- }
+ if (hash && hash === '#search') {
return (
- <SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
- <QueryAutoRefresh
- queries={queries}
- queriesLastUpdate={queriesLastUpdate}
- />
- <PopEditorTab>
- <AppLayout>
- <TabbedSqlEditors />
- </AppLayout>
- </PopEditorTab>
- </SqlLabStyles>
+ <Redirect
+ to={{
+ pathname: '/sqllab/history/',
+ }}
+ />
);
}
+
+ return (
+ <SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
+ <QueryAutoRefresh
+ queries={queries}
+ queriesLastUpdate={queriesLastUpdate}
+ />
+ <PopEditorTab>
+ <AppLayout>
+ <TabbedSqlEditors />
+ </AppLayout>
+ </PopEditorTab>
+ </SqlLabStyles>
+ );
}
function mapStateToProps(state: SqlLabRootState) {
@@ -250,10 +242,8 @@ const mapDispatchToProps = {
function mergeProps(
stateProps: ReturnType<typeof mapStateToProps>,
dispatchProps: typeof mapDispatchToProps,
- state: PureProps,
) {
return {
- ...state,
...stateProps,
actions: dispatchProps,
};
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
index 83c870e1233..d20af45e5e0 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import { useEffect, useCallback, useMemo, useRef } from 'react';
import { EditableTabs } from '@superset-ui/core/components/Tabs';
import { connect } from 'react-redux';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -32,10 +32,10 @@ import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
const DEFAULT_PROPS = {
- queryEditors: [],
+ queryEditors: [] as QueryEditor[],
offline: false,
- saveQueryWarning: null,
- scheduleQueryWarning: null,
+ saveQueryWarning: null as string | null,
+ scheduleQueryWarning: null as string | null,
};
const StyledEditableTabs = styled(EditableTabs)`
@@ -94,166 +94,192 @@ const userOS = detectOS();
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
-class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
- constructor(props: TabbedSqlEditorsProps) {
- super(props);
- this.removeQueryEditor = this.removeQueryEditor.bind(this);
- this.handleSelect = this.handleSelect.bind(this);
- this.handleEdit = this.handleEdit.bind(this);
- }
+function TabbedSqlEditors({
+ actions,
+ queryEditors = DEFAULT_PROPS.queryEditors,
+ queries,
+ tabHistory,
+ displayLimit,
+ offline = DEFAULT_PROPS.offline,
+ defaultQueryLimit,
+ maxRow,
+ saveQueryWarning = DEFAULT_PROPS.saveQueryWarning,
+ scheduleQueryWarning = DEFAULT_PROPS.scheduleQueryWarning,
+}: TabbedSqlEditorsProps) {
+ const activeQueryEditor = useMemo(() => {
+ if (tabHistory.length === 0) {
+ return queryEditors[0];
+ }
+ const qeid = tabHistory[tabHistory.length - 1];
+ return queryEditors.find(qe => qe.id === qeid) || null;
+ }, [tabHistory, queryEditors]);
+
+ // Track whether the initial mount effect has run
+ const hasRunInitialEffect = useRef(false);
+
+ // Fetch query results on initial mount if needed (equivalent to componentDidMount)
+ useEffect(() => {
+ if (hasRunInitialEffect.current) {
+ return;
+ }
+ hasRunInitialEffect.current = true;
- componentDidMount() {
- const qe = this.activeQueryEditor();
- const latestQuery = this.props.queries[qe?.latestQueryId || ''];
+ const latestQuery = queries[activeQueryEditor?.latestQueryId || ''];
if (
isFeatureEnabled(FeatureFlag.SqllabBackendPersistence) &&
latestQuery?.resultsKey
) {
// when results are not stored in localStorage they need to be
// fetched from the results backend (if configured)
- this.props.actions.fetchQueryResults(
- latestQuery,
- this.props.displayLimit,
- );
+ actions.fetchQueryResults(latestQuery, displayLimit);
}
- }
+ }, [queries, activeQueryEditor, actions, displayLimit]);
- activeQueryEditor() {
- if (this.props.tabHistory.length === 0) {
- return this.props.queryEditors[0];
- }
- const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
- return this.props.queryEditors.find(qe => qe.id === qeid) || null;
- }
+ const newQueryEditor = useCallback(() => {
+ actions.addNewQueryEditor();
+ }, [actions]);
- newQueryEditor() {
- this.props.actions.addNewQueryEditor();
- }
+ const removeQueryEditor = useCallback(
+ (qe: QueryEditor) => {
+ actions.removeQueryEditor(qe);
+ },
+ [actions],
+ );
- handleSelect(key: string) {
- const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
- if (key !== qeid) {
- const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
- if (!queryEditor) {
- return;
+ const handleSelect = useCallback(
+ (key: string) => {
+ const qeid = tabHistory[tabHistory.length - 1];
+ if (key !== qeid) {
+ const queryEditor = queryEditors.find(qe => qe.id === key);
+ if (!queryEditor) {
+ return;
+ }
+ actions.setActiveQueryEditor(queryEditor);
}
- this.props.actions.setActiveQueryEditor(queryEditor);
- }
- }
+ },
+ [tabHistory, queryEditors, actions],
+ );
- handleEdit(key: string, action: string) {
- if (action === 'remove') {
- const qe = this.props.queryEditors.find(qe => qe.id === key);
- if (qe) {
- this.removeQueryEditor(qe);
+ const handleEdit = useCallback(
+ (key: string, action: string) => {
+ if (action === 'remove') {
+ const qe = queryEditors.find(qe => qe.id === key);
+ if (qe) {
+ removeQueryEditor(qe);
+ }
}
- }
- if (action === 'add') {
- Logger.markTimeOrigin();
- this.newQueryEditor();
- }
- }
-
- removeQueryEditor(qe: QueryEditor) {
- this.props.actions.removeQueryEditor(qe);
- }
+ if (action === 'add') {
+ Logger.markTimeOrigin();
+ newQueryEditor();
+ }
+ },
+ [queryEditors, removeQueryEditor, newQueryEditor],
+ );
- onTabClicked = () => {
+ const onTabClicked = useCallback(() => {
Logger.markTimeOrigin();
- const noQueryEditors = this.props.queryEditors?.length === 0;
+ const noQueryEditors = queryEditors?.length === 0;
if (noQueryEditors) {
- this.newQueryEditor();
+ newQueryEditor();
}
- };
+ }, [queryEditors, newQueryEditor]);
- render() {
- const editors = this.props.queryEditors?.map(qe => ({
- key: qe.id,
- label: <SqlEditorTabHeader queryEditor={qe} />,
- children: (
- <SqlEditor
- queryEditor={qe}
- defaultQueryLimit={this.props.defaultQueryLimit}
- maxRow={this.props.maxRow}
- displayLimit={this.props.displayLimit}
- saveQueryWarning={this.props.saveQueryWarning}
- scheduleQueryWarning={this.props.scheduleQueryWarning}
+ const editors = useMemo(
+ () =>
+ queryEditors?.map(qe => ({
+ key: qe.id,
+ label: <SqlEditorTabHeader queryEditor={qe} />,
+ children: (
+ <SqlEditor
+ queryEditor={qe}
+ defaultQueryLimit={defaultQueryLimit}
+ maxRow={maxRow}
+ displayLimit={displayLimit}
+ saveQueryWarning={saveQueryWarning}
+ scheduleQueryWarning={scheduleQueryWarning}
+ />
+ ),
+ })),
+ [
+ queryEditors,
+ defaultQueryLimit,
+ maxRow,
+ displayLimit,
+ saveQueryWarning,
+ scheduleQueryWarning,
+ ],
+ );
+
+ const emptyTab = (
+ <StyledTab>
+ <TabTitle>{t('Add a new tab')}</TabTitle>
+ <Tooltip
+ id="add-tab"
+ placement="bottom"
+ title={
+ userOS === 'Windows'
+ ? t('New tab (Ctrl + q)')
+ : t('New tab (Ctrl + t)')
+ }
+ >
+ <Icons.PlusCircleOutlined
+ iconSize="s"
+ css={css`
+ vertical-align: middle;
+ `}
+ data-test="add-tab-icon"
/>
- ),
- }));
+ </Tooltip>
+ </StyledTab>
+ );
+
+ const emptyTabState = {
+ key: '0',
+ label: emptyTab,
+ children: (
+ <EmptyState
+ image="empty_sql_chart.svg"
+ size="large"
+ description={t('Add a new tab to create SQL Query')}
+ />
+ ),
+ };
+
+ const tabItems = queryEditors?.length > 0 ? editors : [emptyTabState];
- const emptyTab = (
- <StyledTab>
- <TabTitle>{t('Add a new tab')}</TabTitle>
+ return (
+ <StyledEditableTabs
+ activeKey={tabHistory[tabHistory.length - 1]}
+ id="a11y-query-editor-tabs"
+ className="SqlEditorTabs"
+ data-test="sql-editor-tabs"
+ onChange={handleSelect}
+ hideAdd={offline}
+ onTabClick={onTabClicked}
+ onEdit={handleEdit}
+ type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
+ addIcon={
<Tooltip
id="add-tab"
- placement="bottom"
+ placement="left"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
- <Icons.PlusCircleOutlined
- iconSize="s"
+ <Icons.PlusOutlined
+ iconSize="l"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
</Tooltip>
- </StyledTab>
- );
-
- const emptyTabState = {
- key: '0',
- label: emptyTab,
- children: (
- <EmptyState
- image="empty_sql_chart.svg"
- size="large"
- description={t('Add a new tab to create SQL Query')}
- />
- ),
- };
-
- const tabItems =
- this.props.queryEditors?.length > 0 ? editors : [emptyTabState];
-
- return (
- <StyledEditableTabs
- activeKey={this.props.tabHistory[this.props.tabHistory.length - 1]}
- id="a11y-query-editor-tabs"
- className="SqlEditorTabs"
- data-test="sql-editor-tabs"
- onChange={this.handleSelect}
- hideAdd={this.props.offline}
- onTabClick={this.onTabClicked}
- onEdit={this.handleEdit}
- type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
- addIcon={
- <Tooltip
- id="add-tab"
- placement="left"
- title={
- userOS === 'Windows'
- ? t('New tab (Ctrl + q)')
- : t('New tab (Ctrl + t)')
- }
- >
- <Icons.PlusOutlined
- iconSize="l"
- css={css`
- vertical-align: middle;
- `}
- data-test="add-tab-icon"
- />
- </Tooltip>
- }
- items={tabItems}
- />
- );
- }
+ }
+ items={tabItems}
+ />
+ );
}
export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {
diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx
index 9a05bb270fb..bb3d083f1b7 100644
--- a/superset-frontend/src/components/Chart/Chart.tsx
+++ b/superset-frontend/src/components/Chart/Chart.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent } from 'react';
+import { useCallback, useEffect, useRef } from 'react';
import { t, logging } from '@apache-superset/core';
import {
ensureIsArray,
@@ -121,19 +121,6 @@ const NONEXISTENT_DATASET = t(
'The dataset associated with this chart no longer exists',
);
-const defaultProps: Partial<ChartProps> = {
- addFilter: () => BLANK,
- onFilterMenuOpen: () => BLANK,
- onFilterMenuClose: () => BLANK,
- initialValues: BLANK,
- setControlValue: () => BLANK,
- triggerRender: false,
- dashboardId: undefined,
- chartStackTrace: undefined,
- force: false,
- isInView: true,
-};
-
const Styles = styled.div<{ height: number; width?: number }>`
min-height: ${p => p.height}px;
position: relative;
@@ -176,243 +163,310 @@ const MessageSpan = styled.span`
color: ${({ theme }) => theme.colorText};
`;
-class Chart extends PureComponent<ChartProps, {}> {
- static defaultProps = defaultProps;
-
- renderStartTime: any;
-
- constructor(props: ChartProps) {
- super(props);
- this.handleRenderContainerFailure =
- this.handleRenderContainerFailure.bind(this);
- }
-
- componentDidMount() {
- if (this.props.triggerQuery) {
- this.runQuery();
- }
- }
-
- componentDidUpdate() {
- if (this.props.triggerQuery) {
- this.runQuery();
- }
- }
-
- shouldRenderChart() {
- return (
- this.props.isInView ||
+function Chart({
+ addFilter = () => BLANK,
+ onFilterMenuOpen = () => BLANK,
+ onFilterMenuClose = () => BLANK,
+ initialValues = BLANK,
+ setControlValue = () => BLANK,
+ triggerRender = false,
+ dashboardId,
+ chartStackTrace,
+ force = false,
+ isInView = true,
+ ...restProps
+}: ChartProps): JSX.Element {
+ const {
+ actions,
+ chartId,
+ datasource,
+ formData,
+ timeout,
+ ownState,
+ chartAlert,
+ chartStatus,
+ queriesResponse = [],
+ errorMessage,
+ chartIsStale,
+ width,
+ height,
+ datasetsStatus,
+ onQuery,
+ annotationData,
+ labelColors: _labelColors,
+ sharedLabelColors: _sharedLabelColors,
+ vizType,
+ isFiltersInitialized: _isFiltersInitialized,
+ latestQueryFormData,
+ triggerQuery,
+ postTransformProps,
+ emitCrossFilters,
+ onChartStateChange,
+ } = restProps;
+
+ const renderStartTimeRef = useRef<number>(0);
+
+ const shouldRenderChart = useCallback(
+ () =>
+ isInView ||
!isFeatureEnabled(FeatureFlag.DashboardVirtualization) ||
- isCurrentUserBot()
- );
- }
+ isCurrentUserBot(),
+ [isInView],
+ );
- runQuery() {
+ const runQuery = useCallback(() => {
if (
isFeatureEnabled(FeatureFlag.DashboardVirtualizationDeferData) &&
- !this.shouldRenderChart()
+ !shouldRenderChart()
) {
return;
}
// Create chart with POST request
- this.props.actions.postChartFormData(
- this.props.formData,
- Boolean(this.props.force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
- this.props.timeout,
- this.props.chartId,
- this.props.dashboardId,
- this.props.ownState,
- );
- }
-
- handleRenderContainerFailure(
- error: Error,
- info: { componentStack: string } | null,
- ) {
- const { actions, chartId } = this.props;
- logging.warn(error);
- actions.chartRenderingFailed(
- error.toString(),
+ actions.postChartFormData(
+ formData,
+ Boolean(force || getUrlParam(URL_PARAMS.force)), // allow override via url params force=true
+ timeout,
chartId,
- info ? info.componentStack : null,
+ dashboardId,
+ ownState,
);
+ }, [
+ actions,
+ chartId,
+ dashboardId,
+ formData,
+ force,
+ ownState,
+ shouldRenderChart,
+ timeout,
+ ]);
+
+ const handleRenderContainerFailure = useCallback(
+ (error: Error, info: { componentStack: string } | null) => {
+ logging.warn(error);
+ actions.chartRenderingFailed(
+ error.toString(),
+ chartId,
+ info ? info.componentStack : null,
+ );
- actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
- slice_id: chartId,
- has_err: true,
- error_details: error.toString(),
- start_offset: this.renderStartTime,
- ts: new Date().getTime(),
- duration: Logger.getTimestamp() - this.renderStartTime,
- });
- }
+ actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+ slice_id: chartId,
+ has_err: true,
+ error_details: error.toString(),
+ start_offset: renderStartTimeRef.current,
+ ts: new Date().getTime(),
+ duration: Logger.getTimestamp() - renderStartTimeRef.current,
+ });
+ },
+ [actions, chartId],
+ );
- renderErrorMessage(queryResponse: ChartErrorType) {
- const {
- chartId,
+ // componentDidMount and componentDidUpdate combined
+ useEffect(() => {
+ if (triggerQuery) {
+ runQuery();
+ }
+ }, [triggerQuery, runQuery]);
+
+ const renderErrorMessage = useCallback(
+ (queryResponse: ChartErrorType) => {
+ const error = queryResponse?.errors?.[0];
+ const message = chartAlert || queryResponse?.message;
+
+ // if datasource is still loading, don't render JS errors
+ if (
+ chartAlert !== undefined &&
+ chartAlert !== NONEXISTENT_DATASET &&
+ datasource === PLACEHOLDER_DATASOURCE &&
+ datasetsStatus !== ResourceStatus.Error
+ ) {
+ return (
+ <Styles
+ key={chartId}
+ data-ui-anchor="chart"
+ className="chart-container"
+ data-test="chart-container"
+ height={height}
+ >
+ <Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
+ </Styles>
+ );
+ }
+
+ return (
+ <ChartErrorMessage
+ key={chartId}
+ chartId={chartId}
+ error={error}
+ subtitle={message}
+ link={queryResponse ? queryResponse.link : undefined}
+ source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
+ stackTrace={chartStackTrace}
+ />
+ );
+ },
+ [
chartAlert,
+ chartId,
chartStackTrace,
- datasource,
dashboardId,
- height,
datasetsStatus,
- } = this.props;
- const error = queryResponse?.errors?.[0];
- const message = chartAlert || queryResponse?.message;
+ datasource,
+ height,
+ ],
+ );
+
+ const renderSpinner = useCallback(
+ (databaseName: string | undefined) => {
+ const message = databaseName
+ ? t('Waiting on %s', databaseName)
+ : t('Waiting on database...');
- // if datasource is still loading, don't render JS errors
- if (
- chartAlert !== undefined &&
- chartAlert !== NONEXISTENT_DATASET &&
- datasource === PLACEHOLDER_DATASOURCE &&
- datasetsStatus !== ResourceStatus.Error
- ) {
return (
- <Styles
- key={chartId}
- data-ui-anchor="chart"
- className="chart-container"
- data-test="chart-container"
- height={height}
- >
+ <LoadingDiv>
<Loading
- size={this.props.dashboardId ? 's' : 'm'}
- muted={!!this.props.dashboardId}
+ position="inline-centered"
+ size={dashboardId ? 's' : 'm'}
+ muted={!!dashboardId}
/>
- </Styles>
+ <MessageSpan>{message}</MessageSpan>
+ </LoadingDiv>
);
- }
-
- return (
- <ChartErrorMessage
- key={chartId}
- chartId={chartId}
- error={error}
- subtitle={message}
- link={queryResponse ? queryResponse.link : undefined}
- source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
- stackTrace={chartStackTrace}
- />
- );
- }
-
- renderSpinner(databaseName: string | undefined) {
- const message = databaseName
- ? t('Waiting on %s', databaseName)
- : t('Waiting on database...');
-
- return (
- <LoadingDiv>
- <Loading
- position="inline-centered"
- size={this.props.dashboardId ? 's' : 'm'}
- muted={!!this.props.dashboardId}
- />
- <MessageSpan>{message}</MessageSpan>
- </LoadingDiv>
- );
- }
+ },
+ [dashboardId],
+ );
- renderChartContainer() {
- return (
+ const renderChartContainer = useCallback(
+ () => (
<div className="slice_container" data-test="slice-container">
- {this.shouldRenderChart() ? (
+ {shouldRenderChart() ? (
<ChartRenderer
- {...this.props}
- source={
- this.props.dashboardId
- ? ChartSource.Dashboard
- : ChartSource.Explore
- }
- data-test={this.props.vizType}
+ annotationData={annotationData}
+ actions={actions}
+ chartId={chartId}
+ datasource={datasource}
+ initialValues={initialValues}
+ formData={formData}
+ height={height}
+ width={width}
+ setControlValue={setControlValue}
+ vizType={vizType}
+ triggerRender={triggerRender}
+ chartAlert={chartAlert}
+ chartStatus={chartStatus}
+ queriesResponse={queriesResponse}
+ triggerQuery={triggerQuery}
+ chartIsStale={chartIsStale}
+ addFilter={addFilter}
+ onFilterMenuOpen={onFilterMenuOpen}
+ onFilterMenuClose={onFilterMenuClose}
+ ownState={ownState}
+ postTransformProps={postTransformProps}
+ emitCrossFilters={emitCrossFilters}
+ onChartStateChange={onChartStateChange}
+ latestQueryFormData={latestQueryFormData}
+ source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
+ data-test={vizType}
/>
) : (
- <Loading
- size={this.props.dashboardId ? 's' : 'm'}
- muted={!!this.props.dashboardId}
- />
+ <Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
)}
</div>
- );
- }
-
- render() {
- const {
- height,
+ ),
+ [
+ actions,
+ addFilter,
+ annotationData,
chartAlert,
+ chartId,
+ chartIsStale,
chartStatus,
+ dashboardId,
datasource,
- errorMessage,
- chartIsStale,
- queriesResponse = [],
+ emitCrossFilters,
+ formData,
+ height,
+ initialValues,
+ latestQueryFormData,
+ onChartStateChange,
+ onFilterMenuClose,
+ onFilterMenuOpen,
+ ownState,
+ postTransformProps,
+ queriesResponse,
+ setControlValue,
+ shouldRenderChart,
+ triggerQuery,
+ triggerRender,
+ vizType,
width,
- } = this.props;
+ ],
+ );
- const databaseName = datasource?.database?.name as string | undefined;
+ const databaseName = datasource?.database?.name as string | undefined;
- const isLoading = chartStatus === 'loading';
+ const isLoading = chartStatus === 'loading';
- if (chartStatus === 'failed') {
- return queriesResponse?.map(item =>
- this.renderErrorMessage(item as ChartErrorType),
- );
- }
-
- if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
- return (
- <EmptyState
- size="large"
- title={t('Add required control values to preview chart')}
- description={getChartRequiredFieldsMissingMessage(true)}
- image="chart.svg"
- />
- );
- }
- if (
- !isLoading &&
- !chartAlert &&
- !errorMessage &&
- chartIsStale &&
- ensureIsArray(queriesResponse).length === 0
- ) {
- return (
- <EmptyState
- size="large"
- title={t('Your chart is ready to go!')}
- description={
- <span>
- {t(
- 'Click on "Create chart" button in the control panel on the left to preview a visualization or',
- )}{' '}
- <span role="button" tabIndex={0} onClick={this.props.onQuery}>
- {t('click here')}
- </span>
- .
- </span>
- }
- image="chart.svg"
- />
- );
- }
+ if (chartStatus === 'failed') {
+ return (
+ <>
+ {queriesResponse?.map(item =>
+ renderErrorMessage(item as ChartErrorType),
+ )}
+ </>
+ );
+ }
+ if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
return (
- <ErrorBoundary
- onError={this.handleRenderContainerFailure}
- showMessage={false}
- >
- <Styles
- data-ui-anchor="chart"
- className="chart-container"
- data-test="chart-container"
- height={height}
- width={width}
- >
- {isLoading
- ? this.renderSpinner(databaseName)
- : this.renderChartContainer()}
- </Styles>
- </ErrorBoundary>
+ <EmptyState
+ size="large"
+ title={t('Add required control values to preview chart')}
+ description={getChartRequiredFieldsMissingMessage(true)}
+ image="chart.svg"
+ />
);
}
+ if (
+ !isLoading &&
+ !chartAlert &&
+ !errorMessage &&
+ chartIsStale &&
+ ensureIsArray(queriesResponse).length === 0
+ ) {
+ return (
+ <EmptyState
+ size="large"
+ title={t('Your chart is ready to go!')}
+ description={
+ <span>
+ {t(
+ 'Click on "Create chart" button in the control panel on the left to preview a visualization or',
+ )}{' '}
+ <span role="button" tabIndex={0} onClick={onQuery}>
+ {t('click here')}
+ </span>
+ .
+ </span>
+ }
+ image="chart.svg"
+ />
+ );
+ }
+
+ return (
+ <ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
+ <Styles
+ data-ui-anchor="chart"
+ className="chart-container"
+ data-test="chart-container"
+ height={height}
+ width={width}
+ >
+ {isLoading ? renderSpinner(databaseName) : renderChartContainer()}
+ </Styles>
+ </ErrorBoundary>
+ );
}
export default Chart;
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx
index 2b6016c1532..e28f8b335e6 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.tsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx
@@ -17,7 +17,16 @@
* under the License.
*/
import { snakeCase, isEqual, cloneDeep } from 'lodash';
-import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
+import {
+ createRef,
+ useCallback,
+ useState,
+ useRef,
+ useMemo,
+ MouseEvent,
+ ReactNode,
+ memo,
+} from 'react';
import {
SuperChart,
Behavior,
@@ -136,14 +145,6 @@ export interface ChartRendererProps {
onChartStateChange?: (chartState: AgGridChartState) => void;
}
-// State interface
-interface ChartRendererState {
- showContextMenu: boolean;
- inContextMenu: boolean;
- legendState: LegendState | undefined;
- legendIndex: number;
-}
-
// Hooks interface
interface ChartHooks {
onAddFilter: (
@@ -174,105 +175,258 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
const behaviors = [Behavior.InteractiveChart];
-const defaultProps: Partial<ChartRendererProps> = {
- addFilter: () => BLANK,
- onFilterMenuOpen: () => BLANK,
- onFilterMenuClose: () => BLANK,
- initialValues: BLANK,
- setControlValue: () => {},
- triggerRender: false,
-};
-
-class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
- static defaultProps = defaultProps;
-
- private hasQueryResponseChange: boolean;
-
- private contextMenuRef: RefObject<ChartContextMenuRef>;
-
- private hooks: ChartHooks;
-
- private mutableQueriesResponse: QueryData[] | null | undefined;
-
- private renderStartTime: number;
-
- constructor(props: ChartRendererProps) {
- super(props);
- const suppressContextMenu = getChartMetadataRegistry().get(
- props.formData.viz_type ?? props.vizType,
- )?.suppressContextMenu;
- this.state = {
- showContextMenu:
- props.source === ChartSource.Dashboard &&
- !suppressContextMenu &&
- isFeatureEnabled(FeatureFlag.DrillToDetail),
- inContextMenu: false,
- legendState: undefined,
- legendIndex: 0,
- };
- this.hasQueryResponseChange = false;
- this.renderStartTime = 0;
-
- this.contextMenuRef = createRef<ChartContextMenuRef>();
-
- this.handleAddFilter = this.handleAddFilter.bind(this);
- this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
- this.handleRenderFailure = this.handleRenderFailure.bind(this);
- this.handleSetControlValue = this.handleSetControlValue.bind(this);
- this.handleOnContextMenu = this.handleOnContextMenu.bind(this);
- this.handleContextMenuSelected = this.handleContextMenuSelected.bind(this);
- this.handleContextMenuClosed = this.handleContextMenuClosed.bind(this);
- this.handleLegendStateChanged = this.handleLegendStateChanged.bind(this);
- this.onContextMenuFallback = this.onContextMenuFallback.bind(this);
- this.handleLegendScroll = this.handleLegendScroll.bind(this);
-
- this.hooks = {
- onAddFilter: this.handleAddFilter,
- onContextMenu: this.state.showContextMenu
- ? this.handleOnContextMenu
- : undefined,
- onError: this.handleRenderFailure,
- setControlValue: this.handleSetControlValue,
- onFilterMenuOpen: this.props.onFilterMenuOpen,
- onFilterMenuClose: this.props.onFilterMenuClose,
- onLegendStateChanged: this.handleLegendStateChanged,
- setDataMask: (dataMask: DataMask) => {
- this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
- },
- onLegendScroll: this.handleLegendScroll,
- onChartStateChange: this.props.onChartStateChange,
- };
-
- // TODO: queriesResponse comes from Redux store but it's being edited by
- // the plugins, hence we need to clone it to avoid state mutation
- // until we change the reducers to use Redux Toolkit with Immer
- this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
- }
+interface ChartRendererState {
+ showContextMenu: boolean;
+ inContextMenu: boolean;
+ legendState: LegendState | undefined;
+ legendIndex: number;
+}
+
+interface PrevPropsRef {
+ queriesResponse: QueryData[] | null | undefined;
+ datasource: Datasource | undefined;
+ annotationData: AnnotationData | undefined;
+ ownState: OwnState | undefined;
+ filterState: FilterState | undefined;
+ height: number | undefined;
+ width: number | undefined;
+ triggerRender: boolean;
+ labelsColor: Record<string, string> | undefined;
+ labelsColorMap: Record<string, string> | undefined;
+ formData: QueryFormData;
+ cacheBusterProp: string | undefined;
+ emitCrossFilters: boolean | undefined;
+ postTransformProps: ((props: JsonObject) => JsonObject) | undefined;
+}
+
+function ChartRendererComponent({
+ addFilter = () => BLANK,
+ onFilterMenuOpen = () => BLANK,
+ onFilterMenuClose = () => BLANK,
+ initialValues = BLANK,
+ setControlValue = () => {},
+ triggerRender = false,
+ ...restProps
+}: ChartRendererProps): JSX.Element | null {
+ const {
+ annotationData,
+ actions,
+ chartId,
+ datasource,
+ formData,
+ latestQueryFormData,
+ labelsColor,
+ labelsColorMap,
+ height,
+ width,
+ vizType: propVizType,
+ chartAlert,
+ chartStatus,
+ queriesResponse,
+ chartIsStale,
+ ownState,
+ filterState,
+ postTransformProps,
+ source,
+ emitCrossFilters,
+ cacheBusterProp,
+ onChartStateChange,
+ } = restProps;
+
+ const suppressContextMenu = getChartMetadataRegistry().get(
+ formData.viz_type ?? propVizType,
+ )?.suppressContextMenu;
+
+ const [state, setState] = useState<ChartRendererState>({
+ showContextMenu:
+ source === ChartSource.Dashboard &&
+ !suppressContextMenu &&
+ isFeatureEnabled(FeatureFlag.DrillToDetail),
+ inContextMenu: false,
+ legendState: undefined,
+ legendIndex: 0,
+ });
+
+ const hasQueryResponseChangeRef = useRef(false);
+ const renderStartTimeRef = useRef(0);
+ const mutableQueriesResponseRef = useRef<QueryData[] | null | undefined>(
+ cloneDeep(queriesResponse),
+ );
+ const contextMenuRef = createRef<ChartContextMenuRef>();
+
+ // Track previous props for shouldComponentUpdate logic
+ const prevPropsRef = useRef<PrevPropsRef>({
+ queriesResponse,
+ datasource,
+ annotationData,
+ ownState,
+ filterState,
+ height,
+ width,
+ triggerRender,
+ labelsColor,
+ labelsColorMap,
+ formData,
+ cacheBusterProp,
+ emitCrossFilters,
+ postTransformProps,
+ });
+
+ // Handler functions
+ const handleAddFilter = useCallback(
+ (col: string, vals: FilterValue[], merge = true, refresh = true): void => {
+ addFilter?.(col, vals, merge, refresh);
+ },
+ [addFilter],
+ );
+
+ const handleRenderSuccess = useCallback((): void => {
+ if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
+ actions.chartRenderingSucceeded(chartId);
+ }
+
+ // only log chart render time which is triggered by query results change
+ if (hasQueryResponseChangeRef.current) {
+ actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+ slice_id: chartId,
+ viz_type: propVizType,
+ start_offset: renderStartTimeRef.current,
+ ts: new Date().getTime(),
+ duration: Logger.getTimestamp() - renderStartTimeRef.current,
+ });
+ }
+ }, [actions, chartId, chartStatus, propVizType]);
+
+ const handleRenderFailure = useCallback(
+ (error: Error, info: { componentStack: string } | null): void => {
+ logging.warn(error);
+ actions.chartRenderingFailed(
+ error.toString(),
+ chartId,
+ info ? info.componentStack : null,
+ );
+
+ // only trigger render log when query is changed
+ if (hasQueryResponseChangeRef.current) {
+ actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
+ slice_id: chartId,
+ has_err: true,
+ error_details: error.toString(),
+ start_offset: renderStartTimeRef.current,
+ ts: new Date().getTime(),
+ duration: Logger.getTimestamp() - renderStartTimeRef.current,
+ });
+ }
+ },
+ [actions, chartId],
+ );
+
+ const handleSetControlValue = useCallback(
+ (name: string, value: unknown): void => {
+ if (setControlValue) {
+ setControlValue(name, value);
+ }
+ },
+ [setControlValue],
+ );
+
+ const handleOnContextMenu = useCallback(
+ (offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
+ contextMenuRef.current?.open(offsetX, offsetY, filters);
+ setState(prev => ({ ...prev, inContextMenu: true }));
+ },
+ [contextMenuRef],
+ );
+
+ const handleContextMenuSelected = useCallback((): void => {
+ setState(prev => ({ ...prev, inContextMenu: false }));
+ }, []);
+
+ const handleContextMenuClosed = useCallback((): void => {
+ setState(prev => ({ ...prev, inContextMenu: false }));
+ }, []);
- shouldComponentUpdate(
- nextProps: ChartRendererProps,
- nextState: ChartRendererState,
- ): boolean {
+ const handleLegendStateChanged = useCallback(
+ (legendState: LegendState): void => {
+ setState(prev => ({ ...prev, legendState }));
+ },
+ [],
+ );
+
+ const handleLegendScroll = useCallback((legendIndex: number): void => {
+ setState(prev => ({ ...prev, legendIndex }));
+ }, []);
+
+ // When viz plugins don't handle `contextmenu` event, fallback handler
+ // calls `handleOnContextMenu` with no `filters` param.
+ const onContextMenuFallback = useCallback(
+ (event: MouseEvent<HTMLDivElement>): void => {
+ if (!state.inContextMenu) {
+ event.preventDefault();
+ handleOnContextMenu(event.clientX, event.clientY);
+ }
+ },
+ [handleOnContextMenu, state.inContextMenu],
+ );
+
+ const setDataMaskCallback = useCallback(
+ (dataMask: DataMask) => {
+ actions?.updateDataMask?.(chartId, dataMask);
+ },
+ [actions, chartId],
+ );
+
+ // Hooks object - memoized
+ const hooks = useMemo<ChartHooks>(
+ () => ({
+ onAddFilter: handleAddFilter,
+ onContextMenu: state.showContextMenu ? handleOnContextMenu : undefined,
+ onError: handleRenderFailure,
+ setControlValue: handleSetControlValue,
+ onFilterMenuOpen,
+ onFilterMenuClose,
+ onLegendStateChanged: handleLegendStateChanged,
+ setDataMask: setDataMaskCallback,
+ onLegendScroll: handleLegendScroll,
+ onChartStateChange,
+ }),
+ [
+ handleAddFilter,
+ handleLegendScroll,
+ handleLegendStateChanged,
+ handleOnContextMenu,
+ handleRenderFailure,
+ handleSetControlValue,
+ onChartStateChange,
+ onFilterMenuClose,
+ onFilterMenuOpen,
+ setDataMaskCallback,
+ state.showContextMenu,
+ ],
+ );
+
+ // shouldComponentUpdate logic - implemented as a useMemo that tracks if we should render
+ // Note: The return value is not used directly, but the useMemo contains necessary
+ // side effects (updating refs).
+ useMemo(() => {
+ const prevProps = prevPropsRef.current;
const resultsReady =
- nextProps.queriesResponse &&
- ['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
- !nextProps.queriesResponse?.[0]?.error;
+ queriesResponse &&
+ ['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
+ !queriesResponse?.[0]?.error;
if (resultsReady) {
- if (!isEqual(this.state, nextState)) {
- return true;
- }
- this.hasQueryResponseChange =
- nextProps.queriesResponse !== this.props.queriesResponse;
+ hasQueryResponseChangeRef.current =
+ queriesResponse !== prevProps.queriesResponse;
- if (this.hasQueryResponseChange) {
- this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
+ if (hasQueryResponseChangeRef.current) {
+ mutableQueriesResponseRef.current = cloneDeep(queriesResponse);
}
// Check if any matrixify-related properties have changed
const hasMatrixifyChanges = (): boolean => {
- const nextFormData = nextProps.formData as JsonObject;
- const currentFormData = this.props.formData as JsonObject;
+ const nextFormData = formData as JsonObject;
+ const currentFormData = prevProps.formData as JsonObject;
const isMatrixifyEnabled =
nextFormData.matrixify_enable_vertical_layout === true ||
nextFormData.matrixify_enable_horizontal_layout === true;
@@ -288,272 +442,193 @@ class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
);
};
- const nextFormData = nextProps.formData as JsonObject;
- const currentFormData = this.props.formData as JsonObject;
-
- return (
- this.hasQueryResponseChange ||
- !isEqual(nextProps.datasource, this.props.datasource) ||
- nextProps.annotationData !== this.props.annotationData ||
- nextProps.ownState !== this.props.ownState ||
- nextProps.filterState !== this.props.filterState ||
- nextProps.height !== this.props.height ||
- nextProps.width !== this.props.width ||
- nextProps.triggerRender === true ||
- nextProps.labelsColor !== this.props.labelsColor ||
- nextProps.labelsColorMap !== this.props.labelsColorMap ||
+ const nextFormData = formData as JsonObject;
+ const currentFormData = prevProps.formData as JsonObject;
+
+ const shouldRender =
+ hasQueryResponseChangeRef.current ||
+ !isEqual(datasource, prevProps.datasource) ||
+ annotationData !== prevProps.annotationData ||
+ ownState !== prevProps.ownState ||
+ filterState !== prevProps.filterState ||
+ height !== prevProps.height ||
+ width !== prevProps.width ||
+ triggerRender === true ||
+ labelsColor !== prevProps.labelsColor ||
+ labelsColorMap !== prevProps.labelsColorMap ||
nextFormData.color_scheme !== currentFormData.color_scheme ||
nextFormData.stack !== currentFormData.stack ||
nextFormData.subcategories !== currentFormData.subcategories ||
- nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
- nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
- nextProps.postTransformProps !== this.props.postTransformProps ||
- hasMatrixifyChanges()
- );
- }
- return false;
- }
-
- handleAddFilter(
- col: string,
- vals: FilterValue[],
- merge = true,
- refresh = true,
- ): void {
- this.props.addFilter?.(col, vals, merge, refresh);
- }
-
- handleRenderSuccess(): void {
- const { actions, chartStatus, chartId, vizType } = this.props;
- if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
- actions.chartRenderingSucceeded(chartId);
- }
+ cacheBusterProp !== prevProps.cacheBusterProp ||
+ emitCrossFilters !== prevProps.emitCrossFilters ||
+ postTransformProps !== prevProps.postTransformProps ||
+ hasMatrixifyChanges();
+
+ // Update prev props ref
+ prevPropsRef.current = {
+ queriesResponse,
+ datasource,
+ annotationData,
+ ownState,
+ filterState,
+ height,
+ width,
+ triggerRender,
+ labelsColor,
+ labelsColorMap,
+ formData,
+ cacheBusterProp,
+ emitCrossFilters,
+ postTransformProps,
+ };
- // only log chart render time which is triggered by query results change
- // currently we don't log chart re-render time, like window resize etc
- if (this.hasQueryResponseChange) {
- actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
- slice_id: chartId,
- viz_type: vizType,
- start_offset: this.renderStartTime,
- ts: new Date().getTime(),
- duration: Logger.getTimestamp() - this.renderStartTime,
- });
+ return shouldRender;
}
+ return false;
+ }, [
+ annotationData,
+ cacheBusterProp,
+ chartStatus,
+ datasource,
+ emitCrossFilters,
+ filterState,
+ formData,
+ height,
+ labelsColor,
+ labelsColorMap,
+ ownState,
+ postTransformProps,
+ queriesResponse,
+ triggerRender,
+ width,
+ ]);
+
+ // Skip chart rendering
+ if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
+ return null;
}
- handleRenderFailure(
- error: Error,
- info: { componentStack: string } | null,
- ): void {
- const { actions, chartId } = this.props;
- logging.warn(error);
- actions.chartRenderingFailed(
- error.toString(),
- chartId,
- info ? info.componentStack : null,
+ renderStartTimeRef.current = Logger.getTimestamp();
+
+ const currentFormData =
+ chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
+ const vizType = currentFormData.viz_type || propVizType;
+
+ // It's bad practice to use unprefixed `vizType` as classnames for chart
+ // container. It may cause css conflicts as in the case of legacy table chart.
+ // When migrating charts, we should gradually add a `superset-chart-` prefix
+ // to each one of them.
+ const snakeCaseVizType = snakeCase(vizType);
+ const chartClassName =
+ vizType === VizType.Table
+ ? `superset-chart-${snakeCaseVizType}`
+ : snakeCaseVizType;
+
+ const webpackHash =
+ process.env.WEBPACK_MODE === 'development'
+ ? `-${
+ typeof __webpack_require__ !== 'undefined' &&
+ typeof __webpack_require__.h === 'function' &&
+ __webpack_require__.h()
+ }`
+ : '';
+
+ let noResultsComponent: ReactNode;
+ const noResultTitle = t('No results were returned for this query');
+ const noResultDescription =
+ source === ChartSource.Explore
+ ? t(
+ 'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
+ )
+ : undefined;
+ const noResultImage = 'chart.svg';
+ if (
+ (width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
+ (height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
+ ) {
+ noResultsComponent = (
+ <EmptyState
+ size="large"
+ title={noResultTitle}
+ description={noResultDescription}
+ image={noResultImage}
+ />
+ );
+ } else {
+ noResultsComponent = (
+ <EmptyState title={noResultTitle} image={noResultImage} size="small" />
);
-
- // only trigger render log when query is changed
- if (this.hasQueryResponseChange) {
- actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
- slice_id: chartId,
- has_err: true,
- error_details: error.toString(),
- start_offset: this.renderStartTime,
- ts: new Date().getTime(),
- duration: Logger.getTimestamp() - this.renderStartTime,
- });
- }
- }
-
- handleSetControlValue(name: string, value: unknown): void {
- const { setControlValue } = this.props;
- if (setControlValue) {
- setControlValue(name, value);
- }
- }
-
- handleOnContextMenu(
- offsetX: number,
- offsetY: number,
- filters?: ContextMenuFilters,
- ): void {
- this.contextMenuRef.current?.open(offsetX, offsetY, filters);
- this.setState({ inContextMenu: true });
- }
-
- handleContextMenuSelected(): void {
- this.setState({ inContextMenu: false });
- }
-
- handleContextMenuClosed(): void {
- this.setState({ inContextMenu: false });
- }
-
- handleLegendStateChanged(legendState: LegendState): void {
- this.setState({ legendState });
- }
-
- // When viz plugins don't handle `contextmenu` event, fallback handler
- // calls `handleOnContextMenu` with no `filters` param.
- onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
- if (!this.state.inContextMenu) {
- event.preventDefault();
- this.handleOnContextMenu(event.clientX, event.clientY);
- }
- }
-
- handleLegendScroll(legendIndex: number): void {
- this.setState({ legendIndex });
}
- render(): ReactNode {
- const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
-
- // Skip chart rendering
- if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) {
- return null;
- }
-
- this.renderStartTime = Logger.getTimestamp();
-
- const {
- width,
- height,
- datasource,
- annotationData,
- initialValues,
- ownState,
- filterState,
- chartIsStale,
- formData,
- latestQueryFormData,
- postTransformProps,
- } = this.props;
-
- const currentFormData =
- chartIsStale && latestQueryFormData ? latestQueryFormData : formData;
- const vizType = currentFormData.viz_type || this.props.vizType;
-
- // It's bad practice to use unprefixed `vizType` as classnames for chart
- // container. It may cause css conflicts as in the case of legacy table chart.
- // When migrating charts, we should gradually add a `superset-chart-` prefix
- // to each one of them.
- const snakeCaseVizType = snakeCase(vizType);
- const chartClassName =
- vizType === VizType.Table
- ? `superset-chart-${snakeCaseVizType}`
- : snakeCaseVizType;
-
- const webpackHash =
- process.env.WEBPACK_MODE === 'development'
- ? `-${
- // eslint-disable-next-line camelcase
- typeof __webpack_require__ !== 'undefined' &&
- // eslint-disable-next-line camelcase, no-undef
- typeof __webpack_require__.h === 'function' &&
- // eslint-disable-next-line no-undef, camelcase
- __webpack_require__.h()
- }`
- : '';
-
- let noResultsComponent: ReactNode;
- const noResultTitle = t('No results were returned for this query');
- const noResultDescription =
- this.props.source === ChartSource.Explore
- ? t(
- 'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
- )
- : undefined;
- const noResultImage = 'chart.svg';
- if (
- (width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
- (height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
- ) {
- noResultsComponent = (
- <EmptyState
- size="large"
- title={noResultTitle}
- description={noResultDescription}
- image={noResultImage}
+ // Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
+ // Detail props or if it'll cause side-effects (e.g. excessive re-renders).
+ const drillToDetailProps = getChartMetadataRegistry()
+ .get(vizType)
+ ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
+ ? { inContextMenu: state.inContextMenu }
+ : {};
+ // By pass no result component when server pagination is enabled & the table has:
+ // - a backend search query, OR
+ // - non-empty AG Grid filter model
+ const hasSearchText = (ownState?.searchText?.length || 0) > 0;
+ const hasAgGridFilters =
+ ownState?.agGridFilterModel &&
+ Object.keys(ownState.agGridFilterModel).length > 0;
+
+ const currentFormDataExtended = currentFormData as JsonObject;
+ const bypassNoResult = !(
+ currentFormDataExtended?.server_pagination &&
+ (hasSearchText || hasAgGridFilters)
+ );
+
+ return (
+ <>
+ {state.showContextMenu && (
+ <ChartContextMenu
+ ref={contextMenuRef}
+ id={chartId}
+ formData={currentFormData as QueryFormData}
+ onSelection={handleContextMenuSelected}
+ onClose={handleContextMenuClosed}
/>
- );
- } else {
- noResultsComponent = (
- <EmptyState title={noResultTitle} image={noResultImage} size="small" />
- );
- }
-
- // Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
- // Detail props or if it'll cause side-effects (e.g. excessive re-renders).
- const drillToDetailProps = getChartMetadataRegistry()
- .get(vizType)
- ?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
- ? { inContextMenu: this.state.inContextMenu }
- : {};
- // By pass no result component when server pagination is enabled & the table has:
- // - a backend search query, OR
- // - non-empty AG Grid filter model
- const hasSearchText = (ownState?.searchText?.length || 0) > 0;
- const hasAgGridFilters =
- ownState?.agGridFilterModel &&
- Object.keys(ownState.agGridFilterModel).length > 0;
-
- const currentFormDataExtended = currentFormData as JsonObject;
- const bypassNoResult = !(
- currentFormDataExtended?.server_pagination &&
- (hasSearchText || hasAgGridFilters)
- );
-
- return (
- <>
- {this.state.showContextMenu && (
- <ChartContextMenu
- ref={this.contextMenuRef}
- id={chartId}
- formData={currentFormData as QueryFormData}
- onSelection={this.handleContextMenuSelected}
- onClose={this.handleContextMenuClosed}
- />
- )}
- <div
- onContextMenu={
- this.state.showContextMenu ? this.onContextMenuFallback : undefined
- }
- >
- <SuperChart
- disableErrorBoundary
- key={`${chartId}${webpackHash}`}
- id={`chart-id-${chartId}`}
- className={chartClassName}
- chartType={vizType}
- width={width}
- height={height}
- annotationData={annotationData}
- datasource={datasource}
- initialValues={initialValues}
- formData={currentFormData}
- ownState={ownState}
- filterState={filterState}
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- hooks={this.hooks as any}
- behaviors={behaviors}
- queriesData={this.mutableQueriesResponse ?? undefined}
- onRenderSuccess={this.handleRenderSuccess}
- onRenderFailure={this.handleRenderFailure}
- noResults={noResultsComponent}
- postTransformProps={postTransformProps}
- emitCrossFilters={emitCrossFilters}
- legendState={this.state.legendState}
- enableNoResults={bypassNoResult}
- legendIndex={this.state.legendIndex}
- {...drillToDetailProps}
- />
- </div>
- </>
- );
- }
+ )}
+ <div
+ onContextMenu={
+ state.showContextMenu ? onContextMenuFallback : undefined
+ }
+ >
+ <SuperChart
+ disableErrorBoundary
+ key={`${chartId}${webpackHash}`}
+ id={`chart-id-${chartId}`}
+ className={chartClassName}
+ chartType={vizType}
+ width={width}
+ height={height}
+ annotationData={annotationData}
+ datasource={datasource}
+ initialValues={initialValues}
+ formData={currentFormData}
+ ownState={ownState}
+ filterState={filterState}
+ hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
+ behaviors={behaviors}
+ queriesData={mutableQueriesResponseRef.current ?? undefined}
+ onRenderSuccess={handleRenderSuccess}
+ onRenderFailure={handleRenderFailure}
+ noResults={noResultsComponent}
+ postTransformProps={postTransformProps}
+ emitCrossFilters={emitCrossFilters}
+ legendState={state.legendState}
+ enableNoResults={bypassNoResult}
+ legendIndex={state.legendIndex}
+ {...drillToDetailProps}
+ />
+ </div>
+ </>
+ );
}
+const ChartRenderer = memo(ChartRendererComponent);
+
export default ChartRenderer;
diff --git a/superset-frontend/src/components/CopyToClipboard/index.tsx b/superset-frontend/src/components/CopyToClipboard/index.tsx
index 976f0059c30..a7c4b00838d 100644
--- a/superset-frontend/src/components/CopyToClipboard/index.tsx
+++ b/superset-frontend/src/components/CopyToClipboard/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { Component, cloneElement, ReactElement } from 'react';
+import { cloneElement, ReactElement, useCallback } from 'react';
import { t } from '@apache-superset/core';
import { css, SupersetTheme } from '@apache-superset/core/ui';
import copyTextToClipboard from 'src/utils/copy';
@@ -24,108 +24,107 @@ import { Tooltip } from '@superset-ui/core/components';
import withToasts from '../MessageToasts/withToasts';
import type { CopyToClipboardProps } from './types';
-const defaultProps: Partial<CopyToClipboardProps> = {
- copyNode: <span>{t('Copy')}</span>,
- onCopyEnd: () => {},
- shouldShowText: true,
- wrapped: true,
- tooltipText: t('Copy to clipboard'),
- hideTooltip: false,
-};
+function CopyToClip({
+ copyNode = <span>{t('Copy')}</span>,
+ onCopyEnd = () => {},
+ shouldShowText = true,
+ wrapped = true,
+ tooltipText = t('Copy to clipboard'),
+ hideTooltip = false,
+ getText,
+ text,
+ addSuccessToast,
+ addDangerToast,
+}: CopyToClipboardProps) {
+ const copyToClipboard = useCallback(
+ (textToCopy: Promise<string>) => {
+ copyTextToClipboard(() => textToCopy)
+ .then(() => {
+ addSuccessToast(t('Copied to clipboard!'));
+ })
+ .catch(() => {
+ addDangerToast(
+ t(
+ 'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
+ ),
+ );
+ })
+ .finally(() => {
+ if (onCopyEnd) onCopyEnd();
+ });
+ },
+ [addSuccessToast, addDangerToast, onCopyEnd],
+ );
-class CopyToClip extends Component<CopyToClipboardProps> {
- static defaultProps = defaultProps;
-
- constructor(props: CopyToClipboardProps) {
- super(props);
- this.copyToClipboard = this.copyToClipboard.bind(this);
- this.onClick = this.onClick.bind(this);
- }
-
- onClick() {
- if (this.props.getText) {
- this.props.getText((d: string) => {
- this.copyToClipboard(Promise.resolve(d));
+ const onClick = useCallback(() => {
+ if (getText) {
+ getText((d: string) => {
+ copyToClipboard(Promise.resolve(d));
});
} else {
- this.copyToClipboard(Promise.resolve(this.props.text || ''));
+ copyToClipboard(Promise.resolve(text || ''));
}
- }
-
- getDecoratedCopyNode() {
- return cloneElement(this.props.copyNode as ReactElement, {
- style: { cursor: 'pointer' },
- onClick: this.onClick,
- });
- }
+ }, [getText, text, copyToClipboard]);
- copyToClipboard(textToCopy: Promise<string>) {
- copyTextToClipboard(() => textToCopy)
- .then(() => {
- this.props.addSuccessToast(t('Copied to clipboard!'));
- })
- .catch(() => {
- this.props.addDangerToast(
- t(
- 'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
- ),
- );
- })
- .finally(() => {
- if (this.props.onCopyEnd) this.props.onCopyEnd();
- });
- }
+ const getDecoratedCopyNode = useCallback(
+ () =>
+ cloneElement(copyNode as ReactElement, {
+ style: { cursor: 'pointer' },
+ onClick,
+ }),
+ [copyNode, onClick],
+ );
- renderTooltip(cursor: string) {
- return (
+ const renderTooltip = useCallback(
+ (cursor: string) => (
<>
- {!this.props.hideTooltip ? (
+ {!hideTooltip ? (
<Tooltip
id="copy-to-clipboard-tooltip"
placement="topRight"
style={{ cursor }}
- title={this.props.tooltipText || ''}
+ title={tooltipText || ''}
trigger={['hover']}
arrow={{ pointAtCenter: true }}
>
- {this.getDecoratedCopyNode()}
+ {getDecoratedCopyNode()}
</Tooltip>
) : (
- this.getDecoratedCopyNode()
+ getDecoratedCopyNode()
)}
</>
- );
- }
+ ),
+ [hideTooltip, tooltipText, getDecoratedCopyNode],
+ );
- renderNotWrapped() {
- return this.renderTooltip('pointer');
- }
+ const renderNotWrapped = useCallback(
+ () => renderTooltip('pointer'),
+ [renderTooltip],
+ );
- renderLink() {
- return (
+ const renderLink = useCallback(
+ () => (
<span css={{ display: 'inline-flex', alignItems: 'center' }}>
- {this.props.shouldShowText && this.props.text && (
+ {shouldShowText && text && (
<span
data-test="short-url"
css={(theme: SupersetTheme) => css`
margin-right: ${theme.sizeUnit}px;
`}
>
- {this.props.text}
+ {text}
</span>
)}
- {this.renderTooltip('pointer')}
+ {renderTooltip('pointer')}
</span>
- );
- }
+ ),
+ [shouldShowText, text, renderTooltip],
+ );
- render() {
- const { wrapped } = this.props;
- if (!wrapped) {
- return this.renderNotWrapped();
- }
- return this.renderLink();
+ if (!wrapped) {
+ return renderNotWrapped();
}
+ return renderLink();
}
export const CopyToClipboard = withToasts(CopyToClip);
diff --git a/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
index 205e9882e02..bb607479f19 100644
--- a/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
+++ b/superset-frontend/src/components/Datasource/components/CollectionTable/CollectionTable.test.tsx
@@ -32,5 +32,5 @@ test('renders a table', () => {
const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).toBeInTheDocument();
const rows = tableBody?.getElementsByTagName('tr');
- expect(rows).toHaveLength(mockDatasource['7__table'].columns.length + 1);
+ expect(rows).toHaveLength(mockDatasource['7__table'].columns.length);
});
diff --git a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
index 53cda1455b4..7494cc33dda 100644
--- a/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
+++ b/superset-frontend/src/components/Datasource/components/CollectionTable/index.tsx
@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
-import { PureComponent, ReactNode } from 'react';
+import { ReactNode, useState, useCallback, useEffect, useMemo } from 'react';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/ui';
@@ -33,8 +33,8 @@ import Fieldset from '../Fieldset';
import { recurseReactClone } from '../../utils';
import {
type CRUDCollectionProps,
- type CRUDCollectionState,
type Sort,
+ SortOrder as SortOrderEnum,
} from '../../types';
const CrudButtonWrapper = styled.div`
@@ -52,18 +52,18 @@ const StyledButtonWrapper = styled.span`
`}
`;
-type CollectionItem = { id: string | number; [key: string]: any };
+type CollectionItem = { id: string | number; [key: string]: unknown };
function createKeyedCollection(arr: Array<object>) {
const collectionArray = arr.map(
- (o: any) =>
+ (o: Record<string, unknown>) =>
({
...o,
id: o.id || nanoid(),
}) as CollectionItem,
);
- const collection: Record<PropertyKey, any> = {};
+ const collection: Record<PropertyKey, CollectionItem> = {};
collectionArray.forEach((o: CollectionItem) => {
collection[o.id] = o;
});
@@ -74,252 +74,291 @@ function createKeyedCollection(arr: Array<object>) {
};
}
-export default class CRUDCollection extends PureComponent<
- CRUDCollectionProps,
- CRUDCollectionState
-> {
- constructor(props: CRUDCollectionProps) {
- super(props);
-
- const { collection, collectionArray } = createKeyedCollection(
- props.collection,
- );
- this.state = {
- expandedColumns: {},
- collection,
- collectionArray,
- sortColumn: '',
- sort: 0,
- };
- this.onAddItem = this.onAddItem.bind(this);
- this.renderExpandableSection = this.renderExpandableSection.bind(this);
- this.getLabel = this.getLabel.bind(this);
- this.onFieldsetChange = this.onFieldsetChange.bind(this);
- this.changeCollection = this.changeCollection.bind(this);
- this.handleTableChange = this.handleTableChange.bind(this);
- this.buildTableColumns = this.buildTableColumns.bind(this);
- this.toggleExpand = this.toggleExpand.bind(this);
- }
-
- componentDidUpdate(prevProps: CRUDCollectionProps) {
- if (this.props.collection !== prevProps.collection) {
- const { collection, collectionArray } = createKeyedCollection(
- this.props.collection,
- );
+export default function CRUDCollection({
+ allowAddItem = false,
+ allowDeletes = false,
+ collection: propsCollection,
+ columnLabels,
+ columnLabelTooltips,
+ emptyMessage = t('No items'),
+ expandFieldset,
+ itemGenerator,
+ itemCellProps,
+ itemRenderers,
+ onChange,
+ tableColumns,
+ sortColumns = [],
+ stickyHeader = false,
+}: CRUDCollectionProps) {
+ const [expandedColumns, setExpandedColumns] = useState<
+ Record<PropertyKey, boolean>
+ >({});
+ const [collection, setCollection] = useState<
+ Record<PropertyKey, CollectionItem>
+ >(() => createKeyedCollection(propsCollection).collection);
+ const [collectionArray, setCollectionArray] = useState<CollectionItem[]>(
+ () => createKeyedCollection(propsCollection).collectionArray,
+ );
+ const [sortColumn, setSortColumn] = useState<string>('');
+ const [sort, setSort] = useState<SortOrderEnum>(SortOrderEnum.Unsorted);
+
+ // Sync with props.collection changes
+ useEffect(() => {
+ const { collection: newCollection, collectionArray: newCollectionArray } =
+ createKeyedCollection(propsCollection);
+ setCollection(newCollection);
+ setCollectionArray(newCollectionArray);
+ }, [propsCollection]);
+
+ const onCellChange = useCallback(
+ (id: string | number, col: string, val: unknown) => {
+ setCollection(prevCollection => {
+ const updatedCollection = {
+ ...prevCollection,
+ [id]: {
+ ...prevCollection[id],
+ [col]: val,
+ },
+ };
+ return updatedCollection;
+ });
- this.setState(prevState => ({
- collection,
- collectionArray,
- expandedColumns: prevState.expandedColumns,
- }));
- }
- }
-
- onCellChange(id: string | number, col: string, val: unknown) {
- this.setState(prevState => {
- const updatedCollection = {
- ...prevState.collection,
- [id]: {
- ...prevState.collection[id],
- [col]: val,
- },
- };
- const updatedCollectionArray = prevState.collectionArray.map(item =>
- item.id === id ? updatedCollection[id] : item,
- );
+ setCollectionArray(prevCollectionArray => {
+ const updatedCollectionArray = prevCollectionArray.map(item => {
+ if (item.id === id) {
+ return {
+ ...item,
+ [col]: val,
+ };
+ }
+ return item;
+ });
+
+ if (onChange) {
+ onChange(updatedCollectionArray);
+ }
+
+ return updatedCollectionArray;
+ });
+ },
+ [onChange],
+ );
- if (this.props.onChange) {
- this.props.onChange(updatedCollectionArray);
+ const changeCollection = useCallback(
+ (
+ newCollection: Record<PropertyKey, CollectionItem>,
+ currentCollectionArray: CollectionItem[],
+ ) => {
+ // Preserve existing order instead of recreating from Object.keys()
+ const existingIds = new Set(currentCollectionArray.map(item => item.id));
+ const newCollectionArray: CollectionItem[] = [];
+
+ // First pass: preserve existing order and update items
+ for (const existingItem of currentCollectionArray) {
+ if (newCollection[existingItem.id]) {
+ newCollectionArray.push(newCollection[existingItem.id]);
+ }
}
- return {
- collection: updatedCollection,
- collectionArray: updatedCollectionArray,
- };
- });
- }
- onAddItem() {
- if (this.props.itemGenerator) {
- let newItem = this.props.itemGenerator();
+ // Second pass: add new items
+ for (const item of Object.values(newCollection)) {
+ if (!existingIds.has(item.id)) {
+ newCollectionArray.push(item);
+ }
+ }
+
+ setCollection(newCollection);
+ setCollectionArray(newCollectionArray);
+
+ if (onChange) {
+ onChange(newCollectionArray);
+ }
+ },
+ [onChange],
+ );
+
+ const deleteItem = useCallback(
+ (id: string | number) => {
+ setCollection(prevCollection => {
+ const newColl = { ...prevCollection };
+ delete newColl[id];
+ return newColl;
+ });
+
+ setCollectionArray(prevCollectionArray => {
+ const newCollectionArray = prevCollectionArray.filter(
+ item => item.id !== id,
+ );
+
+ if (onChange) {
+ onChange(newCollectionArray);
+ }
+
+ return newCollectionArray;
+ });
+ },
+ [onChange],
+ );
+
+ const onAddItem = useCallback(() => {
+ if (itemGenerator) {
+ let newItem = itemGenerator() as CollectionItem;
const shouldStartExpanded = newItem.expanded === true;
if (!newItem.id) {
newItem = { ...newItem, id: nanoid() };
}
delete newItem.expanded;
- this.setState(
- prevState => {
- const newCollection = {
- ...prevState.collection,
- [newItem.id]: newItem,
- };
- const newExpandedColumns = shouldStartExpanded
- ? { ...prevState.expandedColumns, [newItem.id]: true }
- : prevState.expandedColumns;
- const newCollectionArray = [newItem, ...prevState.collectionArray];
-
- return {
- collection: newCollection,
- collectionArray: newCollectionArray,
- expandedColumns: newExpandedColumns,
- };
- },
- () => {
- if (this.props.onChange) {
- this.props.onChange(this.state.collectionArray);
- }
- },
- );
- }
- }
+ setCollection(prevCollection => ({
+ ...prevCollection,
+ [newItem.id]: newItem,
+ }));
- onFieldsetChange(item: any) {
- this.changeCollection({
- ...this.state.collection,
- [item.id]: item,
- });
- }
+ setCollectionArray(prevCollectionArray => {
+ const newCollectionArray = [newItem, ...prevCollectionArray];
- getLabel(col: any): string {
- const { columnLabels } = this.props;
- let label = columnLabels?.[col] ? columnLabels[col] : col;
- if (label.startsWith('__')) {
- label = '';
- }
- return label;
- }
-
- getTooltip(col: string): string | undefined {
- const { columnLabelTooltips } = this.props;
- return columnLabelTooltips?.[col];
- }
-
- changeCollection(collection: any) {
- // Preserve existing order instead of recreating from Object.keys()
- const existingIds = new Set(
- this.state.collectionArray.map(item => item.id),
- );
- const newCollectionArray: CollectionItem[] = [];
-
- // First pass: preserve existing order and update items
- for (const existingItem of this.state.collectionArray) {
- if (collection[existingItem.id]) {
- newCollectionArray.push(collection[existingItem.id]);
+ if (onChange) {
+ onChange(newCollectionArray);
+ }
+
+ return newCollectionArray;
+ });
+
+ if (shouldStartExpanded) {
+ setExpandedColumns(prev => ({ ...prev, [newItem.id]: true }));
}
}
+ }, [itemGenerator, onChange]);
+
+ const onFieldsetChange = useCallback(
+ (item: CollectionItem) => {
+ changeCollection(
+ {
+ ...collection,
+ [item.id]: item,
+ },
+ collectionArray,
+ );
+ },
+ [changeCollection, collection, collectionArray],
+ );
- // Second pass: add new items
- for (const item of Object.values(collection) as CollectionItem[]) {
- if (!existingIds.has(item.id)) {
- newCollectionArray.push(item);
+ const getLabel = useCallback(
+ (col: string): string => {
+ let label = columnLabels?.[col] ? columnLabels[col] : col;
+ if (label.startsWith('__')) {
+ label = '';
}
- }
+ return label;
+ },
+ [columnLabels],
+ );
- this.setState({ collection, collectionArray: newCollectionArray });
+ const getTooltip = useCallback(
+ (col: string): string | undefined => columnLabelTooltips?.[col],
+ [columnLabelTooltips],
+ );
- if (this.props.onChange) {
- this.props.onChange(newCollectionArray);
- }
- }
-
- deleteItem(id: string | number) {
- const newColl = { ...this.state.collection };
- delete newColl[id];
- this.changeCollection(newColl);
- }
-
- toggleExpand(id: any) {
- this.setState(prevState => ({
- expandedColumns: {
- ...prevState.expandedColumns,
- [id]: !prevState.expandedColumns[id],
- },
+ const toggleExpand = useCallback((id: string | number) => {
+ setExpandedColumns(prev => ({
+ ...prev,
+ [id]: !prev[id],
}));
- }
-
- handleTableChange(
- _pagination: TablePaginationConfig,
- _filters: Record<string, FilterValue | null>,
- sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
- ) {
- const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
- let newSortColumn = '';
- let newSortOrder = 0;
-
- if (columnSorter?.columnKey && columnSorter?.order) {
- newSortColumn = columnSorter.columnKey as string;
- newSortOrder = columnSorter.order === 'ascend' ? 1 : 2;
- }
-
- const { sortColumns } = this.props;
- const col = newSortColumn;
+ }, []);
+
+ const handleTableChange = useCallback(
+ (
+ _pagination: TablePaginationConfig,
+ _filters: Record<string, FilterValue | null>,
+ sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
+ ) => {
+ const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
+ let newSortColumn = '';
+ let newSortOrder = SortOrderEnum.Unsorted;
+
+ if (columnSorter?.columnKey && columnSorter?.order) {
+ newSortColumn = columnSorter.columnKey as string;
+ newSortOrder =
+ columnSorter.order === 'ascend'
+ ? SortOrderEnum.Asc
+ : SortOrderEnum.Desc;
+ }
- if (sortColumns?.includes(col) || newSortOrder === 0) {
- let sortedArray = [...this.props.collection];
+ const col = newSortColumn;
+
+ if (
+ sortColumns?.includes(col) ||
+ newSortOrder === SortOrderEnum.Unsorted
+ ) {
+ let sortedArray = [...propsCollection] as CollectionItem[];
+
+ if (newSortOrder !== SortOrderEnum.Unsorted) {
+ const compareSort = (m: Sort, n: Sort) => {
+ if (typeof m === 'string' && typeof n === 'string') {
+ return (m || '').localeCompare(n || '');
+ }
+ if (typeof m === 'number' && typeof n === 'number') {
+ return m - n;
+ }
+ if (typeof m === 'boolean' && typeof n === 'boolean') {
+ return m === n ? 0 : m ? 1 : -1;
+ }
+ const mStr = String(m ?? '');
+ const nStr = String(n ?? '');
+ return mStr.localeCompare(nStr);
+ };
- if (newSortOrder !== 0) {
- const compareSort = (m: Sort, n: Sort) => {
- if (typeof m === 'string' && typeof n === 'string') {
- return (m || '').localeCompare(n || '');
- }
- if (typeof m === 'number' && typeof n === 'number') {
- return m - n;
+ sortedArray.sort((a: CollectionItem, b: CollectionItem) =>
+ compareSort(a[col] as Sort, b[col] as Sort),
+ );
+ if (newSortOrder === SortOrderEnum.Desc) {
+ sortedArray.reverse();
}
- if (typeof m === 'boolean' && typeof n === 'boolean') {
- return m === n ? 0 : m ? 1 : -1;
- }
- const mStr = String(m ?? '');
- const nStr = String(n ?? '');
- return mStr.localeCompare(nStr);
- };
-
- sortedArray.sort((a: any, b: any) => compareSort(a[col], b[col]));
- if (newSortOrder === 2) {
- sortedArray.reverse();
+ } else {
+ const { collectionArray: resetArray } =
+ createKeyedCollection(propsCollection);
+ sortedArray = resetArray;
}
- } else {
- const { collectionArray } = createKeyedCollection(
- this.props.collection,
- );
- sortedArray = collectionArray;
+
+ setCollectionArray(sortedArray);
+ setSortColumn(newSortColumn);
+ setSort(newSortOrder);
}
+ },
+ [propsCollection, sortColumns],
+ );
- this.setState({
- collectionArray: sortedArray,
- sortColumn: newSortColumn,
- sort: newSortOrder,
- });
- }
- }
-
- renderExpandableSection(item: any): ReactNode {
- const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
- return recurseReactClone(
- this.props.expandFieldset,
- Fieldset,
- propsGenerator,
- );
- }
-
- renderCell(record: any, col: any): ReactNode {
- const renderer = this.props.itemRenderers?.[col];
- const val = record[col];
- const onChange = this.onCellChange.bind(this, record.id, col);
- return renderer ? renderer(val, onChange, this.getLabel(col), record) : val;
- }
-
- buildTableColumns() {
- const { tableColumns, allowDeletes, sortColumns = [] } = this.props;
-
- const antdColumns: ColumnsType = tableColumns.map(col => {
- const label = this.getLabel(col);
- const tooltip = this.getTooltip(col);
+ const renderExpandableSection = useCallback(
+ (item: CollectionItem): ReactNode => {
+ const propsGenerator = () => ({ item, onChange: onFieldsetChange });
+ return recurseReactClone(expandFieldset, Fieldset, propsGenerator);
+ },
+ [expandFieldset, onFieldsetChange],
+ );
+
+ const renderCell = useCallback(
+ (record: CollectionItem, col: string): ReactNode => {
+ const renderer = itemRenderers?.[col];
+ const val = record[col];
+ const cellOnChange = (newVal: unknown) =>
+ onCellChange(record.id, col, newVal);
+ return renderer
+ ? renderer(val, cellOnChange, getLabel(col), record)
+ : (val as ReactNode);
+ },
+ [itemRenderers, onCellChange, getLabel],
+ );
+
+ const antdColumns = useMemo((): ColumnsType<CollectionItem> => {
+ const columns: ColumnsType<CollectionItem> = tableColumns.map(col => {
+ const label = getLabel(col);
+ const tooltip = getTooltip(col);
const isSortable = sortColumns.includes(col);
const currentSortOrder: SortOrder | null | undefined =
- this.state.sortColumn === col
- ? this.state.sort === 1
+ sortColumn === col
+ ? sort === SortOrderEnum.Asc
? 'ascend'
- : this.state.sort === 2
+ : sort === SortOrderEnum.Desc
? 'descend'
: null
: null;
@@ -343,10 +382,10 @@ export default class CRUDCollection extends PureComponent<
)}
</>
),
- render: (text: any, record: CollectionItem) =>
- this.renderCell(record, col),
+ render: (_text: unknown, record: CollectionItem) =>
+ renderCell(record, col),
onCell: (record: CollectionItem) => {
- const cellPropsFn = this.props.itemCellProps?.[col];
+ const cellPropsFn = itemCellProps?.[col];
const val = record[col];
return cellPropsFn ? cellPropsFn(val, label, record) : {};
},
@@ -356,7 +395,7 @@ export default class CRUDCollection extends PureComponent<
});
if (allowDeletes) {
- antdColumns.push({
+ columns.push({
key: '__actions',
dataIndex: '__actions',
sorter: false,
@@ -380,7 +419,7 @@ export default class CRUDCollection extends PureComponent<
data-test="crud-delete-icon"
role="button"
tabIndex={0}
- onClick={() => this.deleteItem(record.id)}
+ onClick={() => deleteItem(record.id)}
iconSize="l"
iconColor="inherit"
/>
@@ -389,74 +428,81 @@ export default class CRUDCollection extends PureComponent<
});
}
- return antdColumns as ColumnsType<CollectionItem>;
- }
-
- render() {
- const {
- stickyHeader,
- emptyMessage = t('No items'),
- expandFieldset,
- } = this.props;
-
- const tableColumns = this.buildTableColumns();
- const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
- id => this.state.expandedColumns[id],
- );
-
- const expandableConfig = expandFieldset
- ? {
- expandedRowRender: (record: CollectionItem) =>
- this.renderExpandableSection(record),
- rowExpandable: () => true,
- expandedRowKeys,
- onExpand: (expanded: boolean, record: CollectionItem) => {
- this.toggleExpand(record.id);
- },
- }
- : undefined;
-
- return (
- <>
- <CrudButtonWrapper>
- {this.props.allowAddItem && (
- <StyledButtonWrapper>
- <Button
- buttonSize="small"
- buttonStyle="secondary"
- onClick={this.onAddItem}
- data-test="add-item-button"
- >
- <Icons.PlusOutlined
- iconSize="m"
- data-test="crud-add-table-item"
- />
- {t('Add item')}
- </Button>
- </StyledButtonWrapper>
- )}
- </CrudButtonWrapper>
- <Table<CollectionItem>
- data-test="crud-table"
- columns={tableColumns}
- data={this.state.collectionArray as CollectionItem[]}
- rowKey={(record: CollectionItem) => String(record.id)}
- sticky={stickyHeader}
- pagination={false}
- onChange={this.handleTableChange}
- locale={{ emptyText: emptyMessage }}
- css={
- stickyHeader &&
- css`
- height: 350px;
- overflow: auto;
- `
+ return columns;
+ }, [
+ tableColumns,
+ getLabel,
+ getTooltip,
+ sortColumns,
+ sortColumn,
+ sort,
+ renderCell,
+ itemCellProps,
+ allowDeletes,
+ deleteItem,
+ ]);
+
+ const expandedRowKeys = useMemo(
+ () => Object.keys(expandedColumns).filter(id => expandedColumns[id]),
+ [expandedColumns],
+ );
+
+ const expandableConfig = useMemo(
+ () =>
+ expandFieldset
+ ? {
+ expandedRowRender: (record: CollectionItem) =>
+ renderExpandableSection(record),
+ rowExpandable: () => true,
+ expandedRowKeys,
+ onExpand: (_expanded: boolean, record: CollectionItem) => {
+ toggleExpand(record.id);
+ },
}
- expandable={expandableConfig}
- size={TableSize.Middle}
- tableLayout="auto"
- />
- </>
- );
- }
+ : undefined,
+ [expandFieldset, renderExpandableSection, expandedRowKeys, toggleExpand],
+ );
+
+ return (
+ <>
+ <CrudButtonWrapper>
+ {allowAddItem && (
+ <StyledButtonWrapper>
+ <Button
+ buttonSize="small"
+ buttonStyle="secondary"
+ onClick={onAddItem}
+ data-test="add-item-button"
+ >
+ <Icons.PlusOutlined
+ iconSize="m"
+ data-test="crud-add-table-item"
+ />
+ {t('Add item')}
+ </Button>
+ </StyledButtonWrapper>
+ )}
+ </CrudButtonWrapper>
+ <Table<CollectionItem>
+ data-test="crud-table"
+ columns={antdColumns}
+ data={collectionArray}
+ rowKey={(record: CollectionItem) => String(record.id)}
+ sticky={stickyHeader}
+ pagination={false}
+ onChange={handleTableChange}
+ locale={{ emptyText: emptyMessage }}
+ css={
+ stickyHeader &&
+ css`
+ height: 350px;
+ overflow: auto;
+ `
+ }
+ expandable={expandableConfig}
+ size={TableSize.Middle}
+ tableLayout="auto"
+ />
+ </>
+ );
}
diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
index fb0c1f597fc..542242584d5 100644
--- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
+++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
@@ -17,10 +17,16 @@
* under the License.
*/
import rison from 'rison';
-import { PureComponent, useCallback, ReactNode } from 'react';
+import {
+ useCallback,
+ ReactNode,
+ useState,
+ useEffect,
+ useRef,
+ useMemo,
+} from 'react';
import { connect, ConnectedProps } from 'react-redux';
import type { JsonObject } from '@superset-ui/core';
-import type { SupersetTheme } from '@apache-superset/core/ui';
import type { AnyAction } from 'redux';
import type { ThunkDispatch } from 'redux-thunk';
import { Radio } from '@superset-ui/core/components/Radio';
@@ -37,7 +43,7 @@ import {
styled,
themeObject,
Alert,
- withTheme,
+ useTheme,
t,
} from '@apache-superset/core/ui';
import Tabs from '@superset-ui/core/components/Tabs';
@@ -46,7 +52,6 @@ import TableSelector from 'src/components/TableSelector';
import CheckboxControl from 'src/explore/components/controls/CheckboxControl';
import TextControl from 'src/explore/components/controls/TextControl';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
-import SpatialControl from 'src/explore/components/controls/SpatialControl';
import withToasts from 'src/components/MessageToasts/withToasts';
import CurrencyControl from 'src/explore/components/controls/CurrencyControl';
import {
@@ -192,7 +197,6 @@ interface DatasourceEditorOwnProps {
addDangerToast: (msg: string) => void;
setIsEditing?: (isEditing: boolean) => void;
currencies?: string[];
- theme?: SupersetTheme;
}
interface QueryResultColumn {
@@ -250,21 +254,6 @@ interface ChartUsageData {
}>;
}
-interface DatasourceEditorState {
- datasource: DatasourceObject;
- errors: string[];
- isSqla: boolean;
- isEditMode: boolean;
- databaseColumns: Column[];
- calculatedColumns: Column[];
- folders: DatasourceFolder[];
- metadataLoading: boolean;
- activeTabKey: string;
- datasourceType: string;
- usageCharts: ChartUsageData[];
- usageChartsCount: number;
-}
-
interface AbortControllers {
formatQuery: AbortController | null;
formatSql: AbortController | null;
@@ -824,189 +813,284 @@ const mapStateToProps = (state: RootState) => ({
const connector = connect(mapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
-type DatasourceEditorProps = DatasourceEditorOwnProps &
- PropsFromRedux & {
- theme?: SupersetTheme;
- };
+type DatasourceEditorProps = DatasourceEditorOwnProps & PropsFromRedux;
+
+function DatasourceEditor({
+ datasource: propsDatasource,
+ onChange = () => {},
+ addSuccessToast,
+ addDangerToast,
+ setIsEditing = () => {},
+ database,
+ runQuery,
+ resetQuery,
+ formatQuery: formatQueryAction,
+}: DatasourceEditorProps) {
+ const theme = useTheme();
+ const isComponentMounted = useRef(false);
+ const abortControllers = useRef<AbortControllers>({
+ formatQuery: null,
+ formatSql: null,
+ syncMetadata: null,
+ fetchUsageData: null,
+ });
+
+ // Initialize datasource state with transformed owners and metrics
+ const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
+ ...propsDatasource,
+ owners: propsDatasource.owners.map(owner => ({
+ value: owner.value || owner.id,
+ label: owner.label || `${owner.first_name} ${owner.last_name}`,
+ })),
+ metrics: propsDatasource.metrics?.map(metric => {
+ const {
+ certified_by: certifiedByMetric,
+ certification_details: certificationDetails,
+ } = metric;
+ const {
+ certification: {
+ details = undefined,
+ certified_by: certifiedBy = undefined,
+ } = {},
+ warning_markdown: warningMarkdown,
+ } = JSON.parse(metric.extra || '{}') || {};
+ return {
+ ...metric,
+ certification_details: certificationDetails || details,
+ warning_markdown: warningMarkdown || '',
+ certified_by: certifiedBy || certifiedByMetric,
+ };
+ }),
+ }));
-class DatasourceEditor extends PureComponent<
- DatasourceEditorProps,
- DatasourceEditorState
-> {
- private isComponentMounted: boolean;
+ const [errors, setErrors] = useState<string[]>([]);
+ const [isSqla] = useState(
+ propsDatasource.datasource_type === 'table' ||
+ propsDatasource.type === 'table',
+ );
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [databaseColumns, setDatabaseColumns] = useState<Column[]>(
+ propsDatasource.columns.filter(col => !col.expression),
+ );
+ const [calculatedColumns, setCalculatedColumns] = useState<Column[]>(
+ propsDatasource.columns.filter(col => !!col.expression),
+ );
+ const [folders, setFolders] = useState<DatasourceFolder[]>(
+ propsDatasource.folders || [],
+ );
+ const [metadataLoading, setMetadataLoading] = useState(false);
+ const [activeTabKey, setActiveTabKey] = useState(TABS_KEYS.SOURCE);
+ const [datasourceType, setDatasourceType] = useState(
+ propsDatasource.sql
+ ? DATASOURCE_TYPES.virtual.key
+ : DATASOURCE_TYPES.physical.key,
+ );
+ const [usageCharts, setUsageCharts] = useState<ChartUsageData[]>([]);
+ const [usageChartsCount, setUsageChartsCount] = useState(0);
+
+ const findDuplicates = useCallback(
+ <T,>(arr: T[], accessor: (obj: T) => string): string[] => {
+ const seen: Record<string, null> = {};
+ const dups: string[] = [];
+ arr.forEach((obj: T) => {
+ const item = accessor(obj);
+ if (item in seen) {
+ dups.push(item);
+ } else {
+ seen[item] = null;
+ }
+ });
+ return dups;
+ },
+ [],
+ );
- private abortControllers: AbortControllers;
+ const validate = useCallback(
+ (callback: () => void) => {
+ let validationErrors: string[] = [];
+ let dups: string[];
- static defaultProps = {
- onChange: () => {},
- setIsEditing: () => {},
- };
+ // Looking for duplicate column_name
+ dups = findDuplicates(datasource.columns, obj => obj.column_name);
+ validationErrors = validationErrors.concat(
+ dups.map(name => t('Column name [%s] is duplicated', name)),
+ );
- constructor(props: DatasourceEditorProps) {
- super(props);
- this.state = {
... 17179 lines suppressed ...