diff --git a/superset-frontend/.storybook/shared/Expandable.tsx b/superset-frontend/.storybook/shared/Expandable.tsx index 37b177f325b..cea87063bd7 100644 --- a/superset-frontend/.storybook/shared/Expandable.tsx +++ b/superset-frontend/.storybook/shared/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 function Expandable({ children, expandableWhat }: Props) { + const [open, setOpen] = useState(false); -export default class Expandable extends Component { - constructor(props: Props) { - super(props); - this.state = { open: false }; - this.handleToggle = this.handleToggle.bind(this); - } + const handleToggle = useCallback(() => { + setOpen(prevOpen => !prevOpen); + }, []); - handleToggle() { - this.setState(({ open }) => ({ open: !open })); - } - - render() { - const { open } = this.state; - const { children, expandableWhat } = this.props; - - return ( -
- -
-
- {open ? children : null} -
- ); - } + return ( +
+ +
+
+ {open ? children : null} +
+ ); } diff --git a/superset-frontend/.storybook/shared/VerifyCORS.tsx b/superset-frontend/.storybook/shared/VerifyCORS.tsx index bbc3e115bf6..1803439bbb5 100644 --- a/superset-frontend/.storybook/shared/VerifyCORS.tsx +++ b/superset-frontend/.storybook/shared/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) => (
The following error occurred, make sure you have
@@ -54,29 +48,37 @@ export const renderError = (error: Error) => (
); -export default class VerifyCORS extends Component { - 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( + undefined, + ); + const [payload, setPayload] = useState(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 { } 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 }) - ) : ( -
-
- This example requires CORS requests from this domain.
-
- 1) enable CORS requests in your Superset App from{' '} - {`${window.location.origin}`} -
- 2) configure your Superset App host name below
- 3) click below to verify authentication. You may debug CORS further - using the `@superset-ui/connection` story.
-
- -
-
-
- - {error && ( -
- -
- )} + return didVerify ? ( + <>{children({ payload })} + ) : ( +
+
+ This example requires CORS requests from this domain.
+
+ 1) enable CORS requests in your Superset App from{' '} + {`${window.location.origin}`} +
+ 2) configure your Superset App host name below
+ 3) click below to verify authentication. You may debug CORS further + using the `@superset-ui/connection` story.
+
+ +
+
- ); - } + + {error && ( +
+ +
+ )} +
+ ); } 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 { - 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 ( -
- {renderContent({ - height: Math.max(contentHeight ?? 0, height), - width: Math.max(contentWidth ?? 0, width), - })} -
- ); - } - - return renderContent({ height, width }); + if (overflowX || overflowY) { + return ( +
+ {renderContent({ + height: Math.max(contentHeight ?? 0, height), + width: Math.max(contentWidth ?? 0, width), + })} +
+ ); } + + 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; +}; const LEGEND_STYLE_BASE: CSSProperties = { display: 'flex', @@ -52,95 +45,101 @@ const CHART_STYLE_BASE: CSSProperties = { position: 'relative', }; -class WithLegend extends PureComponent { - 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 'flex-end'; - } - - render() { - const { - className, - debounceTime, - width, - height, - position, - renderChart, - renderLegend, - } = this.props; - - const isHorizontal = position === 'left' || position === 'right'; - - const style: CSSProperties = { - display: 'flex', - flexDirection: this.getContainerDirection(), - height, - width, - }; - - const chartStyle: CSSProperties = { ...CHART_STYLE_BASE }; - if (isHorizontal) { - chartStyle.width = 0; - } else { - chartStyle.height = 0; - } - - const legendDirection = isHorizontal ? 'column' : 'row'; - const legendStyle: CSSProperties = { - ...LEGEND_STYLE_BASE, - flexDirection: legendDirection, - justifyContent: this.getLegendJustifyContent(), - }; - - return ( -
- {renderLegend && ( -
- {renderLegend({ - // Pass flexDirection for @vx/legend to arrange legend items - direction: legendDirection, - })} -
- )} -
- - {(parent: { width: number; height: number }) => - parent.width > 0 && parent.height > 0 - ? // Only render when necessary - renderChart(parent) - : null - } - -
-
- ); - } + return 'column'; } -export default WithLegend; +function getLegendJustifyContent( + legendJustifyContent: Props['legendJustifyContent'], + position: Props['position'], +) { + if (legendJustifyContent) { + return legendJustifyContent; + } + + if (position === 'left' || position === 'right') { + return 'flex-start'; + } + + return 'flex-end'; +} + +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: getContainerDirection(position), + height, + width, + }), + [position, height, width], + ); + + const chartStyle: CSSProperties = useMemo(() => { + const baseStyle = { ...CHART_STYLE_BASE }; + if (isHorizontal) { + baseStyle.width = 0; + } else { + baseStyle.height = 0; + } + return baseStyle; + }, [isHorizontal]); + + const legendDirection = isHorizontal ? 'column' : 'row'; + const legendStyle: CSSProperties = useMemo( + () => ({ + ...LEGEND_STYLE_BASE, + flexDirection: legendDirection, + justifyContent: getLegendJustifyContent(legendJustifyContent, position), + }), + [legendDirection, legendJustifyContent, position], + ); + + return ( +
+ {renderLegend && ( +
+ {renderLegend({ + // Pass flexDirection for @vx/legend to arrange legend items + direction: legendDirection, + })} +
+ )} +
+ + {(parent: { width: number; height: number }) => + parent.width > 0 && parent.height > 0 + ? // Only render when necessary + renderChart(parent) + : null + } + +
+
+ ); +} + +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; +}; const CONTAINER_STYLE = { padding: 8 }; -class TooltipFrame extends PureComponent { - static defaultProps = defaultProps; - - render() { - const { className, children } = this.props; - - return ( -
- {children} -
- ); - } +function TooltipFrame({ className = '', children }: Props) { + return ( +
+ {children} +
+ ); } -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; +function ChartDataProvider({ + children, + client, + formData, + sliceId, + loadDatasource, + onError, + onLoaded, + formDataRequestOptions, + datasourceRequestOptions, + queryRequestOptions, +}: ChartDataProviderProps) { + const [state, setState] = useState({ + status: 'uninitialized', + }); - constructor(props: ChartDataProviderProps) { - super(props); - this.state = { status: 'uninitialized' }; - this.chartClient = new ChartClient({ client: props.client }); - } + const chartClient = useMemo(() => new ChartClient({ client }), [client]); - componentDidMount() { - this.handleFetchData(); - } + const extractSliceIdAndFormData = useCallback( + (): SliceIdAndOrFormData => + formData ? { formData } : { sliceId: sliceId as number }, + [formData, sliceId], + ); - componentDidUpdate(prevProps: ChartDataProviderProps) { - const { formData, sliceId } = this.props; - if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) { - this.handleFetchData(); + 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, + ]); - private extractSliceIdAndFormData() { - const { formData, sliceId } = this.props; - return formData ? { formData } : { sliceId: sliceId as number }; - } + // Fetch data on mount and when formData or sliceId changes + useEffect(() => { + handleFetchData(); + }, [formData, sliceId, handleFetchData]); - private handleFetchData = () => { - const { - loadDatasource, - formDataRequestOptions, - datasourceRequestOptions, - queryRequestOptions, - } = this.props; + const { status, payload, error } = state; - 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; - } + 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; @@ -102,215 +101,261 @@ export type Props = Omit & inContextMenu?: boolean; }; -type PropsWithDefault = Props & Readonly; - -class SuperChart extends PureComponent { +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(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 }) => ( -
{children}
- ); + 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 }) => ( +
{children}
+ ); + + 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 { + // Check if Matrixify is enabled - use rawFormData (snake_case) + const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData); + + if (matrixifyEnabled) { + // When matrixify is enabled, queriesData is expected to be empty + // since each cell fetches its own data via StatefulChart + const matrixifyChart = ( + + ); + + // Apply wrapper if provided + const wrappedChart = Wrapper ? ( + + {matrixifyChart} + + ) : ( + matrixifyChart + ); + + // Include error boundary unless disabled + return disableErrorBoundary === true ? ( + wrappedChart + ) : ( + ( + + )} + onError={onErrorBoundary} + > + {wrappedChart} + + ); + } + + // 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), + )); + + let chart: JSX.Element; + if (noResultQueries) { + chart = noResults ? ( + <>{noResults} + ) : ( + + ); + } else { + const chartWithoutWrapper = ( + + ); + chart = Wrapper ? ( + + {chartWithoutWrapper} + + ) : ( + chartWithoutWrapper + ); + } + // Include the error boundary by default unless it is specifically disabled. + return disableErrorBoundary === true ? ( + chart + ) : ( + ( + + )} + onError={onErrorBoundary} + > + {chart} + + ); + }, + [ + createChartProps, + rest, + queriesData, + theme, + Wrapper, + disableErrorBoundary, + FallbackComponent, + onErrorBoundary, + enableNoResults, + getQueryCount, + noResults, id, className, + setRef, chartType, preTransformProps, overrideTransformProps, postTransformProps, onRenderSuccess, onRenderFailure, - disableErrorBoundary, - FallbackComponent, - onErrorBoundary, - Wrapper, - queriesData, - enableNoResults, - noResults, - theme, - ...rest - } = this.props as PropsWithDefault; + ], + ); - const chartProps = this.createChartProps({ - ...rest, - queriesData, - height, - width, - theme, - }); + const { heightInfo, widthInfo, BoundingBox } = parseDimension({ + width, + height, + }); - // Check if Matrixify is enabled - use rawFormData (snake_case) - const matrixifyEnabled = isMatrixifyEnabled(chartProps.rawFormData); - - if (matrixifyEnabled) { - // When matrixify is enabled, queriesData is expected to be empty - // since each cell fetches its own data via StatefulChart - const matrixifyChart = ( - - ); - - // Apply wrapper if provided - const wrappedChart = Wrapper ? ( - - {matrixifyChart} - - ) : ( - matrixifyChart - ); - - // Include error boundary unless disabled - return disableErrorBoundary === true ? ( - wrappedChart - ) : ( - ( - - )} - onError={onErrorBoundary} - > - {wrappedChart} - - ); - } - - // 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), - )); - - let chart; - if (noResultQueries) { - chart = noResults || ( - - ); - } else { - const chartWithoutWrapper = ( - - ); - chart = Wrapper ? ( - - {chartWithoutWrapper} - - ) : ( - chartWithoutWrapper - ); - } - // Include the error boundary by default unless it is specifically disabled. - return disableErrorBoundary === true ? ( - chart - ) : ( - ( - - )} - onError={onErrorBoundary} - > - {chart} - + // If any of the dimension is dynamic, get parent's dimension + if (widthInfo.isDynamic || heightInfo.isDynamic) { + return ( + + + {({ width: parentWidth, height: parentHeight }) => + renderChart( + widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value, + heightInfo.isDynamic + ? Math.floor(parentHeight) + : heightInfo.value, + ) + } + + ); } - 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 ( - - - {({ width, height }) => - this.renderChart( - widthInfo.isDynamic ? Math.floor(width) : widthInfo.value, - heightInfo.isDynamic ? Math.floor(height) : heightInfo.value, - ) - } - - - ); - } - - 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(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 { - /** - * 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: { +const SuperChartCore = forwardRef( + function SuperChartCore( + { + id = '', + className = '', + chartProps = BLANK_CHART_PROPS, + chartType, + preTransformProps = IDENTITY, + overrideTransformProps, + postTransformProps = IDENTITY, + onRenderSuccess = () => {}, + onRenderFailure = () => {}, + }, + ref, + ) { + const containerRef = useRef(null); + + // 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 + * - preTransformProps + * - chartProps + * is changed. + */ + const preSelector = useMemo( + () => + createSelector( + [ + (input: { + chartProps: ChartProps; + preTransformProps?: PreTransformProps; + }) => input.chartProps, + input => input.preTransformProps, + ], + (inputChartProps, pre = IDENTITY) => pre(inputChartProps), + ), + [], + ); + + /** + * 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 the input arguments have changed. + */ + const postSelector = useMemo( + () => + createSelector( + [ + (input: { + chartProps: ChartProps; + postTransformProps?: PostTransformProps; + }) => input.chartProps, + input => input.postTransformProps, + ], + (transformedChartProps, post = IDENTITY) => + post(transformedChartProps), + ), + [], + ); + + /** + * Using each memoized function to retrieve the computed chartProps + */ + const processChartProps = useCallback( + ({ + chartProps: inputChartProps, + preTransformProps: pre, + transformProps, + postTransformProps: post, + }: { chartProps: ChartProps; preTransformProps?: PreTransformProps; - }) => input.chartProps, - input => input.preTransformProps, - ], - (chartProps, pre = IDENTITY) => pre(chartProps), - ); - - /** - * 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), - ); - - /** - * memoized function so it will not recompute and return previous value - * unless one of the input arguments have changed. - */ - postSelector = createSelector( - [ - (input: { - chartProps: ChartProps; + transformProps?: TransformProps; postTransformProps?: PostTransformProps; - }) => input.chartProps, - input => input.postTransformProps, - ], - (transformedChartProps, post = IDENTITY) => post(transformedChartProps), - ); - - /** - * 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 - * - 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, - }); - - // Trigger preloading. - Renderer.preload(); - - return Renderer; - } - - return EMPTY; - }, - ); - - static defaultProps = defaultProps; - - private renderChart = (loaded: LoadedModules, props: RenderProps) => { - const { Chart, transformProps } = loaded; - const { chartProps, preTransformProps, postTransformProps } = props; - - return ( - + }) => + postSelector({ + chartProps: transformSelector({ + chartProps: preSelector({ + chartProps: inputChartProps, + preTransformProps: pre, + }), + transformProps, + }), + postTransformProps: post, + }), + [preSelector, transformSelector, postSelector], ); - }; - private renderLoading = (loadingProps: LoadingProps, chartType: string) => { - const { error } = loadingProps; + const renderLoading = useCallback( + (loadingProps: LoadingProps, loadingChartType: string) => { + const { error } = loadingProps; - if (error) { - return ( -
- {t('ERROR')}  - chartType="{chartType}" — - {error.toString()} -
- ); - } + if (error) { + return ( +
+ {t('ERROR')}  + chartType="{loadingChartType}" — + {error.toString()} +
+ ); + } - return null; - }; + return null; + }, + [], + ); - private setRef = (container: HTMLElement | null) => { - this.container = container; - }; + const renderChart = useCallback( + (loaded: LoadedModules, props: RenderProps) => { + const { Chart, transformProps } = loaded; + const { + chartProps: renderChartProps, + preTransformProps: pre, + postTransformProps: post, + } = props; - render() { - const { - id, - className, - preTransformProps, - postTransformProps, - chartProps = BLANK_CHART_PROPS, - onRenderSuccess, - onRenderFailure, - } = this.props; + return ( + + ); + }, + [processChartProps], + ); + + /** + * 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, + }); + + // Trigger preloading. + Renderer.preload(); + + return Renderer; + } + + return EMPTY; + }, + ), + [renderLoading, renderChart], + ); + + 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 { } return ( -
+
{ />
); - } -} + }, +); + +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 { propTypes?: WeakValidationMap; } +export interface ReactifiedComponentRef { + container?: HTMLDivElement; +} + +type ReactifiedComponent = ForwardRefExoticComponent< + PropsWithoutRef & RefAttributes +> & { + defaultProps?: Partial; + propTypes?: WeakValidationMap; +}; + export default function reactify( renderFn: RenderFuncType, callbacks?: LifeCycleCallbacks, -): ComponentClass { - class ReactifiedComponent extends Component { - container?: HTMLDivElement; +): ReactifiedComponent { + const ReactifiedComponent = forwardRef< + ReactifiedComponentRef, + Props & ReactifyProps + >(function ReactifiedComponent(props, ref) { + const containerRef = useRef(null); - constructor(props: Props & ReactifyProps) { - super(props); - this.setContainerRef = this.setContainerRef.bind(this); - } + // Expose container via ref for external access + useImperativeHandle( + ref, + () => ({ + get container() { + return containerRef.current ?? undefined; + }, + }), + [], + ); - componentDidMount() { - this.execute(); - } - - componentDidUpdate() { - this.execute(); - } - - componentWillUnmount() { - this.container = undefined; - if (callbacks?.componentWillUnmount) { - callbacks.componentWillUnmount.bind(this)(); + // 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
; - } - } - - const ReactifiedClass: ComponentClass = - ReactifiedComponent; + return
; + }); if (renderFn.displayName) { - ReactifiedClass.displayName = renderFn.displayName; + ReactifiedComponent.displayName = renderFn.displayName; } - // eslint-disable-next-line react/forbid-foreign-prop-types + + // 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.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; } diff --git a/superset-frontend/packages/superset-ui-core/src/number-format/stories/NumberFormat.stories.tsx b/superset-frontend/packages/superset-ui-core/src/number-format/stories/NumberFormat.stories.tsx index 718a1f17cfb..f473a8f8df2 100644 --- a/superset-frontend/packages/superset-ui-core/src/number-format/stories/NumberFormat.stories.tsx +++ b/superset-frontend/packages/superset-ui-core/src/number-format/stories/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)[]; -} +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, +]; -class NumberFormatValidator extends PureComponent< - Record, - 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, - ], - }; +function NumberFormatValidator() { + const [formatString, setFormatString] = useState('.3~s'); - constructor(props: Record) { - super(props); + const handleFormatChange = useCallback( + (event: React.ChangeEvent) => { + setFormatString(event.target.value); + }, + [], + ); - this.handleFormatChange = this.handleFormatChange.bind(this); - } - - handleFormatChange(event: React.ChangeEvent) { - this.setState({ - formatString: event.target.value, - }); - } - - render() { - const { formatString, testValues } = this.state; - - return ( -
-
-
-

- This @superset-ui/number-format package enriches{' '} - d3-format - to handle invalid formats as well as edge case values. Use the - validator below to preview outputs from the specified format - string. See - - D3 Format Reference - - for how to write a D3 format string. -

-
-
-
-
-
-
-
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
-
-
-
-
-
-
- - - - - - - - - {testValues.map((v, index) => ( - - - - - ))} - -
Input (number)Formatted output (string)
- {`${v}`} - - "{formatNumber(formatString, v)}" -
-
+ return ( +
+
+
+

+ This @superset-ui/number-format package enriches{' '} + d3-format + to handle invalid formats as well as edge case values. Use the + validator below to preview outputs from the specified format string. + See + + D3 Format Reference + + for how to write a D3 format string. +

- ); - } +
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+
+
+
+
+
+
+ + + + + + + + + {testValues.map((v, index) => ( + + + + + ))} + +
Input (number)Formatted output (string)
+ {`${v}`} + + "{formatNumber(formatString, v)}" +
+
+
+
+ ); } export default { diff --git a/superset-frontend/packages/superset-ui-core/src/time-format/stories/TimeFormat.stories.tsx b/superset-frontend/packages/superset-ui-core/src/time-format/stories/TimeFormat.stories.tsx index 2798113b587..34ee21dee46 100644 --- a/superset-frontend/packages/superset-ui-core/src/time-format/stories/TimeFormat.stories.tsx +++ b/superset-frontend/packages/superset-ui-core/src/time-format/stories/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)[]; -} +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, +]; -class TimeFormatValidator extends PureComponent< - Record, - 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, - ], - }; +function TimeFormatValidator() { + const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S'); - constructor(props: Record) { - super(props); - this.handleFormatChange = this.handleFormatChange.bind(this); - } + const handleFormatChange = useCallback( + (event: React.ChangeEvent) => { + setFormatString(event.target.value); + }, + [], + ); - handleFormatChange(event: React.ChangeEvent) { - this.setState({ - formatString: event.target.value, - }); - } - - render() { - const { formatString, testValues } = this.state; - - return ( -
-
-
-

- This @superset-ui/time-format package enriches - d3-time-format to handle invalid formats as well as - edge case values. Use the validator below to preview outputs from - the specified format string. See   - - D3 Time Format Reference - -  for how to write a D3 time format string. -

-
-
-
-
-
-
-
- {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} - -
-
-
-
-
-
-
- - - - - - - - - {testValues.map((v, index) => ( - - - - - ))} - -
Input (time)Formatted output (string)
- - {v instanceof Date ? v.toUTCString() : `${v}`} - - - "{formatTime(formatString, v)}" -
-
+ return ( +
+
+
+

+ This @superset-ui/time-format package enriches + d3-time-format to handle invalid formats as well as + edge case values. Use the validator below to preview outputs from + the specified format string. See   + + D3 Time Format Reference + +  for how to write a D3 time format string. +

- ); - } +
+
+
+
+
+ {/* eslint-disable-next-line jsx-a11y/label-has-associated-control */} + +
+
+
+
+
+
+
+ + + + + + + + + {testValues.map((v, index) => ( + + + + + ))} + +
Input (time)Formatted output (string)
+ {v instanceof Date ? v.toUTCString() : `${v}`} + + "{formatTime(formatString, v)}" +
+
+
+
+ ); } export default { 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( + 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 ; - } + return ; } - class AnotherTestComponent extends PureComponent<{}, {}> { - render() { - return ; - } + function AnotherTestComponent() { + return ; } - test('returns a React component class', () => - new Promise(done => { - render(); + test('returns a React component and re-renders on prop changes', async () => { + render(); - 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); - })); + expect(renderFn).toHaveBeenCalledTimes(1); + expect(screen.getByText('abc')).toBeInTheDocument(); + expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test'); + + 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(); + expect(anotherRenderFn).toHaveBeenCalled(); + unmount(); + }); + test('calls willUnmount hook when it is provided', async () => { + const { unmount } = render(); + unmount(); + expect(willUnmountCb).toHaveBeenCalledTimes(1); }); - test('calls willUnmount hook when it is provided', () => - new Promise(done => { - const { unmount } = render(); - setTimeout(() => { - unmount(); - expect(willUnmountCb).toHaveBeenCalledTimes(1); - done(undefined); - }, 20); - })); }); 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 = { - 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 { - 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( (acc, current) => acc.concat(current.values), @@ -106,35 +88,36 @@ class HorizonChart extends PureComponent { 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 ( - -
- {data.map(row => ( - - ))} -
-
- ); - } + return ( + +
+ {data.map(row => ( + + ))} +
+
+ ); } -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 = { - className: '', - width: 800, - height: 20, - bands: DEFAULT_COLORS.length >> 1, - colors: DEFAULT_COLORS, - colorScale: 'series', - mode: 'offset', - offsetX: 0, - title: '', - yDomain: undefined, -}; +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(null); -class HorizonRow extends PureComponent { - static defaultProps = defaultProps; + const drawChart = useCallback(() => { + const canvas = canvasRef.current; + if (!canvas) return; - private canvas: HTMLCanvasElement | null = null; + const data = + colorScale === 'change' + ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y })) + : rawData; - componentDidMount() { - this.drawChart(); - } + 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); - componentDidUpdate() { - this.drawChart(); - } + 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), + ); - componentWillUnmount() { - this.canvas = null; - } + // skip drawing if there's no data to be drawn + if (startIndex > data.length) { + return; + } - 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; + // 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]); - const data = - colorScale === 'change' - ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y })) - : rawData; + // 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]; - 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); + // Adjust the range based on the current band index. + bExtents = (b + 1 - bands) * height; + y.range([bands * height + bExtents, bExtents]); - 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), - ); + // 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)!, + ); + } + } + } - // skip drawing if there's no data to be drawn - if (startIndex > data.length) { - return; + // 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)!, + ); } } } - } + }, [ + rawData, + yDomain, + width, + height, + bands, + colors, + colorScale, + offsetX, + mode, + ]); - render() { - const { className, title, width, height } = this.props; + useEffect(() => { + drawChart(); + }, [drawChart]); - return ( -
- {title} - { - this.canvas = c; - }} - width={width} - height={height} - /> -
- ); - } + return ( +
+ {title} + +
+ ); } -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 = { - width: 400, - height: 400, - globalOpacity: 1, - onViewportChange: NOOP, - pointRadius: DEFAULT_POINT_RADIUS, - pointRadiusUnit: 'Pixels', -}; - -class MapBox extends Component { - 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 { ({ latitude, longitude, zoom } = mercator); } - this.state = { - viewport: { - longitude, - latitude, - zoom, - }, - }; - this.handleViewportChange = this.handleViewportChange.bind(this); - } + return { longitude, latitude, zoom }; + }, []); // Only compute once on mount - bounds don't update as we pan/zoom - handleViewportChange(viewport: Viewport) { - this.setState({ viewport }); - const { onViewportChange } = this.props; - onViewportChange!(viewport); - } + const [viewport, setViewport] = useState(initialViewport); - 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; + const handleViewportChange = useCallback( + (newViewport: Viewport) => { + setViewport(newViewport); + onViewportChange(newViewport); + }, + [onViewportChange], + ); - // 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; + const isDragging = + viewport.isDragging === undefined ? false : viewport.isDragging; - // 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 + // 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; - const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom)); + // 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 - return ( - { + const { coordinates } = location.geometry; + return [coordinates[0], coordinates[1]] as [number, number]; + }, []); + + return ( + + - { - const { coordinates } = location.geometry; - - return [coordinates[0], coordinates[1]]; - }} - /> - - ); - } + isDragging={isDragging} + locations={clusters} + dotRadius={pointRadius} + pointRadiusUnit={pointRadiusUnit} + rgb={rgb} + globalOpacity={globalOpacity} + compositeOperation="screen" + renderWhileDragging={renderWhileDragging} + aggregation={hasCustomMetric ? aggregatorName : undefined} + lngLatAccessor={lngLatAccessor} + /> + + ); } -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 e5220a0d538..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 = { - // 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, @@ -101,65 +94,293 @@ const computeClusterLabel = ( return count; }; -class ScatterPlotGlowOverlay extends PureComponent { - static defaultProps = defaultProps; +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, + ); - constructor(props: ScatterPlotGlowOverlayProps) { - super(props); - this.redraw = this.redraw.bind(this); - } + 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' : ''; + } - 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, - ); + const textWidth = ctx.measureText(String(label)).width; + if (textWidth > maxWidth) { + const scale = fontHeight / textWidth; + ctx.font = `${scale * maxWidth}px sans-serif`; + } - 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' : ''; - } + ctx.fillText(String(label), pixel[0], pixel[1]); + ctx.globalCompositeOperation = (compositeOperation ?? + 'source-over') as GlobalCompositeOperation; + ctx.shadowBlur = 0; + ctx.shadowColor = ''; + }, + [compositeOperation], + ); - const textWidth = ctx.measureText(String(label)).width; - if (textWidth > maxWidth) { - const scale = fontHeight / textWidth; - ctx.font = `${scale * maxWidth}px sans-serif`; - } + const redraw = useCallback( + ({ width, height, ctx, isDragging, project }: RedrawParams) => { + const radius = dotRadius ?? 4; + const clusterLabelMap: (number | string)[] = []; - const { compositeOperation } = this.props; + locations.forEach((location, i) => { + if (location.properties.cluster) { + clusterLabelMap[i] = computeClusterLabel( + location.properties, + aggregation, + ); + } + }); - ctx.fillText(String(label), pixel[0], pixel[1]); - ctx.globalCompositeOperation = (compositeOperation ?? - 'source-over') as GlobalCompositeOperation; - ctx.shadowBlur = 0; - ctx.shadowColor = ''; - } + 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; - // Modified: https://github.com/uber/react-map-gl/blob/master/overlays/scatterplot.react.js - redraw({ width, height, ctx, isDragging, project }: RedrawParams) { - const { + // 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.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 { + // 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; + } + + 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, + }); + } + } + } + }); + } + }, + [ aggregation, compositeOperation, dotRadius, + drawText, globalOpacity, lngLatAccessor, locations, @@ -167,234 +388,10 @@ class ScatterPlotGlowOverlay extends PureComponent 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, - ); - } - }); - - 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); - } - } - }); - } - - 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) { - 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`; - } - this.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 { - // 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; - } - - 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) { - this.drawText(ctx, pixelRounded, { - fontHeight: roundDecimal(pointRadius, 1), - label: pointLabel, - radius: pointRadius, - rgb, - shadow: false, - }); - } - } - } - }, this); - } - } - - render() { - return ; - } + return ; } -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; 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 { - static defaultProps = defaultProps; - - render() { - const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } = - this.props; - - return ( - -
-
-
- {metrics.map((metric, i) => ( - - ))} -
+function PairedTTest({ + alpha = 0.05, + className = '', + data, + groups, + liftValPrec = 4, + metrics, + pValPrec = 6, +}: PairedTTestProps) { + return ( + +
+
+
+ {metrics.map((metric, i) => ( + + ))}
- - ); - } +
+
+ ); } 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 95922fd32de..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)[]; -} +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)[]>([]); -const defaultProps = { - alpha: 0.05, - liftValPrec: 4, - pValPrec: 6, -}; + 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; + }); -class TTestTable extends Component { - static defaultProps = defaultProps; + return (((sumValues - sumControl) / sumControl) * 100).toFixed( + liftValPrec, + ); + }, + [liftValPrec], + ); - constructor(props: TTestTableProps) { - super(props); - this.state = { - control: 0, - liftValues: [], - pValues: [], - }; - } - - componentDidMount() { - const { control } = this.state; - this.computeTTest(control); // initially populate table - } - - 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 - } - - return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false - } - - 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'; - } - - return ''; // p-values won't normally be colored - } - - 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'; - } - - // p-values significant below set threshold - return Number(pValues[row]) <= alpha; - } - - 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; - }); - - return (((sumValues - sumControl) / sumControl) * 100).toFixed(liftValPrec); - } - - 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 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; } - }); - 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], + ); - 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)); + 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; } - } - this.setState({ control, liftValues, pValues }); + 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], + ); + + // Initially populate table on mount + useEffect(() => { + computeTTest(control); + }, [computeTTest, control]); + + 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 Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on false + }, + [control, liftValues], + ); + + const getPValueStatus = useCallback( + (row: number): string => { + if (row === control) { + return 'control'; + } + const pVal = pValues[row]; + if (Number.isNaN(pVal) || !Number.isFinite(pVal)) { + return 'invalid'; + } + + return ''; // p-values won't normally be colored + }, + [control, pValues], + ); + + const getSignificance = useCallback( + (row: number): string | boolean => { + // Color significant as green, else red + if (row === control) { + return 'control'; + } + + // p-values significant below set threshold + return Number(pValues[row]) <= alpha; + }, + [control, pValues, alpha], + ); + + const handleRowClick = useCallback( + (rowIndex: number) => { + computeTTest(rowIndex); + }, + [computeTTest], + ); + + 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; + // Render column header for each group + const columns = groups.map((group, i) => ( + + {group} + + )); + const numGroups = groups.length; + // Columns for p-value, lift-value, and significance (true/false) + columns.push( + + p-value + , + ); + columns.push( + + Lift % + , + ); + columns.push( + + Significant + , + ); - if (!Array.isArray(groups) || groups.length === 0) { - throw new Error('Group by param is required'); - } - - // Render column header for each group - const columns = groups.map((group, i) => ( - - {group} - - )); - const numGroups = groups.length; - // Columns for p-value, lift-value, and significance (true/false) - columns.push( - - p-value - , + const rows = data.map((entry, i) => { + const values = groups.map( + ( + group, + j, // group names + ) => , ); - columns.push( - - Lift % - , + values.push( + , ); - columns.push( - - Significant - , + values.push( + , + ); + values.push( + , ); - const rows = data.map((entry, i) => { - const values = groups.map( - ( - group, - j, // group names - ) => , - ); - values.push( - , - ); - values.push( - , - ); - values.push( - , - ); - - return ( - - {values} - - ); - }); - // 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 a > b ? 1 : -1; // p-values ascending - }, - }, - { - 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 - }, - }, - { - 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 ( -
-

{metric}

- - {columns} - {rows} -
-
+ handleRowClick(i)} + > + {values} + ); - } + }); + + // When sorted ascending, 'control' will always be at top + type SortConfigItem = + | string + | { column: string; sortFunction: (a: string, b: string) => number }; + + 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; + } + + 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; + } + + return a > b ? -1 : 1; // significant values first + }, + }, + ]), + [groups], + ); + + return ( +
+

{metric}

+ + {columns} + {rows} +
+
+ ); } 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; -class PivotTable extends PureComponent { - render() { - return ; - } +function PivotTable(props: PivotTableProps) { + return ; } -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 3d5f29631e0..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,13 +17,21 @@ * 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'; -import { FaSort } from 'react-icons/fa'; -import { FaSortDown as FaSortDesc } from 'react-icons/fa'; -import { FaSortUp as FaSortAsc } from 'react-icons/fa'; +import { FaSort } from '@react-icons/all-files/fa/FaSort'; +import { FaSortDown as FaSortDesc } from '@react-icons/all-files/fa/FaSortDown'; +import { FaSortUp as FaSortAsc } from '@react-icons/all-files/fa/FaSortUp'; import { PivotData, flatKey } from './utilities'; import { Styles } from './Styles'; @@ -80,7 +88,7 @@ interface TableRendererProps { cols: string[]; rows: string[]; aggregatorName: string; - tableOptions: TableOptions; + tableOptions?: TableOptions; subtotalOptions?: SubtotalOptions; namesMapping?: Record; onContextMenu: ( @@ -93,13 +101,6 @@ interface TableRendererProps { [key: string]: unknown; } -interface TableRendererState { - collapsedRows: Record; - collapsedCols: Record; - sortingOrder: string[]; - activeSortColumn?: number | null; -} - interface PivotSettings { pivotData: InstanceType; colAttrs: string[]; @@ -174,33 +175,6 @@ function displayHeaderCell( ); } -function getCellColor( - keys: string[], - aggValue: string | number | null, - cellColorFormatters: Record | undefined, -): { backgroundColor: string | undefined } { - if (!cellColorFormatters) return { backgroundColor: undefined }; - - let backgroundColor: string | undefined; - - for (const cellColorFormatter of Object.values(cellColorFormatters)) { - if (!Array.isArray(cellColorFormatter)) continue; - - for (const key of keys) { - for (const formatter of cellColorFormatter) { - if (formatter.column === key) { - const result = formatter.getColorFromValue(aggValue); - if (result) { - backgroundColor = result; - } - } - } - } - } - - return { backgroundColor }; -} - interface HierarchicalNode { currentVal?: number; [key: string]: HierarchicalNode | number | undefined; @@ -299,275 +273,182 @@ function convertToArray( return result; } -export class TableRenderer extends Component< - TableRendererProps, - TableRendererState -> { - sortCache: Map; - cachedProps: TableRendererProps | null; - cachedBasePivotSettings: PivotSettings | null; +export function TableRenderer({ + cols, + rows, + aggregatorName, + tableOptions = {}, + subtotalOptions, + namesMapping: namesMappingProp, + onContextMenu, + allowRenderHtml, + ...restProps +}: TableRendererProps) { + const [collapsedRows, setCollapsedRows] = useState>( + {}, + ); + const [collapsedCols, setCollapsedCols] = useState>( + {}, + ); + const [sortingOrder, setSortingOrder] = useState([]); + const [activeSortColumn, setActiveSortColumn] = useState(null); + const [sortedRowKeys, setSortedRowKeys] = useState(null); - static propTypes: Record; - static defaultProps: Record; + const sortCacheRef = useRef(new Map()); - constructor(props: TableRendererProps) { - super(props); + // Memoize props object to maintain referential stability + const props = useMemo( + () => ({ + cols, + rows, + aggregatorName, + tableOptions, + subtotalOptions, + namesMapping: namesMappingProp, + onContextMenu, + allowRenderHtml, + ...restProps, + }), + [ + cols, + rows, + aggregatorName, + tableOptions, + subtotalOptions, + namesMappingProp, + onContextMenu, + allowRenderHtml, + restProps, + ], + ); - // 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); - } - - getBasePivotSettings(): 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 tableOptions: TableOptions = { - rowTotals: true, - colTotals: true, - ...props.tableOptions, - }; - const rowTotals = tableOptions.rowTotals || colAttrs.length === 0; - const colTotals = tableOptions.colTotals || rowAttrs.length === 0; - - const namesMapping = props.namesMapping || {}; - const subtotalOptions: Required< - Pick - > & - SubtotalOptions = { - arrowCollapsed: '\u25B2', - arrowExpanded: '\u25BC', - ...props.subtotalOptions, - }; - - const colSubtotalDisplay: SubtotalDisplay = { - displayOnTop: false, - enabled: tableOptions.colSubTotals, - hideOnExpand: false, - ...subtotalOptions.colSubtotalDisplay, - }; - - const rowSubtotalDisplay: SubtotalDisplay = { - displayOnTop: false, - enabled: tableOptions.rowSubTotals, - hideOnExpand: false, - ...subtotalOptions.rowSubtotalDisplay, - }; - - const pivotData = new PivotData(props as Record, { - rowEnabled: rowSubtotalDisplay.enabled, - colEnabled: colSubtotalDisplay.enabled, - rowPartialOnTop: rowSubtotalDisplay.displayOnTop, - colPartialOnTop: colSubtotalDisplay.displayOnTop, - }); - const rowKeys = pivotData.getRowKeys(); - const colKeys = pivotData.getColKeys(); - - // Also pre-calculate all the callbacks for cells, etc... This is nice to have to - // avoid re-calculations of the call-backs on cell expansions, etc... - const cellCallbacks: Record< - string, - Record void> - > = {}; - const rowTotalCallbacks: Record void> = {}; - const colTotalCallbacks: Record void> = {}; - let grandTotalCallback: ((e: MouseEvent) => void) | null = null; - if (tableOptions.clickCallback) { - rowKeys.forEach(rowKey => { - const flatRowKey = flatKey(rowKey); - if (!(flatRowKey in cellCallbacks)) { - cellCallbacks[flatRowKey] = {}; + const clickHandler = useCallback( + ( + pivotData: InstanceType, + rowValues: string[], + colValues: string[], + ) => { + const colAttrs = cols; + const rowAttrs = rows; + const value = pivotData.getAggregator(rowValues, colValues).value(); + const filters: Record = {}; + 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]; } - colKeys.forEach(colKey => { - cellCallbacks[flatRowKey][flatKey(colKey)] = this.clickHandler( - pivotData, - rowKey, - colKey, - ); - }); - }); - - // Add in totals as well. - if (rowTotals) { - rowKeys.forEach(rowKey => { - rowTotalCallbacks[flatKey(rowKey)] = this.clickHandler( - pivotData, - rowKey, - [], - ); - }); } - if (colTotals) { - colKeys.forEach(colKey => { - colTotalCallbacks[flatKey(colKey)] = this.clickHandler( - pivotData, - [], - colKey, - ); - }); - } - if (rowTotals && colTotals) { - grandTotalCallback = this.clickHandler(pivotData, [], []); - } - } - - return { - pivotData, - colAttrs, - rowAttrs, - colKeys, - rowKeys, - rowTotals, - colTotals, - arrowCollapsed: subtotalOptions.arrowCollapsed, - arrowExpanded: subtotalOptions.arrowExpanded, - colSubtotalDisplay, - rowSubtotalDisplay, - cellCallbacks, - rowTotalCallbacks, - colTotalCallbacks, - grandTotalCallback, - namesMapping, - allowRenderHtml: props.allowRenderHtml, - }; - } - - clickHandler( - pivotData: InstanceType, - rowValues: string[], - colValues: string[], - ) { - const colAttrs = this.props.cols; - const rowAttrs = this.props.rows; - const value = pivotData.getAggregator(rowValues, colValues).value(); - const filters: Record = {}; - 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, - values: string[], - attrs: string[], - attrIdx: number, - callback: HeaderClickCallback | undefined, - isSubtotal = false, - isGrandTotal = false, - ) { - const filters: Record = {}; - 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) => { - // Collapse an entire attribute. - e.stopPropagation(); - const keyLen = attrIdx + 1; - const collapsed = allKeys - .filter((k: string[]) => k.length === keyLen) - .map(flatKey); - - const updates: Record = {}; - 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) => { - // 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 = {}; - allKeys.forEach((k: string[]) => { - for (let i = 0; i <= attrIdx; i += 1) { - updates[flatKey(k.slice(0, i + 1))] = false; + 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]; } - }); - - if (rowOrCol) { - this.setState(state => ({ - collapsedRows: { ...state.collapsedRows, ...updates }, - })); - } else { - this.setState(state => ({ - collapsedCols: { ...state.collapsedCols, ...updates }, - })); } - }; - } + const { clickCallback } = tableOptions; + return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData); + }, + [cols, rows, tableOptions], + ); - toggleRowKey(flatRowKey: string) { - return (e: MouseEvent) => { + const clickHeaderHandler = useCallback( + ( + pivotData: InstanceType, + values: string[], + attrs: string[], + attrIdx: number, + callback: HeaderClickCallback | undefined, + isSubtotal = false, + isGrandTotal = false, + ) => { + const filters: Record = {}; + 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) => { + // Collapse an entire attribute. + e.stopPropagation(); + const keyLen = attrIdx + 1; + const collapsed = allKeys + .filter((k: string[]) => k.length === keyLen) + .map(flatKey); + + const updates: Record = {}; + 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) => { + // 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 = {}; + 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) => { e.stopPropagation(); - this.setState(state => ({ - collapsedRows: { - ...state.collapsedRows, - [flatRowKey]: !state.collapsedRows[flatRowKey], - }, + setCollapsedRows(state => ({ + ...state, + [flatRowKey]: !state[flatRowKey], })); - }; - } + }, + [], + ); - toggleColKey(flatColKey: string) { - return (e: MouseEvent) => { + const toggleColKey = useCallback( + (flatColKey: string) => (e: MouseEvent) => { e.stopPropagation(); - this.setState(state => ({ - collapsedCols: { - ...state.collapsedCols, - [flatColKey]: !state.collapsedCols[flatColKey], - }, + setCollapsedCols(state => ({ + ...state, + [flatColKey]: !state[flatColKey], })); - }; - } + }, + [], + ); - calcAttrSpans(attrArr: string[][], numAttrs: number) { + 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 @@ -598,81 +479,295 @@ export class TableRenderer extends Component< lv = cv; } return spans; - } + }, []); - getAggregatedData( - pivotData: InstanceType, - 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 = {}; - const rows = pivotData.rowKeys; - rows.forEach(rowKey => { - const aggValue = - pivotData.getAggregator(rowKey, visibleColName).value() ?? 0; + const getAggregatedData = useCallback( + ( + pivotData: InstanceType, + 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 = {}; + 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, key: string) => - (acc[key] ??= {}) as Record, + if (rowPartialOnTop) { + const parent = rowKey + .slice(0, -1) + .reduce( + (acc: Record, key: string) => + (acc[key] ??= {}) as Record, + groups, + ); + parent[rowKey.at(-1)!] = { currentVal: aggValue as number }; + } else { + rowKey.reduce( + (acc: Record, key: string) => { + acc[key] = acc[key] || { currentVal: 0 }; + (acc[key] as HierarchicalNode).currentVal = aggValue as number; + return acc[key] as Record; + }, groups, ); - parent[rowKey.at(-1)!] = { currentVal: aggValue as number }; - } else { - rowKey.reduce((acc: Record, key: string) => { - acc[key] = acc[key] || { currentVal: 0 }; - (acc[key] as HierarchicalNode).currentVal = aggValue as number; - return acc[key] as Record; - }, groups); - } + } + }); + return groups; + }, + [], + ); + + const sortAndCacheData = useCallback( + ( + groups: Record, + 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 colAttrs = cols; + const rowAttrs = rows; + + const mergedTableOptions: TableOptions = { + rowTotals: true, + colTotals: true, + ...tableOptions, + }; + const rowTotals = mergedTableOptions.rowTotals || colAttrs.length === 0; + const colTotals = mergedTableOptions.colTotals || rowAttrs.length === 0; + + const namesMapping = namesMappingProp || {}; + const mergedSubtotalOptions: Required< + Pick + > & + SubtotalOptions = { + arrowCollapsed: '\u25B2', + arrowExpanded: '\u25BC', + ...subtotalOptions, + }; + + const colSubtotalDisplay: SubtotalDisplay = { + displayOnTop: false, + enabled: mergedTableOptions.colSubTotals, + hideOnExpand: false, + ...mergedSubtotalOptions.colSubtotalDisplay, + }; + + const rowSubtotalDisplay: SubtotalDisplay = { + displayOnTop: false, + enabled: mergedTableOptions.rowSubTotals, + hideOnExpand: false, + ...mergedSubtotalOptions.rowSubtotalDisplay, + }; + + const pivotData = new PivotData(props as Record, { + rowEnabled: rowSubtotalDisplay.enabled, + colEnabled: colSubtotalDisplay.enabled, + rowPartialOnTop: rowSubtotalDisplay.displayOnTop, + colPartialOnTop: colSubtotalDisplay.displayOnTop, }); - return groups; - } + const rowKeys = pivotData.getRowKeys(); + const colKeys = pivotData.getColKeys(); - sortAndCacheData( - groups: Record, - 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, - ); - } + // Also pre-calculate all the callbacks for cells, etc... This is nice to have to + // avoid re-calculations of the call-backs on cell expansions, etc... + const cellCallbacks: Record< + string, + Record void> + > = {}; + const rowTotalCallbacks: Record void> = {}; + const colTotalCallbacks: Record void> = {}; + let grandTotalCallback: ((e: MouseEvent) => void) | null = null; + if (mergedTableOptions.clickCallback) { + rowKeys.forEach(rowKey => { + const flatRowKey = flatKey(rowKey); + if (!(flatRowKey in cellCallbacks)) { + cellCallbacks[flatRowKey] = {}; + } + colKeys.forEach(colKey => { + cellCallbacks[flatRowKey][flatKey(colKey)] = clickHandler( + pivotData, + rowKey, + colKey, + ); + }); + }); - sortData( - columnIndex: number, - visibleColKeys: string[][], - pivotData: InstanceType, - 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; + // Add in totals as well. + if (rowTotals) { + rowKeys.forEach(rowKey => { + rowTotalCallbacks[flatKey(rowKey)] = clickHandler( + pivotData, + rowKey, + [], + ); + }); + } + if (colTotals) { + colKeys.forEach(colKey => { + colTotalCallbacks[flatKey(colKey)] = clickHandler( + pivotData, + [], + colKey, + ); + }); + } + if (rowTotals && colTotals) { + grandTotalCallback = clickHandler(pivotData, [], []); + } + } - const newSortingOrder = []; + return { + pivotData, + colAttrs, + rowAttrs, + colKeys, + rowKeys, + rowTotals, + colTotals, + arrowCollapsed: mergedSubtotalOptions.arrowCollapsed, + arrowExpanded: mergedSubtotalOptions.arrowExpanded, + colSubtotalDisplay, + rowSubtotalDisplay, + cellCallbacks, + rowTotalCallbacks, + colTotalCallbacks, + grandTotalCallback, + namesMapping, + allowRenderHtml, + }; + }, [ + cols, + rows, + tableOptions, + namesMappingProp, + subtotalOptions, + props, + allowRenderHtml, + clickHandler, + ]); + + const visibleKeys = useCallback( + ( + keys: string[][], + collapsed: Record, + 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), + ), + [], + ); + + const isDashboardEditMode = useCallback( + () => document.contains(document.querySelector('.dashboard--editing')), + [], + ); + + // 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, + ); + + 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, + 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) { @@ -685,704 +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, - colAttrs, - colKeys, - visibleColKeys, - colAttrSpans, - rowTotals, - arrowExpanded, - arrowCollapsed, - colSubtotalDisplay, - maxColVisible, - pivotData, - namesMapping, - allowRenderHtml, - } = pivotSettings; - const { - highlightHeaderCellsOnHover, - omittedHighlightHeaderGroups = [], - highlightedHeaderCells, - cellColorFormatters, - dateFormatters, - } = this.props.tableOptions; + 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 (!visibleColKeys || !colAttrSpans) { - return null; - } + if (!settingsVisibleColKeys || !colAttrSpans) { + return null; + } - const spaceCell = - attrIdx === 0 && rowAttrs.length !== 0 ? ( - - ) : null; + const spaceCell = + attrIdx === 0 && settingsRowAttrs.length !== 0 ? ( + + ) : 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 = ( - - {displayHeaderCell( - needToggle, - subArrow, - arrowClickHandle, - attrName, - namesMapping, - allowRenderHtml, - )} - - ); + 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 = ( + + {displayHeaderCell( + needToggle, + subArrow, + arrowClickHandle, + attrName, + namesMapping, + settingsAllowRenderHtml, + )} + + ); - 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])) { + 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], + }); + } + 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 ( + + sortData( + key, + settingsVisibleColKeys, + pivotData, + maxRowIndex, + ) + } + /> + ); + } + + const SortIcon = + sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc; + return ( + + sortData(key, settingsVisibleColKeys, pivotData, maxRowIndex) + } + /> + ); + }; + const headerCellFormattedValue = + dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; + attrValueCells.push( + + {displayHeaderCell( + needToggle, + collapsedCols[flatColKey] ? arrowCollapsed : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + settingsAllowRenderHtml, + )} + { + e.stopPropagation(); + }} + aria-label={ + activeSortColumn === i + ? `Sorted by ${columnName} ${sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}` + : undefined + } + > + {visibleSortIcon && getSortIcon(i)} + + , + ); + } else if (attrIdx === colKey.length) { + const rowSpan = settingsColAttrs.length - colKey.length + rowIncrSpan; + attrValueCells.push( + + {t('Subtotal')} + , + ); + } + // The next colSpan columns will have the same value anyway... + i += colSpan; + } + + const totalCell = + attrIdx === 0 && rowTotals ? ( + + {t('Total (%(aggregatorName)s)', { + aggregatorName: t(aggregatorName), + })} + + ) : null; + + const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; + return {cells}; + }, + [ + 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 ( + + {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 ( + + {displayHeaderCell( + needLabelToggle, + subArrow, + arrowClickHandle, + r, + namesMapping, + settingsAllowRenderHtml, + )} + + ); + })} + + {settingsColAttrs.length === 0 + ? t('Total (%(aggregatorName)s)', { + aggregatorName: t(aggregatorName), + }) + : null} + + + ); + }, + [ + collapseAttr, + expandAttr, + clickHeaderHandler, + rows, + tableOptions.clickRowHeaderCallback, + aggregatorName, + ], + ); + + const renderTableRow = useCallback( + (rowKey: string[], rowIdx: number, settings: PivotSettings) => { + // Render a single row in the pivot table. + + 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) { - colLabelClass += ' hoverable'; + valueCellClassName += ' hoverable'; } handleContextMenu = (e: MouseEvent) => - this.props.onContextMenu(e, colKey, undefined, { - [attrName]: colKey[attrIdx], + onContextMenu(e, undefined, rowKey, { + [settingsRowAttrs[i]]: r, }); } if ( highlightedHeaderCells && - Array.isArray(highlightedHeaderCells[colAttrs[attrIdx]]) && - highlightedHeaderCells[colAttrs[attrIdx]].includes(colKey[attrIdx]) + Array.isArray(highlightedHeaderCells[settingsRowAttrs[i]]) && + highlightedHeaderCells[settingsRowAttrs[i]].includes(r) ) { - colLabelClass += ' active'; + valueCellClassName += ' active'; } - const maxRowIndex = pivotSettings.maxRowVisible!; - const mColVisible = pivotSettings.maxColVisible!; - const visibleSortIcon = mColVisible - 1 === attrIdx; - const columnName = colKey[mColVisible - 1]; + 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 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) { - return ( - - this.sortData(key, visibleColKeys, pivotData, maxRowIndex) - } - /> - ); - } - - const SortIcon = sortingOrder[key] === 'asc' ? FaSortAsc : FaSortDesc; + const headerCellFormattedValue = + dateFormatters?.[settingsRowAttrs[i]]?.(r) ?? r; return ( - - this.sortData(key, visibleColKeys, pivotData, maxRowIndex) - } - /> - ); - }; - const headerCellFormattedValue = - dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; - const { backgroundColor } = getCellColor( - [attrName], - headerCellFormattedValue, - cellColorFormatters, - ); - const style = { backgroundColor }; - attrValueCells.push( - - {displayHeaderCell( - needToggle, - this.state.collapsedCols[flatColKey] - ? arrowCollapsed - : arrowExpanded, - onArrowClick, - headerCellFormattedValue, - namesMapping, - allowRenderHtml, - )} - { - e.stopPropagation(); - }} - aria-label={ - this.state.activeSortColumn === i - ? `Sorted by ${columnName} ${this.state.sortingOrder[i] === 'asc' ? 'ascending' : 'descending'}` - : undefined - } + - {visibleSortIcon && getSortIcon(i)} - - , - ); - } else if (attrIdx === colKey.length) { - const rowSpan = colAttrs.length - colKey.length + rowIncrSpan; - attrValueCells.push( + {displayHeaderCell( + needRowToggle, + collapsedRows[flatRowKeySlice] ? arrowCollapsed : arrowExpanded, + onArrowClick, + headerCellFormattedValue, + namesMapping, + settingsAllowRenderHtml, + )} + + ); + } + return null; + }); + + const attrValuePaddingCell = + rowKey.length < settingsRowAttrs.length ? ( {t('Subtotal')} - , + + ) : null; + + if (!settingsVisibleColKeys) { + return null; + } + + 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 style = agg.isSubtotal + ? { fontWeight: 'bold' } + : { backgroundColor }; + + return ( + onContextMenu(e, colKey, rowKey)} + style={style} + > + {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)} + + ); + }); + + let totalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator(rowKey, []); + const aggValue = agg.value(); + totalCell = ( + onContextMenu(e, undefined, rowKey)} + > + {displayCell(agg.format(aggValue, agg), settingsAllowRenderHtml)} + ); } - // The next colSpan columns will have the same value anyway... - i += colSpan; - } - const totalCell = - attrIdx === 0 && rowTotals ? ( + const rowCells = [ + ...attrValueCells, + attrValuePaddingCell, + ...valueCells, + totalCell, + ]; + + return {rowCells}; + }, + [ + tableOptions, + onContextMenu, + toggleRowKey, + clickHeaderHandler, + rows, + collapsedRows, + ], + ); + + const renderTotalsRow = useCallback( + (settings: PivotSettings) => { + // Render the final totals rows that has the totals for all the columns. + + const { + rowAttrs: settingsRowAttrs, + colAttrs: settingsColAttrs, + visibleColKeys: settingsVisibleColKeys, + rowTotals, + pivotData, + colTotalCallbacks, + grandTotalCallback, + } = settings; + + if (!settingsVisibleColKeys) { + return null; + } + + const totalLabelCell = ( {t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), + aggregatorName: t(aggregatorName), })} - ) : null; + ); - const cells = [spaceCell, attrNameCell, ...attrValueCells, totalCell]; - return {cells}; - } + const totalValueCells = settingsVisibleColKeys.map((colKey: string[]) => { + const flatColKey = flatKey(colKey); + const agg = pivotData.getAggregator([], colKey); + const aggValue = agg.value(); - renderRowHeaderRow(pivotSettings: PivotSettings) { - // Render just the attribute names of the rows (the actual attribute values - // will show up in the individual rows). - - const { - rowAttrs, - colAttrs, - rowKeys, - arrowCollapsed, - arrowExpanded, - rowSubtotalDisplay, - maxRowVisible, - pivotData, - namesMapping, - allowRenderHtml, - } = pivotSettings; - return ( - - {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; - } - return ( - - {displayHeaderCell( - needLabelToggle, - subArrow, - arrowClickHandle, - r, - namesMapping, - allowRenderHtml, - )} - - ); - })} - - {colAttrs.length === 0 - ? t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), - }) - : null} - - - ); - } - - 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; - - const { backgroundColor } = getCellColor( - [rowAttrs[i]], - headerCellFormattedValue, - cellColorFormatters, - ); - const style = { backgroundColor }; return ( - onContextMenu(e, colKey, undefined)} + style={{ padding: '5px' }} > - {displayHeaderCell( - needRowToggle, - this.state.collapsedRows[flatRowKey] - ? arrowCollapsed - : arrowExpanded, - onArrowClick, - headerCellFormattedValue, - namesMapping, - allowRenderHtml, - )} - + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} + + ); + }); + + let grandTotalCell = null; + if (rowTotals) { + const agg = pivotData.getAggregator([], []); + const aggValue = agg.value(); + grandTotalCell = ( + onContextMenu(e, undefined, undefined)} + > + {displayCell(agg.format(aggValue, agg), allowRenderHtml)} + ); } - return null; - }); - const attrValuePaddingCell = - rowKey.length < rowAttrs.length ? ( - + {totalCells} + + ); + }, + [ + clickHeaderHandler, + rows, + tableOptions.clickRowHeaderCallback, + aggregatorName, + onContextMenu, + allowRenderHtml, + ], + ); + + return ( + + + + {colAttrs.map((c: string, j: number) => + renderColHeaderRow(c, j, pivotSettings), )} - > - {t('Subtotal')} - - ) : 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]; - - const { backgroundColor } = getCellColor( - keys, - aggValue, - cellColorFormatters, - ); - - const style = agg.isSubtotal - ? { fontWeight: 'bold' } - : { backgroundColor }; - - return ( - - ); - }); - - let totalCell = null; - if (rowTotals) { - const agg = pivotData.getAggregator(rowKey, []); - const aggValue = agg.value(); - totalCell = ( - - ); - } - - const rowCells = [ - ...attrValueCells, - attrValuePaddingCell, - ...valueCells, - totalCell, - ]; - - return {rowCells}; - } - - renderTotalsRow(pivotSettings: PivotSettings) { - // Render the final totals rows that has the totals for all the columns. - - const { - rowAttrs, - colAttrs, - visibleColKeys, - rowTotals, - pivotData, - colTotalCallbacks, - grandTotalCallback, - } = pivotSettings; - - if (!visibleColKeys) { - return null; - } - - const totalLabelCell = ( - - ); - - const totalValueCells = visibleColKeys.map((colKey: string[]) => { - const flatColKey = flatKey(colKey); - const agg = pivotData.getAggregator([], colKey); - const aggValue = agg.value(); - - return ( - - ); - }); - - let grandTotalCell = null; - if (rowTotals) { - const agg = pivotData.getAggregator([], []); - const aggValue = agg.value(); - grandTotalCell = ( - - ); - } - - const totalCells = [totalLabelCell, ...totalValueCells, grandTotalCell]; - - return ( - - {totalCells} - - ); - } - - visibleKeys( - keys: string[][], - collapsed: Record, - 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')); - } - - componentWillUnmount() { - this.sortCache.clear(); - } - - 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; - - // 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), - allowRenderHtml, - ...basePivotSettings, - }; - - return ( - -
this.props.onContextMenu(e, colKey, rowKey)} - style={style} - > - {displayCell(agg.format(aggValue, agg), allowRenderHtml)} - this.props.onContextMenu(e, undefined, rowKey)} - > - {displayCell(agg.format(aggValue, agg), allowRenderHtml)} -
- {t('Total (%(aggregatorName)s)', { - aggregatorName: t(this.props.aggregatorName), - })} - this.props.onContextMenu(e, colKey, undefined)} - style={{ padding: '5px' }} - > - {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} - this.props.onContextMenu(e, undefined, undefined)} - > - {displayCell(agg.format(aggValue, agg), this.props.allowRenderHtml)} -
- - {colAttrs.map((c: string, j: number) => - this.renderColHeaderRow(c, j, pivotSettings), - )} - {rowAttrs.length !== 0 && this.renderRowHeaderRow(pivotSettings)} - - - {visibleRowKeys.map((r: string[], i: number) => - this.renderTableRow(r, i, pivotSettings), - )} - {colTotals && this.renderTotalsRow(pivotSettings)} - -
-
- ); - } + {rowAttrs.length !== 0 && renderRowHeaderRow(pivotSettings)} + + + {visibleRowKeys.map((r: string[], i: number) => + renderTableRow(r, i, pivotSettings), + )} + {colTotals && renderTotalsRow(pivotSettings)} + + + + ); } 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 = { - 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 { - static defaultProps = defaultProps; +function WordCloud({ + data, + encoding = {}, + width, + height, + rotation = 'flat', + sliceId, + colorScheme, + theme, +}: FullWordCloudProps) { + const [words, setWords] = useState([]); + const [scaleFactor] = useState(1); + const isMountedRef = useRef(true); - isComponentMounted = false; + // Store previous props for comparison + const prevPropsRef = useRef<{ + data: PlainObject[]; + encoding: Partial; + width: number; + height: number; + rotation: RotationType; + } | null>(null); - createEncoder = (encoding?: Partial): SimpleEncoder => - new SimpleEncoder(encoding ?? {}, { - color: this.props.theme.colorTextLabel, - fontFamily: this.props.theme.fontFamily, - fontSize: 20, - fontWeight: 'bold', - text: '', - }); + const createEncoder = useCallback( + (enc?: Partial): SimpleEncoder => + new SimpleEncoder(enc ?? {}, { + color: theme.colorTextLabel, + fontFamily: theme.fontFamily, + fontSize: 20, + fontWeight: 'bold', + text: '', + }), + [theme.colorTextLabel, theme.fontFamily], + ); - 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(); + const setWordsIfMounted = useCallback((newWords: Word[]) => { + if (isMountedRef.current) { + setWords(newWords); } - } + }, []); - componentWillUnmount() { - this.isComponentMounted = false; - } + 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], + ); - setWords(words: Word[]) { - if (this.isComponentMounted) { - this.setState({ words }); - } - } - - update() { - const { data, encoding } = this.props; - - 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 { ); 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; + // Component mount/unmount tracking + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); - 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(); - } + // 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; - render() { - const { scaleFactor, words } = this.state; - const { width, height, encoding, sliceId, colorScheme } = this.props; + if (shouldUpdate) { + update(); + } - const encoder = this.createEncoder(encoding); + prevPropsRef.current = { data, encoding, width, height, rotation }; + }, [data, encoding, width, height, rotation, update]); - const colorFn = CategoricalColorNamespace.getScale(colorScheme); - const viewBoxWidth = width * scaleFactor; - const viewBoxHeight = height * scaleFactor; + const encoder = useMemo( + () => createEncoder(encoding), + [createEncoder, encoding], + ); - return ( - - - {words.map(w => ( - - {w.text} - - ))} - - - ); - } + const colorFn = CategoricalColorNamespace.getScale(colorScheme); + const viewBoxWidth = width * scaleFactor; + const viewBoxHeight = height * scaleFactor; + + return ( + + + {words.map(w => ( + + {w.text} + + ))} + + + ); } 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(); + rerender(); expect(storeExceedLocalStorage.getActions()).toContainEqual( expect.objectContaining({ type: LOG_EVENT, @@ -118,7 +118,7 @@ describe('SqlLab App', () => { useRedux: true, store: storeExceedLocalStorage, }); - rerender(); + rerender(); 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; -type AppProps = ReturnType & PureProps; +function App({ + actions, + localStorageUsageInKilobytes, + queries, + queriesLastUpdate, +}: AppProps) { + const [hash, setHash] = useState(window.location.hash); + const hasLoggedLocalStorageUsageRef = useRef(false); -interface AppState { - hash: string; -} + 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], + ); -class App extends PureComponent { - hasLoggedLocalStorageUsage: boolean; + const onHashChanged = useCallback(() => { + setHash(window.location.hash); + }, []); - private boundOnHashChanged: () => void; - - constructor(props: AppProps) { - super(props); - this.state = { - hash: window.location.hash, - }; - - this.boundOnHashChanged = this.onHashChanged.bind(this); - - this.showLocalStorageUsageWarning = throttle( - this.showLocalStorageUsageWarning, - LOCALSTORAGE_WARNING_MESSAGE_THROTTLE_MS, - { trailing: false }, - ); - } - - 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 { LOG_ACTIONS_SQLLAB_MONITOR_LOCAL_STORAGE_USAGE, eventData, ); - this.hasLoggedLocalStorageUsage = true; + hasLoggedLocalStorageUsageRef.current = true; } - } + }, [ + localStorageUsageInKilobytes, + queries, + actions, + showLocalStorageUsageWarning, + ]); - 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, - ); - } - - render() { - const { queries, queriesLastUpdate } = this.props; - if (this.state.hash && this.state.hash === '#search') { - return ( - - ); - } + if (hash && hash === '#search') { return ( - - - - - - - - + ); } + + return ( + + + + + + + + + ); } function mapStateToProps(state: SqlLabRootState) { @@ -250,10 +242,8 @@ const mapDispatchToProps = { function mergeProps( stateProps: ReturnType, 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; -class TabbedSqlEditors extends PureComponent { - 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]); - componentDidMount() { - const qe = this.activeQueryEditor(); - const latestQuery = this.props.queries[qe?.latestQueryId || '']; + // 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; + + 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(); - } - } + if (action === 'add') { + Logger.markTimeOrigin(); + newQueryEditor(); + } + }, + [queryEditors, removeQueryEditor, newQueryEditor], + ); - removeQueryEditor(qe: QueryEditor) { - this.props.actions.removeQueryEditor(qe); - } - - 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]); + + const editors = useMemo( + () => + queryEditors?.map(qe => ({ + key: qe.id, + label: , + children: ( + + ), + })), + [ + queryEditors, + defaultQueryLimit, + maxRow, + displayLimit, + saveQueryWarning, + scheduleQueryWarning, + ], + ); + + const emptyTab = ( + + {t('Add a new tab')} + + + + + ); + + const emptyTabState = { + key: '0', + label: emptyTab, + children: ( + + ), }; - render() { - const editors = this.props.queryEditors?.map(qe => ({ - key: qe.id, - label: , - children: ( - - ), - })); + const tabItems = queryEditors?.length > 0 ? editors : [emptyTabState]; - const emptyTab = ( - - {t('Add a new tab')} + return ( + - - - ); - - const emptyTabState = { - key: '0', - label: emptyTab, - children: ( - - ), - }; - - const tabItems = - this.props.queryEditors?.length > 0 ? editors : [emptyTabState]; - - return ( - - - - } - 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 a65ee1912f9..37d4903f286 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, @@ -123,19 +123,6 @@ const NONEXISTENT_DATASET = t( 'The dataset associated with this chart no longer exists', ); -const defaultProps: Partial = { - 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; @@ -183,252 +170,316 @@ const MessageSpan = styled.span` color: ${({ theme }) => theme.colorText}; `; -class Chart extends PureComponent { - static defaultProps = defaultProps; +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, + suppressLoadingSpinner, + } = restProps; - renderStartTime: any; + const renderStartTimeRef = useRef(0); - 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 || + 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, - ); - - 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, - }); - } - - renderErrorMessage(queryResponse: ChartErrorType) { - const { - chartId, - chartAlert, - chartStackTrace, - datasource, dashboardId, - height, - datasetsStatus, - } = this.props; - const error = queryResponse?.errors?.[0]; - const message = chartAlert || queryResponse?.message; + ownState, + ); + }, [ + actions, + chartId, + dashboardId, + formData, + force, + ownState, + shouldRenderChart, + timeout, + ]); - // if datasource is still loading, don't render JS errors - // but always show backend API errors (which have an errors array) - // so users can see real issues like auth failures - if ( - !error && - chartAlert !== undefined && - chartAlert !== NONEXISTENT_DATASET && - datasource === PLACEHOLDER_DATASOURCE && - datasetsStatus !== ResourceStatus.Error - ) { - return ( - - - + 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: renderStartTimeRef.current, + ts: new Date().getTime(), + duration: Logger.getTimestamp() - renderStartTimeRef.current, + }); + }, + [actions, 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 + // but always show backend API errors (which have an errors array) + // so users can see real issues like auth failures + if ( + !error && + chartAlert !== undefined && + chartAlert !== NONEXISTENT_DATASET && + datasource === PLACEHOLDER_DATASOURCE && + datasetsStatus !== ResourceStatus.Error + ) { + return ( + + + + ); + } + + return ( + + ); + }, + [ + chartAlert, + chartId, + chartStackTrace, + dashboardId, + datasetsStatus, + datasource, + height, + ], + ); + + const renderSpinner = useCallback( + (databaseName: string | undefined) => { + const message = databaseName + ? t('Waiting on %s', databaseName) + : t('Waiting on database...'); + + return ( + + + {message} + + ); + }, + [dashboardId], + ); + + const renderChartContainer = useCallback( + () => ( +
+ {shouldRenderChart() ? ( + + ) : ( + + )} +
+ ), + [ + actions, + addFilter, + annotationData, + chartAlert, + chartId, + chartIsStale, + chartStatus, + dashboardId, + datasource, + emitCrossFilters, + formData, + height, + initialValues, + latestQueryFormData, + onChartStateChange, + onFilterMenuClose, + onFilterMenuOpen, + ownState, + postTransformProps, + queriesResponse, + setControlValue, + shouldRenderChart, + triggerQuery, + triggerRender, + vizType, + width, + ], + ); + + const databaseName = datasource?.database?.name as string | undefined; + + const isLoading = chartStatus === 'loading'; + // Suppress spinner during auto-refresh to avoid visual flicker + const showSpinner = isLoading && !suppressLoadingSpinner; + + if (chartStatus === 'failed') { return ( - + {queriesResponse?.map(item => + renderErrorMessage(item as ChartErrorType), + )} + + ); + } + + if (errorMessage && ensureIsArray(queriesResponse).length === 0) { + return ( + + ); + } + if ( + !isLoading && + !chartAlert && + !errorMessage && + chartIsStale && + ensureIsArray(queriesResponse).length === 0 + ) { + return ( + + {t( + 'Click on "Create chart" button in the control panel on the left to preview a visualization or', + )}{' '} + + {t('click here')} + + . + + } + image="chart.svg" /> ); } - renderSpinner(databaseName: string | undefined) { - const message = databaseName - ? t('Waiting on %s', databaseName) - : t('Waiting on database...'); - - return ( - - - {message} - - ); - } - - renderChartContainer() { - return ( -
- {this.shouldRenderChart() ? ( - - ) : ( - - )} -
- ); - } - - render() { - const { - height, - chartAlert, - chartStatus, - datasource, - errorMessage, - chartIsStale, - queriesResponse = [], - width, - } = this.props; - - const databaseName = datasource?.database?.name as string | undefined; - - const isLoading = chartStatus === 'loading'; - // Suppress spinner during auto-refresh to avoid visual flicker - const showSpinner = isLoading && !this.props.suppressLoadingSpinner; - - if (chartStatus === 'failed') { - return ( - - {queriesResponse?.map(item => - this.renderErrorMessage(item as ChartErrorType), - )} - - ); - } - - if (errorMessage && ensureIsArray(queriesResponse).length === 0) { - return ( - - ); - } - if ( - !isLoading && - !chartAlert && - !errorMessage && - chartIsStale && - ensureIsArray(queriesResponse).length === 0 - ) { - return ( - - {t( - 'Click on "Create chart" button in the control panel on the left to preview a visualization or', - )}{' '} - - {t('click here')} - - . - - } - image="chart.svg" - /> - ); - } - - return ( - + - - {showSpinner - ? this.renderSpinner(databaseName) - : this.renderChartContainer()} - - - ); - } + {showSpinner ? renderSpinner(databaseName) : renderChartContainer()} + + + ); } export default Chart; diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index be6fd3aeaec..a4c60efc231 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, @@ -137,14 +146,6 @@ export interface ChartRendererProps { suppressLoadingSpinner?: boolean; } -// State interface -interface ChartRendererState { - showContextMenu: boolean; - inContextMenu: boolean; - legendState: LegendState | undefined; - legendIndex: number; -} - // Hooks interface interface ChartHooks { onAddFilter: ( @@ -175,105 +176,258 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220; const behaviors = [Behavior.InteractiveChart]; -const defaultProps: Partial = { - addFilter: () => BLANK, - onFilterMenuOpen: () => BLANK, - onFilterMenuClose: () => BLANK, - initialValues: BLANK, - setControlValue: () => {}, - triggerRender: false, -}; +interface ChartRendererState { + showContextMenu: boolean; + inContextMenu: boolean; + legendState: LegendState | undefined; + legendIndex: number; +} -class ChartRenderer extends Component { - static defaultProps = defaultProps; +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 | undefined; + labelsColorMap: Record | undefined; + formData: QueryFormData; + cacheBusterProp: string | undefined; + emitCrossFilters: boolean | undefined; + postTransformProps: ((props: JsonObject) => JsonObject) | undefined; +} - private hasQueryResponseChange: boolean; +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; - private contextMenuRef: RefObject; + const suppressContextMenu = getChartMetadataRegistry().get( + formData.viz_type ?? propVizType, + )?.suppressContextMenu; - private hooks: ChartHooks; + const [state, setState] = useState({ + showContextMenu: + source === ChartSource.Dashboard && + !suppressContextMenu && + isFeatureEnabled(FeatureFlag.DrillToDetail), + inContextMenu: false, + legendState: undefined, + legendIndex: 0, + }); - private mutableQueriesResponse: QueryData[] | null | undefined; + const hasQueryResponseChangeRef = useRef(false); + const renderStartTimeRef = useRef(0); + const mutableQueriesResponseRef = useRef( + cloneDeep(queriesResponse), + ); + const contextMenuRef = createRef(); - private renderStartTime: number; + // Track previous props for shouldComponentUpdate logic + const prevPropsRef = useRef({ + queriesResponse, + datasource, + annotationData, + ownState, + filterState, + height, + width, + triggerRender, + labelsColor, + labelsColorMap, + formData, + cacheBusterProp, + emitCrossFilters, + postTransformProps, + }); - 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; + // Handler functions + const handleAddFilter = useCallback( + (col: string, vals: FilterValue[], merge = true, refresh = true): void => { + addFilter?.(col, vals, merge, refresh); + }, + [addFilter], + ); - this.contextMenuRef = createRef(); + const handleRenderSuccess = useCallback((): void => { + if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) { + actions.chartRenderingSucceeded(chartId); + } - 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); + // 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]); - 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, - }; + const handleRenderFailure = useCallback( + (error: Error, info: { componentStack: string } | null): void => { + logging.warn(error); + actions.chartRenderingFailed( + error.toString(), + chartId, + info ? info.componentStack : null, + ); - // 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); - } + // 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], + ); - shouldComponentUpdate( - nextProps: ChartRendererProps, - nextState: ChartRendererState, - ): boolean { + 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 })); + }, []); + + 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): 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( + () => ({ + 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; @@ -289,285 +443,209 @@ class ChartRenderer extends Component { ); }; - const nextFormData = nextProps.formData as JsonObject; - const currentFormData = this.props.formData as JsonObject; + const nextFormData = formData as JsonObject; + const currentFormData = prevProps.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 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() - ); + 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, + }; + + return shouldRender; } return false; + }, [ + annotationData, + cacheBusterProp, + chartStatus, + datasource, + emitCrossFilters, + filterState, + formData, + height, + labelsColor, + labelsColorMap, + ownState, + postTransformProps, + queriesResponse, + triggerRender, + width, + ]); + + const hasAnyErrors = queriesResponse?.some(item => item?.error); + const hasValidPreviousData = + (queriesResponse?.length ?? 0) > 0 && !hasAnyErrors; + + if (!!chartAlert || chartStatus === null) { + return null; } - 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); - } - - // 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, - }); - } - } - - 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, - ); - - // 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): 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; - - const hasAnyErrors = this.props.queriesResponse?.some(item => item?.error); - const hasValidPreviousData = - (this.props.queriesResponse?.length ?? 0) > 0 && !hasAnyErrors; - - if (!!chartAlert || chartStatus === null) { + if (chartStatus === 'loading') { + if (!restProps.suppressLoadingSpinner || !hasValidPreviousData) { return null; } + } - if (chartStatus === 'loading') { - if (!this.props.suppressLoadingSpinner || !hasValidPreviousData) { - return null; - } - } + renderStartTimeRef.current = Logger.getTimestamp(); - this.renderStartTime = Logger.getTimestamp(); + const currentFormData = + chartIsStale && latestQueryFormData ? latestQueryFormData : formData; + const vizType = currentFormData.viz_type || propVizType; - const { - width, - height, - datasource, - annotationData, - initialValues, - ownState, - filterState, - chartIsStale, - formData, - latestQueryFormData, - postTransformProps, - } = this.props; + // 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 currentFormData = - chartIsStale && latestQueryFormData ? latestQueryFormData : formData; - const vizType = currentFormData.viz_type || this.props.vizType; + 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() + }` + : ''; - // 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 = ( - - ); - } else { - noResultsComponent = ( - - ); - } - - // 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) + 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 = ( + ); - - return ( - <> - {this.state.showContextMenu && ( - - )} -
- -
- + } else { + noResultsComponent = ( + ); } + + // 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 && ( + + )} +
+ [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} + isRefreshing={ + Boolean(restProps.suppressLoadingSpinner) && + chartStatus === 'loading' + } + {...drillToDetailProps} + /> +
+ + ); } +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 = { - copyNode: {t('Copy')}, - onCopyEnd: () => {}, - shouldShowText: true, - wrapped: true, - tooltipText: t('Copy to clipboard'), - hideTooltip: false, -}; +function CopyToClip({ + copyNode = {t('Copy')}, + onCopyEnd = () => {}, + shouldShowText = true, + wrapped = true, + tooltipText = t('Copy to clipboard'), + hideTooltip = false, + getText, + text, + addSuccessToast, + addDangerToast, +}: CopyToClipboardProps) { + const copyToClipboard = useCallback( + (textToCopy: Promise) => { + 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 { - 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 || '')); } - } + }, [getText, text, copyToClipboard]); - getDecoratedCopyNode() { - return cloneElement(this.props.copyNode as ReactElement, { - style: { cursor: 'pointer' }, - onClick: this.onClick, - }); - } + const getDecoratedCopyNode = useCallback( + () => + cloneElement(copyNode as ReactElement, { + style: { cursor: 'pointer' }, + onClick, + }), + [copyNode, onClick], + ); - copyToClipboard(textToCopy: Promise) { - 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(); - }); - } - - renderTooltip(cursor: string) { - return ( + const renderTooltip = useCallback( + (cursor: string) => ( <> - {!this.props.hideTooltip ? ( + {!hideTooltip ? ( - {this.getDecoratedCopyNode()} + {getDecoratedCopyNode()} ) : ( - this.getDecoratedCopyNode() + getDecoratedCopyNode() )} - ); - } + ), + [hideTooltip, tooltipText, getDecoratedCopyNode], + ); - renderNotWrapped() { - return this.renderTooltip('pointer'); - } + const renderNotWrapped = useCallback( + () => renderTooltip('pointer'), + [renderTooltip], + ); - renderLink() { - return ( + const renderLink = useCallback( + () => ( - {this.props.shouldShowText && this.props.text && ( + {shouldShowText && text && ( css` margin-right: ${theme.sizeUnit}px; `} > - {this.props.text} + {text} )} - {this.renderTooltip('pointer')} + {renderTooltip('pointer')} - ); - } + ), + [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 a99e7f344f5..efc9db5261e 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) { const collectionArray = arr.map( - (o: any) => + (o: Record) => ({ ...o, id: o.id || nanoid(), }) as CollectionItem, ); - const collection: Record = {}; + const collection: Record = {}; collectionArray.forEach((o: CollectionItem) => { collection[o.id] = o; }); @@ -74,270 +74,291 @@ function createKeyedCollection(arr: Array) { }; } -export default class CRUDCollection extends PureComponent< - CRUDCollectionProps, - CRUDCollectionState -> { - constructor(props: CRUDCollectionProps) { - super(props); +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 + >({}); + const [collection, setCollection] = useState< + Record + >(() => createKeyedCollection(propsCollection).collection); + const [collectionArray, setCollectionArray] = useState( + () => createKeyedCollection(propsCollection).collectionArray, + ); + const [sortColumn, setSortColumn] = useState(''); + const [sort, setSort] = useState(SortOrderEnum.Unsorted); - const { collection, collectionArray } = createKeyedCollection( - props.collection, - ); + // Sync with props.collection changes + useEffect(() => { + const { collection: newCollection, collectionArray: newCollectionArray } = + createKeyedCollection(propsCollection); + setCollection(newCollection); + setCollectionArray(newCollectionArray); + }, [propsCollection]); - // Get initial page size from pagination prop - const initialPageSize = - typeof props.pagination === 'object' && props.pagination?.pageSize - ? props.pagination.pageSize - : 10; + const onCellChange = useCallback( + (id: string | number, col: string, val: unknown) => { + setCollection(prevCollection => { + const updatedCollection = { + ...prevCollection, + [id]: { + ...prevCollection[id], + [col]: val, + }, + }; + return updatedCollection; + }); - this.state = { - expandedColumns: {}, - collection, - collectionArray, - sortColumn: '', - sort: 0, - currentPage: 1, - pageSize: initialPageSize, - }; - 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); - } + setCollectionArray(prevCollectionArray => { + const updatedCollectionArray = prevCollectionArray.map(item => { + if (item.id === id) { + return { + ...item, + [col]: val, + }; + } + return item; + }); - componentDidUpdate(prevProps: CRUDCollectionProps) { - if (this.props.collection !== prevProps.collection) { - const { collection, collectionArray } = createKeyedCollection( - this.props.collection, - ); + if (onChange) { + onChange(updatedCollectionArray); + } - this.setState(prevState => ({ - collection, - collectionArray, - expandedColumns: prevState.expandedColumns, - })); - } - } + return updatedCollectionArray; + }); + }, + [onChange], + ); - 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, - ); + const changeCollection = useCallback( + ( + newCollection: Record, + currentCollectionArray: CollectionItem[], + ) => { + // Preserve existing order instead of recreating from Object.keys() + const existingIds = new Set(currentCollectionArray.map(item => item.id)); + const newCollectionArray: CollectionItem[] = []; - if (this.props.onChange) { - this.props.onChange(updatedCollectionArray); + // 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]; + setCollection(prevCollection => ({ + ...prevCollection, + [newItem.id]: newItem, + })); - return { - collection: newCollection, - collectionArray: newCollectionArray, - expandedColumns: newExpandedColumns, - }; - }, - () => { - if (this.props.onChange) { - this.props.onChange(this.state.collectionArray); - } - }, - ); - } - } + setCollectionArray(prevCollectionArray => { + const newCollectionArray = [newItem, ...prevCollectionArray]; - onFieldsetChange(item: any) { - this.changeCollection({ - ...this.state.collection, - [item.id]: item, - }); - } - - 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]); - } - } - - // Second pass: add new items - for (const item of Object.values(collection) as CollectionItem[]) { - if (!existingIds.has(item.id)) { - newCollectionArray.push(item); - } - } - - this.setState({ collection, collectionArray: newCollectionArray }); - - 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], - }, - })); - } - - handleTableChange( - pagination: TablePaginationConfig, - _filters: Record, - sorter: SorterResult | SorterResult[], - ) { - // Handle pagination changes - if (pagination.current !== undefined && pagination.pageSize !== undefined) { - this.setState({ - currentPage: pagination.current, - pageSize: pagination.pageSize, - }); - } - - // Handle sorting changes - 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; - - if (sortColumns?.includes(col) || newSortOrder === 0) { - let sortedArray = [...this.props.collection]; - - 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; - } - 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(); + if (onChange) { + onChange(newCollectionArray); } - } else { - const { collectionArray } = createKeyedCollection( - this.props.collection, - ); - sortedArray = collectionArray; + + 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], + ); + + const getLabel = useCallback( + (col: string): string => { + let label = columnLabels?.[col] ? columnLabels[col] : col; + if (label.startsWith('__')) { + label = ''; + } + return label; + }, + [columnLabels], + ); + + const getTooltip = useCallback( + (col: string): string | undefined => columnLabelTooltips?.[col], + [columnLabelTooltips], + ); + + const toggleExpand = useCallback((id: string | number) => { + setExpandedColumns(prev => ({ + ...prev, + [id]: !prev[id], + })); + }, []); + + const handleTableChange = useCallback( + ( + _pagination: TablePaginationConfig, + _filters: Record, + sorter: SorterResult | SorterResult[], + ) => { + 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; } - this.setState({ - collectionArray: sortedArray, - sortColumn: newSortColumn, - sort: newSortOrder, - }); - } - } + const col = newSortColumn; - renderExpandableSection(item: any): ReactNode { - const propsGenerator = () => ({ item, onChange: this.onFieldsetChange }); - return recurseReactClone( - this.props.expandFieldset, - Fieldset, - propsGenerator, - ); - } + if ( + sortColumns?.includes(col) || + newSortOrder === SortOrderEnum.Unsorted + ) { + let sortedArray = [...propsCollection] as CollectionItem[]; - 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; - } + 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); + }; - buildTableColumns() { - const { tableColumns, allowDeletes, sortColumns = [] } = this.props; + sortedArray.sort((a: CollectionItem, b: CollectionItem) => + compareSort(a[col] as Sort, b[col] as Sort), + ); + if (newSortOrder === SortOrderEnum.Desc) { + sortedArray.reverse(); + } + } else { + const { collectionArray: resetArray } = + createKeyedCollection(propsCollection); + sortedArray = resetArray; + } - const antdColumns: ColumnsType = tableColumns.map(col => { - const label = this.getLabel(col); - const tooltip = this.getTooltip(col); + setCollectionArray(sortedArray); + setSortColumn(newSortColumn); + setSort(newSortOrder); + } + }, + [propsCollection, sortColumns], + ); + + 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 => { + const columns: ColumnsType = 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; @@ -361,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) : {}; }, @@ -374,7 +395,7 @@ export default class CRUDCollection extends PureComponent< }); if (allowDeletes) { - antdColumns.push({ + columns.push({ key: '__actions', dataIndex: '__actions', sorter: false, @@ -398,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" /> @@ -407,103 +428,81 @@ export default class CRUDCollection extends PureComponent< }); } - return antdColumns as ColumnsType; - } + return columns; + }, [ + tableColumns, + getLabel, + getTooltip, + sortColumns, + sortColumn, + sort, + renderCell, + itemCellProps, + allowDeletes, + deleteItem, + ]); - render() { - const { - stickyHeader, - emptyMessage = t('No items'), - expandFieldset, - pagination = false, - filterTerm, - filterFields, - } = this.props; + const expandedRowKeys = useMemo( + () => Object.keys(expandedColumns).filter(id => expandedColumns[id]), + [expandedColumns], + ); - const displayData = - filterTerm && filterFields?.length - ? this.state.collectionArray.filter(item => - filterFields.some(field => - String(item[field] ?? '') - .toLowerCase() - .includes(filterTerm.toLowerCase()), - ), - ) - : this.state.collectionArray; - - 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; - - // Build controlled pagination config, clamping currentPage to valid range - // based on displayData (filtered) length, not the full collection - const { pageSize, currentPage: statePage } = this.state; - const totalItems = displayData.length; - const maxPage = totalItems > 0 ? Math.ceil(totalItems / pageSize) : 1; - const currentPage = Math.min(statePage, maxPage); - const paginationConfig: false | TablePaginationConfig | undefined = - pagination === false || pagination === undefined - ? pagination - : { - ...(typeof pagination === 'object' ? pagination : {}), - current: currentPage, - pageSize, - total: totalItems, - }; - - return ( - <> - - {this.props.allowAddItem && ( - - - - )} - - - data-test="crud-table" - columns={tableColumns} - data={displayData as CollectionItem[]} - rowKey={(record: CollectionItem) => String(record.id)} - sticky={stickyHeader} - pagination={paginationConfig} - onChange={this.handleTableChange} - locale={{ emptyText: emptyMessage }} - css={ - stickyHeader && - css` - overflow: auto; - ` + 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 ( + <> + + {allowAddItem && ( + + + + )} + + + 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 5e8be8d450b..7b38cc59b75 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, type 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 { @@ -78,12 +83,6 @@ import { import Mousetrap from 'mousetrap'; import { clearDatasetCache } from 'src/utils/cachedSupersetGet'; import { makeUrl } from 'src/utils/pathUtils'; -import { - OwnerSelectLabel, - OWNER_TEXT_LABEL_PROP, - OWNER_EMAIL_PROP, - OWNER_OPTION_FILTER_PROPS, -} from 'src/features/owners/OwnerSelectLabel'; import { DatabaseSelector } from '../../../DatabaseSelector'; import CollectionTable from '../CollectionTable'; import Fieldset from '../Fieldset'; @@ -91,7 +90,9 @@ import Field from '../Field'; import { fetchSyncedColumns, updateColumns } from '../../utils'; import DatasetUsageTab from './components/DatasetUsageTab'; import { + DEFAULT_COLUMNS_FOLDER_UUID, DEFAULT_FOLDERS_COUNT, + DEFAULT_METRICS_FOLDER_UUID, isDefaultFolder, } from '../../FoldersEditor/constants'; import { validateFolders } from '../../FoldersEditor/folderValidation'; @@ -109,11 +110,9 @@ const extensionsRegistry = getExtensionsRegistry(); interface Owner { id?: number; value?: number; - label?: ReactNode; + label?: string; first_name?: string; last_name?: string; - email?: string; - [key: string]: unknown; } interface Currency { @@ -206,7 +205,6 @@ interface DatasourceEditorOwnProps { addDangerToast: (msg: string) => void; setIsEditing?: (isEditing: boolean) => void; currencies?: string[]; - theme?: SupersetTheme; } interface QueryResultColumn { @@ -264,25 +262,6 @@ interface ChartUsageData { }>; } -interface DatasourceEditorState { - datasource: DatasourceObject; - errors: string[]; - isSqla: boolean; - isEditMode: boolean; - databaseColumns: Column[]; - calculatedColumns: Column[]; - folders: DatasourceFolder[]; - folderCount: number; - metadataLoading: boolean; - activeTabKey: string; - datasourceType: string; - usageCharts: ChartUsageData[]; - usageChartsCount: number; - metricSearchTerm: string; - columnSearchTerm: string; - calculatedColumnSearchTerm: string; -} - interface AbortControllers { formatQuery: AbortController | null; formatSql: AbortController | null; @@ -328,11 +307,6 @@ interface OwnersSelectorProps { } const DatasourceContainer = styled.div` - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - .change-warning { margin: 16px 10px 0; color: ${({ theme }) => theme.colorWarning}; @@ -361,23 +335,9 @@ const FlexRowContainer = styled.div` `; const StyledTableTabs = styled(Tabs)` - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - + overflow: visible; .ant-tabs-content-holder { - flex: 1; - min-height: 0; - overflow: auto; - } - - .ant-tabs-content { - height: 100%; - } - - .ant-tabs-tabpane-active { - height: 100%; + overflow: visible; } `; @@ -492,28 +452,19 @@ function CollectionTabTitle({ collection, count, }: CollectionTabTitleProps): JSX.Element { + const displayCount = + count !== undefined ? count : collection ? collection.length : 0; return (
{title}{' '} - +
); } -function FormContainer({ children }: FormContainerProps): JSX.Element { - return ( - - {children} - - ); -} - function ColumnCollectionTable({ columns, datasource, @@ -787,6 +738,14 @@ function StackedField({ label, formElement }: StackedFieldProps): JSX.Element { ); } +function FormContainer({ children }: FormContainerProps): JSX.Element { + return ( + + {children} + + ); +} + function OwnersSelector({ datasource, onChange, @@ -805,12 +764,7 @@ function OwnersSelector({ .filter(item => item.extra.active) .map(item => ({ value: item.value as number, - label: OwnerSelectLabel({ - name: item.text as string, - email: item.extra?.email as string | undefined, - }), - [OWNER_TEXT_LABEL_PROP]: item.text as string, - [OWNER_EMAIL_PROP]: (item.extra?.email as string) ?? '', + label: item.text as string, })), totalCount: response.json.count, })); @@ -828,7 +782,6 @@ function OwnersSelector({ onChange={value => onChange(value as Owner[])} header={{t('Owners')}} allowClear - optionFilterProps={OWNER_OPTION_FILTER_PROPS} /> ); } @@ -878,127 +831,172 @@ const mapStateToProps = (state: RootState) => ({ const connector = connect(mapStateToProps, mapDispatchToProps); type PropsFromRedux = ConnectedProps; -type DatasourceEditorProps = DatasourceEditorOwnProps & - PropsFromRedux & { - theme?: SupersetTheme; - }; +type DatasourceEditorProps = DatasourceEditorOwnProps & PropsFromRedux; -class DatasourceEditor extends PureComponent< - DatasourceEditorProps, - DatasourceEditorState -> { - private isComponentMounted: boolean; +function DatasourceEditor({ + datasource: propsDatasource, + onChange = () => {}, + addSuccessToast, + addDangerToast, + setIsEditing = () => {}, + database, + runQuery, + resetQuery, + formatQuery: formatQueryAction, +}: DatasourceEditorProps) { + const theme = useTheme(); + const isComponentMounted = useRef(false); + const abortControllers = useRef({ + formatQuery: null, + formatSql: null, + syncMetadata: null, + fetchUsageData: null, + }); - private abortControllers: AbortControllers; + // Initialize datasource state with transformed owners and metrics + const [datasource, setDatasource] = useState(() => ({ + ...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, + }; + }), + })); - static defaultProps = { - onChange: () => {}, - setIsEditing: () => {}, - }; + const [errors, setErrors] = useState([]); + const [isSqla] = useState( + propsDatasource.datasource_type === 'table' || + propsDatasource.type === 'table', + ); + const [isEditMode, setIsEditMode] = useState(false); + const [databaseColumns, setDatabaseColumns] = useState( + propsDatasource.columns.filter(col => !col.expression), + ); + const [calculatedColumns, setCalculatedColumns] = useState( + propsDatasource.columns.filter(col => !!col.expression), + ); + const [folders, setFolders] = useState( + propsDatasource.folders || [], + ); + const [folderCount, setFolderCount] = useState(() => { + const savedFolders = propsDatasource.folders || []; + const savedCount = countAllFolders(savedFolders); + const hasDefaultsSaved = savedFolders.some(f => isDefaultFolder(f.uuid)); + return savedCount + (hasDefaultsSaved ? 0 : DEFAULT_FOLDERS_COUNT); + }); + 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([]); + const [usageChartsCount, setUsageChartsCount] = useState(0); + const [metricSearchTerm, setMetricSearchTerm] = useState(''); + const [columnSearchTerm, setColumnSearchTerm] = useState(''); + const [calculatedColumnSearchTerm, setCalculatedColumnSearchTerm] = + useState(''); - constructor(props: DatasourceEditorProps) { - super(props); - this.state = { - datasource: { - ...props.datasource, - owners: props.datasource.owners.map(owner => { - const ownerName = - owner.label || `${owner.first_name} ${owner.last_name}`; - return { - value: owner.value || owner.id, - label: OwnerSelectLabel({ - name: typeof ownerName === 'string' ? ownerName : '', - email: owner.email, + const findDuplicates = useCallback( + (arr: T[], accessor: (obj: T) => string): string[] => { + const seen: Record = {}; + const dups: string[] = []; + arr.forEach((obj: T) => { + const item = accessor(obj); + if (item in seen) { + dups.push(item); + } else { + seen[item] = null; + } + }); + return dups; + }, + [], + ); + + const validate = useCallback( + (callback: () => void) => { + let validationErrors: string[] = []; + let dups: string[]; + + // 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)), + ); + + // Looking for duplicate metric_name + dups = findDuplicates(datasource.metrics ?? [], obj => obj.metric_name); + validationErrors = validationErrors.concat( + dups.map(name => t('Metric name [%s] is duplicated', name)), + ); + + // Making sure calculatedColumns have an expression defined + const noFilterCalcCols = calculatedColumns.filter( + col => !col.expression && !col.json, + ); + validationErrors = validationErrors.concat( + noFilterCalcCols.map(col => + t('Calculated column [%s] requires an expression', col.column_name), + ), + ); + + // validate currency code (skip 'AUTO' - it's a placeholder for auto-detection) + try { + datasource.metrics?.forEach( + metric => + metric.currency?.symbol && + metric.currency.symbol !== 'AUTO' && + new Intl.NumberFormat('en-US', { + style: 'currency', + currency: metric.currency.symbol, }), - [OWNER_TEXT_LABEL_PROP]: - typeof ownerName === 'string' ? ownerName : '', - [OWNER_EMAIL_PROP]: owner.email ?? '', - }; - }), - metrics: props.datasource.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, - }; - }), - }, - errors: [], - isSqla: - props.datasource.datasource_type === 'table' || - props.datasource.type === 'table', - isEditMode: false, - databaseColumns: props.datasource.columns.filter(col => !col.expression), - calculatedColumns: props.datasource.columns.filter( - col => !!col.expression, - ), - folders: props.datasource.folders || [], - folderCount: (() => { - const savedFolders = props.datasource.folders || []; - const savedCount = countAllFolders(savedFolders); - const hasDefaultsSaved = savedFolders.some(f => - isDefaultFolder(f.uuid), ); - return savedCount + (hasDefaultsSaved ? 0 : DEFAULT_FOLDERS_COUNT); - })(), - metadataLoading: false, - activeTabKey: TABS_KEYS.SOURCE, - datasourceType: props.datasource.sql - ? DATASOURCE_TYPES.virtual.key - : DATASOURCE_TYPES.physical.key, - usageCharts: [], - usageChartsCount: 0, - metricSearchTerm: '', - columnSearchTerm: '', - calculatedColumnSearchTerm: '', - }; + } catch { + validationErrors = validationErrors.concat([ + t('Invalid currency code in saved metrics'), + ]); + } - this.isComponentMounted = false; - this.abortControllers = { - formatQuery: null, - formatSql: null, - syncMetadata: null, - fetchUsageData: null, - }; + // Validate folders + if (folders?.length > 0) { + const folderValidation = validateFolders(folders); + validationErrors = validationErrors.concat(folderValidation.errors); + } - this.onChange = this.onChange.bind(this); - this.onChangeEditMode = this.onChangeEditMode.bind(this); - this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this); - this.onDatasourceChange = this.onDatasourceChange.bind(this); - this.tableChangeAndSyncMetadata = - this.tableChangeAndSyncMetadata.bind(this); - this.syncMetadata = this.syncMetadata.bind(this); - this.setColumns = this.setColumns.bind(this); - this.validateAndChange = this.validateAndChange.bind(this); - this.handleTabSelect = this.handleTabSelect.bind(this); - this.formatSql = this.formatSql.bind(this); - this.fetchUsageData = this.fetchUsageData.bind(this); - this.handleFoldersChange = this.handleFoldersChange.bind(this); - } + setErrors(validationErrors); + callback(); + }, + [datasource, calculatedColumns, folders, findDuplicates], + ); - onChange() { + const onChangeInternal = useCallback(() => { // Emptying SQL if "Physical" radio button is selected - // Currently the logic to know whether the source is - // physical or virtual is based on whether SQL is empty or not. - const { datasourceType, datasource } = this.state; const sql = datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql; const columns = [ - ...this.state.databaseColumns, - ...this.state.calculatedColumns, + ...databaseColumns, + ...calculatedColumns, ]; // Remove deleted column/metric references from folders @@ -1009,89 +1007,134 @@ class DatasourceEditor extends PureComponent< for (const metric of datasource.metrics ?? []) { if (metric.uuid) validUuids.add(metric.uuid); } - const folders = filterFoldersByValidUuids(this.state.folders, validUuids); + const filteredFolders = filterFoldersByValidUuids(folders, validUuids); const newDatasource = { - ...this.state.datasource, + ...datasource, sql, columns, - folders, + folders: filteredFolders, }; - this.props.onChange?.(newDatasource, this.state.errors); - } + onChange(newDatasource, errors); + }, [ + datasource, + datasourceType, + databaseColumns, + calculatedColumns, + folders, + errors, + onChange, + ]); - onChangeEditMode() { - this.props.setIsEditing?.(!this.state.isEditMode); - this.setState(prevState => ({ isEditMode: !prevState.isEditMode })); - } + const validateAndChange = useCallback(() => { + validate(onChangeInternal); + }, [validate, onChangeInternal]); - onDatasourceChange( - datasource: DatasourceObject, - callback: () => void = this.validateAndChange, - ) { - this.setState({ datasource }, callback); - } + const onDatasourceChange = useCallback( + ( + newDatasource: DatasourceObject, + callback: () => void = validateAndChange, + ) => { + setDatasource(newDatasource); + // Need to call callback after state update + setTimeout(callback, 0); + }, + [validateAndChange], + ); - onDatasourcePropChange(attr: string, value: unknown) { - if (value === undefined) return; // if value is undefined do not update state - const datasource = { ...this.state.datasource, [attr]: value }; - this.setState( - prevState => ({ - datasource: { ...prevState.datasource, [attr]: value }, - }), - () => - attr === 'table_name' - ? this.onDatasourceChange(datasource, this.tableChangeAndSyncMetadata) - : this.onDatasourceChange(datasource, this.validateAndChange), - ); - } - - onDatasourceTypeChange(datasourceType: string) { - // Call onChange after setting datasourceType to ensure - // SQL is cleared when switching to a physical dataset - this.setState({ datasourceType }, this.onChange); - } - - handleFoldersChange(folders: DatasourceFolder[]) { - const folderCount = countAllFolders(folders); - this.setState({ folders, folderCount }, () => { - this.onDatasourceChange({ - ...this.state.datasource, - folders, - }); + const onDatasourcePropChange = useCallback((attr: string, value: unknown) => { + if (value === undefined) return; + setDatasource(prev => { + const newDatasource = { ...prev, [attr]: value }; + return newDatasource; }); - } + }, []); - setColumns( - obj: { databaseColumns?: Column[] } | { calculatedColumns?: Column[] }, - ) { - // update calculatedColumns or databaseColumns - this.setState( - obj as Pick< - DatasourceEditorState, - 'databaseColumns' | 'calculatedColumns' - >, - this.validateAndChange, + // Effect to trigger validation after datasource changes + useEffect(() => { + if (isComponentMounted.current) { + validateAndChange(); + } + }, [datasource]); + + const onChangeEditMode = useCallback(() => { + setIsEditing(!isEditMode); + setIsEditMode(prev => !prev); + }, [isEditMode, setIsEditing]); + + const onDatasourceTypeChange = useCallback((newDatasourceType: string) => { + setDatasourceType(newDatasourceType); + }, []); + + // Effect to call onChange after datasourceType changes + useEffect(() => { + if (isComponentMounted.current) { + onChangeInternal(); + } + }, [datasourceType]); + + const handleFoldersChange = useCallback((newFolders: DatasourceFolder[]) => { + const userMadeFolders = newFolders.filter( + f => + f.uuid !== DEFAULT_METRICS_FOLDER_UUID && + f.uuid !== DEFAULT_COLUMNS_FOLDER_UUID && + (f.children?.length ?? 0) > 0, ); - } + setFolders(userMadeFolders); + setFolderCount(countAllFolders(userMadeFolders)); + setDatasource(prev => ({ ...prev, folders: userMadeFolders })); + }, []); - validateAndChange() { - this.validate(this.onChange); - } + const setColumns = useCallback( + ( + obj: { databaseColumns?: Column[] } | { calculatedColumns?: Column[] }, + ) => { + if ('databaseColumns' in obj && obj.databaseColumns) { + setDatabaseColumns(obj.databaseColumns); + } + if ('calculatedColumns' in obj && obj.calculatedColumns) { + setCalculatedColumns(obj.calculatedColumns); + } + }, + [], + ); - async onQueryRun() { - const databaseId = this.state.datasource.database?.id; - const { sql } = this.state.datasource; + // Effect to trigger validation after column changes + useEffect(() => { + if (isComponentMounted.current) { + validateAndChange(); + } + }, [databaseColumns, calculatedColumns]); + + const getSQLLabUrl = useCallback(() => { + const queryParams = new URLSearchParams({ + dbid: String(datasource.database?.id ?? ''), + sql: datasource.sql ?? '', + name: datasource.datasource_name ?? '', + schema: datasource.schema ?? '', + autorun: 'true', + isDataset: 'true', + }); + return makeUrl(`/sqllab/?${queryParams.toString()}`); + }, [datasource]); + + const openOnSqlLab = useCallback(() => { + window.open(getSQLLabUrl(), '_blank', 'noopener,noreferrer'); + }, [getSQLLabUrl]); + + const onQueryRun = useCallback(async () => { + const databaseId = datasource.database?.id; + const { sql } = datasource; if (!databaseId || !sql) { return; } - this.props.runQuery({ - client_id: this.props.database?.clientId, + runQuery({ + client_id: database?.clientId, database_id: databaseId, runAsync: false, - catalog: this.state.datasource.catalog, - schema: this.state.datasource.schema, + catalog: datasource.catalog, + schema: datasource.schema, sql, tmp_table_name: '', select_as_cta: false, @@ -1099,131 +1142,60 @@ class DatasourceEditor extends PureComponent< queryLimit: 25, expand_data: true, }); - } + }, [datasource, database?.clientId, runQuery]); - /** - * Formats SQL query using the formatQuery action. - * Aborts any pending format requests before starting a new one. - */ - async onQueryFormat() { - const { datasource } = this.state; - if (!datasource.sql || !this.state.isEditMode) { + const onQueryFormat = useCallback(async () => { + if (!datasource.sql || !isEditMode) { return; } // Abort previous formatQuery if still pending - if (this.abortControllers.formatQuery) { - this.abortControllers.formatQuery.abort(); + if (abortControllers.current.formatQuery) { + abortControllers.current.formatQuery.abort(); } - this.abortControllers.formatQuery = new AbortController(); - const { signal } = this.abortControllers.formatQuery; + abortControllers.current.formatQuery = new AbortController(); + const { signal } = abortControllers.current.formatQuery; try { - const response = await this.props.formatQuery(datasource.sql, { signal }); + const response = await formatQueryAction(datasource.sql, { signal }); - this.onDatasourcePropChange('sql', response.json.result); - this.props.addSuccessToast(t('SQL was formatted')); - } catch (error) { - if (error.name === 'AbortError') return; + onDatasourcePropChange('sql', response.json.result); + addSuccessToast(t('SQL was formatted')); + } catch (error: unknown) { + if ((error as Error).name === 'AbortError') return; - const { error: clientError, statusText } = - await getClientErrorObject(error); + const { error: clientError, statusText } = await getClientErrorObject( + error as Response, + ); - this.props.addDangerToast( + addDangerToast( clientError || statusText || t('An error occurred while formatting SQL'), ); } finally { - this.abortControllers.formatQuery = null; + abortControllers.current.formatQuery = null; } - } - - getSQLLabUrl() { - const queryParams = new URLSearchParams({ - dbid: String(this.state.datasource.database?.id ?? ''), - sql: this.state.datasource.sql ?? '', - name: this.state.datasource.datasource_name ?? '', - schema: this.state.datasource.schema ?? '', - autorun: 'true', - isDataset: 'true', - }); - return makeUrl(`/sqllab/?${queryParams.toString()}`); - } - - openOnSqlLab() { - window.open(this.getSQLLabUrl(), '_blank', 'noopener,noreferrer'); - } - - tableChangeAndSyncMetadata() { - this.validate(() => { - this.syncMetadata(); - this.onChange(); - }); - } - - /** - * Formats SQL query using the SQL format API endpoint. - * Aborts any pending format requests before starting a new one. - */ - async formatSql() { - const { datasource } = this.state; - if (!datasource.sql) { - return; - } - - // Abort previous formatSql if still pending - if (this.abortControllers.formatSql) { - this.abortControllers.formatSql.abort(); - } - - this.abortControllers.formatSql = new AbortController(); - const { signal } = this.abortControllers.formatSql; - - try { - const response = await SupersetClient.post({ - endpoint: '/api/v1/sql/format', - body: JSON.stringify({ sql: datasource.sql }), - headers: { 'Content-Type': 'application/json' }, - signal, - }); - - this.onDatasourcePropChange('sql', response.json.result); - this.props.addSuccessToast(t('SQL was formatted')); - } catch (error) { - if (error.name === 'AbortError') return; - - const { error: clientError, statusText } = - await getClientErrorObject(error); - - this.props.addDangerToast( - clientError || - statusText || - t('An error occurred while formatting SQL'), - ); - } finally { - this.abortControllers.formatSql = null; - } - } - - /** - * Syncs dataset columns with the database schema. - * Fetches column metadata from the underlying table/view and updates the dataset. - * Aborts any pending sync requests before starting a new one. - */ - async syncMetadata() { - const { datasource } = this.state; + }, [ + datasource.sql, + isEditMode, + formatQueryAction, + onDatasourcePropChange, + addSuccessToast, + addDangerToast, + ]); + const syncMetadata = useCallback(async () => { // Abort previous syncMetadata if still pending - if (this.abortControllers.syncMetadata) { - this.abortControllers.syncMetadata.abort(); + if (abortControllers.current.syncMetadata) { + abortControllers.current.syncMetadata.abort(); } - this.abortControllers.syncMetadata = new AbortController(); - const { signal } = this.abortControllers.syncMetadata; + abortControllers.current.syncMetadata = new AbortController(); + const { signal } = abortControllers.current.syncMetadata; - this.setState({ metadataLoading: true }); + setMetadataLoading(true); try { const newCols = await fetchSyncedColumns(datasource, signal); @@ -1231,11 +1203,11 @@ class DatasourceEditor extends PureComponent< const columnChanges = updateColumns( datasource.columns, newCols, - this.props.addSuccessToast, + addSuccessToast, ); - this.setColumns({ + setColumns({ databaseColumns: columnChanges.finalColumns.filter( - col => !col.expression, // remove calculated columns + col => !col.expression, ) as Column[], }); @@ -1243,229 +1215,285 @@ class DatasourceEditor extends PureComponent< clearDatasetCache(datasource.id); } - this.props.addSuccessToast(t('Metadata has been synced')); - this.setState({ metadataLoading: false }); - } catch (error) { - if (error.name === 'AbortError') { - // Only update state if still mounted (abort may happen during unmount) - if (this.isComponentMounted) { - this.setState({ metadataLoading: false }); + addSuccessToast(t('Metadata has been synced')); + setMetadataLoading(false); + } catch (error: unknown) { + if ((error as Error).name === 'AbortError') { + if (isComponentMounted.current) { + setMetadataLoading(false); } return; } - const { error: clientError, statusText } = - await getClientErrorObject(error); - - this.props.addDangerToast( - clientError || statusText || t('An error has occurred'), + const { error: clientError, statusText } = await getClientErrorObject( + error as Response, ); - this.setState({ metadataLoading: false }); + + addDangerToast(clientError || statusText || t('An error has occurred')); + setMetadataLoading(false); } finally { - this.abortControllers.syncMetadata = null; + abortControllers.current.syncMetadata = null; } - } + }, [datasource, addSuccessToast, addDangerToast, setColumns]); - /** - * Fetches chart usage data for this dataset (which charts use this dataset). - * Aborts any pending fetch requests before starting a new one. - * - * @param {number} page - Page number (1-indexed) - * @param {number} pageSize - Number of results per page - * @param {string} sortColumn - Column to sort by - * @param {string} sortDirection - Sort direction ('asc' or 'desc') - * @returns {Promise<{charts: Array, count: number, ids: Array}>} Chart usage data - */ - async fetchUsageData( - page = 1, - pageSize = 25, - sortColumn = 'changed_on_delta_humanized', - sortDirection = 'desc', - ) { - const { datasource } = this.state; + const fetchUsageData = useCallback( + async ( + page = 1, + pageSize = 25, + sortColumn = 'changed_on_delta_humanized', + sortDirection = 'desc', + ) => { + // Abort previous fetchUsageData if still pending + if (abortControllers.current.fetchUsageData) { + abortControllers.current.fetchUsageData.abort(); + } - // Abort previous fetchUsageData if still pending - if (this.abortControllers.fetchUsageData) { - this.abortControllers.fetchUsageData.abort(); - } + abortControllers.current.fetchUsageData = new AbortController(); + const { signal } = abortControllers.current.fetchUsageData; - this.abortControllers.fetchUsageData = new AbortController(); - const { signal } = this.abortControllers.fetchUsageData; - - try { - const queryParams = rison.encode({ - columns: [ - 'slice_name', - 'url', - 'certified_by', - 'certification_details', - 'description', - 'owners.first_name', - 'owners.last_name', - 'owners.id', - 'changed_on_delta_humanized', - 'changed_on', - 'changed_by.first_name', - 'changed_by.last_name', - 'changed_by.id', - 'dashboards.id', - 'dashboards.dashboard_title', - 'dashboards.url', - ], - filters: [ - { - col: 'datasource_id', - opr: 'eq', - value: datasource.id, - }, - ], - order_column: sortColumn, - order_direction: sortDirection, - page: page - 1, - page_size: pageSize, - }); - - const { json = {} } = await SupersetClient.get({ - endpoint: `/api/v1/chart/?q=${queryParams}`, - signal, - }); - - const charts = json?.result || []; - const ids = json?.ids || []; - - // Map chart IDs to chart objects - const chartsWithIds = charts.map( - (chart: Omit, index: number) => ({ - ...chart, - id: ids[index], - }), - ); - - // Only update state if not aborted and component still mounted - if (!signal.aborted && this.isComponentMounted) { - this.setState({ - usageCharts: chartsWithIds, - usageChartsCount: json?.count || 0, + try { + const queryParams = rison.encode({ + columns: [ + 'slice_name', + 'url', + 'certified_by', + 'certification_details', + 'description', + 'owners.first_name', + 'owners.last_name', + 'owners.id', + 'changed_on_delta_humanized', + 'changed_on', + 'changed_by.first_name', + 'changed_by.last_name', + 'changed_by.id', + 'dashboards.id', + 'dashboards.dashboard_title', + 'dashboards.url', + ], + filters: [ + { + col: 'datasource_id', + opr: 'eq', + value: datasource.id, + }, + ], + order_column: sortColumn, + order_direction: sortDirection, + page: page - 1, + page_size: pageSize, }); + + const { json = {} } = await SupersetClient.get({ + endpoint: `/api/v1/chart/?q=${queryParams}`, + signal, + }); + + const charts = json?.result || []; + const ids = json?.ids || []; + + const chartsWithIds = charts.map( + (chart: Omit, index: number) => ({ + ...chart, + id: ids[index], + }), + ); + + if (!signal.aborted && isComponentMounted.current) { + setUsageCharts(chartsWithIds); + setUsageChartsCount(json?.count || 0); + } + + return { + charts: chartsWithIds, + count: json?.count || 0, + ids, + }; + } catch (error: unknown) { + if ((error as Error).name === 'AbortError') throw error; + + const { error: clientError, statusText } = await getClientErrorObject( + error as Response, + ); + + addDangerToast( + clientError || + statusText || + t('An error occurred while fetching usage data'), + ); + setUsageCharts([]); + setUsageChartsCount(0); + + return { + charts: [], + count: 0, + ids: [], + }; + } finally { + abortControllers.current.fetchUsageData = null; } + }, + [datasource.id, addDangerToast], + ); - return { - charts: chartsWithIds, - count: json?.count || 0, - ids, - }; - } catch (error) { - // Rethrow AbortError so callers can handle gracefully - if (error.name === 'AbortError') throw error; + const handleTabSelect = useCallback((key: string) => { + setActiveTabKey(key); + }, []); - const { error: clientError, statusText } = - await getClientErrorObject(error); + const sortMetrics = useCallback( + (metrics: Metric[]) => + [...metrics].sort( + ({ id: a }: { id?: number }, { id: b }: { id?: number }) => + (b ?? 0) - (a ?? 0), + ), + [], + ); - this.props.addDangerToast( - clientError || - statusText || - t('An error occurred while fetching usage data'), - ); - this.setState({ - usageCharts: [], - usageChartsCount: 0, + // componentDidMount + useEffect(() => { + isComponentMounted.current = true; + Mousetrap.bind('ctrl+shift+f', e => { + e.preventDefault(); + if (isEditMode) { + onQueryFormat(); + } + return false; + }); + fetchUsageData().catch(error => { + if (error?.name !== 'AbortError') throw error; + }); + + // componentWillUnmount + return () => { + isComponentMounted.current = false; + + // Abort all pending requests + Object.values(abortControllers.current).forEach(controller => { + if (controller) controller.abort(); }); - return { - charts: [], - count: 0, - ids: [], - }; - } finally { - this.abortControllers.fetchUsageData = null; - } - } + Mousetrap.unbind('ctrl+shift+f'); + resetQuery(); + }; + }, []); - findDuplicates(arr: T[], accessor: (obj: T) => string): string[] { - const seen: Record = {}; - const dups: string[] = []; - arr.forEach((obj: T) => { - const item = accessor(obj); - if (item in seen) { - dups.push(item); - } else { - seen[item] = null; + // Update Mousetrap binding when isEditMode changes + useEffect(() => { + Mousetrap.unbind('ctrl+shift+f'); + Mousetrap.bind('ctrl+shift+f', e => { + e.preventDefault(); + if (isEditMode) { + onQueryFormat(); } + return false; }); - return dups; - } + }, [isEditMode, onQueryFormat]); - validate(callback: () => void) { - let errors: string[] = []; - let dups: string[]; - const { datasource } = this.state; + // componentDidUpdate for props.datasource changes + useEffect(() => { + if (!isComponentMounted.current) return; - // Looking for duplicate column_name - dups = this.findDuplicates(datasource.columns, obj => obj.column_name); - errors = errors.concat( - dups.map(name => t('Column name [%s] is duplicated', name)), + const newCalculatedColumns = propsDatasource.columns.filter( + col => !!col.expression, ); - // Looking for duplicate metric_name - dups = this.findDuplicates( - datasource.metrics ?? [], - obj => obj.metric_name, - ); - errors = errors.concat( - dups.map(name => t('Metric name [%s] is duplicated', name)), - ); + if (newCalculatedColumns.length === calculatedColumns.length) { + const orderedCalculatedColumns: Column[] = []; + const usedIds = new Set(); - // Making sure calculatedColumns have an expression defined - const noFilterCalcCols = this.state.calculatedColumns.filter( - col => !col.expression && !col.json, - ); - errors = errors.concat( - noFilterCalcCols.map(col => - t('Calculated column [%s] requires an expression', col.column_name), - ), - ); + calculatedColumns.forEach(currentCol => { + const id = currentCol.id || currentCol.column_name; + const updatedCol = newCalculatedColumns.find( + newCol => (newCol.id || newCol.column_name) === id, + ); + if (updatedCol) { + orderedCalculatedColumns.push(updatedCol); + usedIds.add(id); + } + }); - // validate currency code (skip 'AUTO' - it's a placeholder for auto-detection) - try { - this.state.datasource.metrics?.forEach( - metric => - metric.currency?.symbol && - metric.currency.symbol !== 'AUTO' && - new Intl.NumberFormat('en-US', { - style: 'currency', - currency: metric.currency.symbol, - }), + newCalculatedColumns.forEach(newCol => { + const id = newCol.id || newCol.column_name; + if (!usedIds.has(id)) { + orderedCalculatedColumns.push(newCol); + } + }); + + setCalculatedColumns(orderedCalculatedColumns); + setDatabaseColumns( + propsDatasource.columns.filter(col => !col.expression), ); - } catch { - errors = errors.concat([t('Invalid currency code in saved metrics')]); } + }, [propsDatasource]); - // Validate folders - if (this.state.folders?.length > 0) { - const folderValidation = validateFolders(this.state.folders); - errors = errors.concat(folderValidation.errors); - } + const renderSqlEditorOverlay = useCallback( + () => ( +
css` + position: absolute; + background: ${themeParam.colorBgLayout}; + align-items: center; + display: flex; + height: 100%; + width: 100%; + justify-content: center; + `} + > +
+ + css` + display: block; + margin: ${themeParam.sizeUnit * 4}px auto; + width: fit-content; + color: ${themeParam.colorText}; + `} + > + {t('We are working on your query')} + +
+
+ ), + [], + ); - this.setState({ errors }, callback); - } + const renderOpenInSqlLabLink = useCallback( + (isError = false) => ( + css` + color: ${isError ? themeParam.colorErrorText : themeParam.colorText}; + font-size: ${themeParam.fontSizeSM}px; + text-decoration: underline; + `} + > + {t('Open in SQL lab')} + + ), + [getSQLLabUrl], + ); - handleTabSelect(activeTabKey: string) { - this.setState({ activeTabKey }); - } + const renderSqlErrorMessage = useCallback( + () => ( + css` + font-size: ${themeParam.fontSizeSM}px; + color: ${themeParam.colorErrorText}; + `} + > + {database?.error && t('Error executing query. ')} + {renderOpenInSqlLabLink(true)} + {t(' to check for details.')} + + ), + [database?.error, renderOpenInSqlLabLink], + ); - sortMetrics(metrics: Metric[]) { - return metrics.sort( - ({ id: a }: { id?: number }, { id: b }: { id?: number }) => - (b ?? 0) - (a ?? 0), - ); - } - - renderDefaultColumnSettings() { - const { datasource, databaseColumns, calculatedColumns } = this.state; - const { theme } = this.props; + const renderDefaultColumnSettings = useCallback(() => { const allColumns = [...databaseColumns, ...calculatedColumns]; - // Get datetime-compatible columns for the default datetime dropdown const datetimeColumns = allColumns .filter(col => col.is_dttm) .map(col => ({ @@ -1473,7 +1501,6 @@ class DatasourceEditor extends PureComponent< label: col.verbose_name || col.column_name, })); - // String columns + untyped calculated columns for the currency code dropdown const stringColumns = allColumns .filter( col => @@ -1505,7 +1532,7 @@ class DatasourceEditor extends PureComponent< options={datetimeColumns} value={datasource.main_dttm_col} onChange={value => - this.onDatasourceChange({ + onDatasourceChange({ ...datasource, main_dttm_col: value as string | undefined, }) @@ -1529,7 +1556,7 @@ class DatasourceEditor extends PureComponent< options={stringColumns} value={datasource.currency_code_column} onChange={value => - this.onDatasourceChange({ + onDatasourceChange({ ...datasource, currency_code_column: value as string | undefined, }) @@ -1542,15 +1569,20 @@ class DatasourceEditor extends PureComponent< ); - } + }, [ + databaseColumns, + calculatedColumns, + theme?.sizeUnit, + datasource, + onDatasourceChange, + ]); - renderSettingsFieldset() { - const { datasource } = this.state; - return ( + const renderSettingsFieldset = useCallback( + () => (
} /> - {this.state.isSqla && ( + {isSqla && ( )} - {this.state.isSqla && ( + {isSqla && ( { - this.onDatasourceChange({ ...datasource, owners: newOwners }); + onDatasourceChange({ ...datasource, owners: newOwners }); }} />
- ); - } + ), + [datasource, onDatasourceChange, isSqla], + ); - renderAdvancedFieldset() { - const { datasource } = this.state; - return ( + const renderAdvancedFieldset = useCallback( + () => (
- {this.state.isSqla && ( + {isSqla && ( } />
- ); - } - - renderSpatialTab() { - const { datasource } = this.state; - const { spatials, all_cols: allCols } = datasource; - - return { - key: TABS_KEYS.SPATIAL, - label: , - children: ( - ({ - name: t(''), - type: t(''), - config: null, - })} - collection={spatials ?? []} - allowDeletes - itemRenderers={{ - name: (d, onChange) => ( - - ), - config: (v, onChange) => ( - [col, col] as [string, string])} - /> - ), - }} - /> - ), - }; - } - - renderSqlEditorOverlay = () => ( -
css` - position: absolute; - background: ${theme.colorBgLayout}; - align-items: center; - display: flex; - height: 100%; - width: 100%; - justify-content: center; - `} - > -
- - css` - display: block; - margin: ${theme.sizeUnit * 4}px auto; - width: fit-content; - color: ${theme.colorText}; - `} - > - {t('We are working on your query')} - -
-
+ ), + [datasource, onDatasourceChange, isSqla], ); - renderOpenInSqlLabLink(isError = false) { - return ( - css` - color: ${isError ? theme.colorErrorText : theme.colorText}; - font-size: ${theme.fontSizeSM}px; - text-decoration: underline; - `} - > - {t('Open in SQL lab')} - - ); - } - - renderSqlErrorMessage = () => ( - css` - font-size: ${theme.fontSizeSM}px; - color: ${theme.colorErrorText}; - `} - > - {this.props.database?.error && t('Error executing query. ')} - {this.renderOpenInSqlLabLink(true)} - {t(' to check for details.')} - - ); - - renderSourceFieldset() { - const { datasource } = this.state; - - return ( + const renderSourceFieldset = useCallback( + () => (
css` - color: ${theme.colorTextTertiary}; + css={themeParam => css` + color: ${themeParam.colorTextTertiary}; `} role="button" tabIndex={0} - onClick={this.onChangeEditMode} + onClick={onChangeEditMode} > - {this.state.isEditMode ? ( + {isEditMode ? ( css` - margin: auto ${theme.sizeUnit}px auto 0; + css={themeParam => css` + margin: auto ${themeParam.sizeUnit}px auto 0; `} /> ) : ( ({ - margin: `auto ${theme.sizeUnit}px auto 0`, + css={themeParam => ({ + margin: `auto ${themeParam.sizeUnit}px auto 0`, })} /> )} - {!this.state.isEditMode && ( -
{t('Click the lock to make changes.')}
- )} - {this.state.isEditMode && ( + {!isEditMode &&
{t('Click the lock to make changes.')}
} + {isEditMode && (
{t('Click the lock to prevent further changes.')}
)}
css` - margin-top: ${theme.sizeUnit * 3}px; + css={themeParam => css` + margin-top: ${themeParam.sizeUnit * 3}px; display: flex; - gap: ${theme.sizeUnit * 4}px; + gap: ${themeParam.sizeUnit * 4}px; `} > {DATASOURCE_TYPES_ARR.map(type => ( onDatasourceTypeChange(type.key)} + checked={datasourceType === type.key} + disabled={!isEditMode} > {type.label} ))}
-
- {this.state.datasourceType === DATASOURCE_TYPES.virtual.key && ( +
+ {datasourceType === DATASOURCE_TYPES.virtual.key && (
- {this.state.isSqla && ( + {isSqla && ( <> - this.state.isEditMode && - this.onDatasourcePropChange('catalog', catalog) + isEditMode && + onDatasourcePropChange('catalog', catalog) } onSchemaChange={schema => - this.state.isEditMode && - this.onDatasourcePropChange('schema', schema) + isEditMode && + onDatasourcePropChange('schema', schema) } - onDbChange={database => - this.state.isEditMode && - this.onDatasourcePropChange('database', database) + onDbChange={db => + isEditMode && + onDatasourcePropChange('database', db) } formMode={false} - handleError={this.props.addDangerToast} - readOnly={!this.state.isEditMode} + handleError={addDangerToast} + readOnly={!isEditMode} />
} @@ -1904,10 +1831,10 @@ class DatasourceEditor extends PureComponent< { - this.onDatasourcePropChange('table_name', table); + onDatasourcePropChange('table_name', table); }} placeholder={t('Dataset name')} - disabled={!this.state.isEditMode} + disabled={!isEditMode} /> } /> @@ -1924,17 +1851,16 @@ class DatasourceEditor extends PureComponent< 'columns in your dataset will be synced when saving the dataset.', )} control={ - this.props.database?.isLoading ? ( + database?.isLoading ? ( <> - {this.renderSqlEditorOverlay()} + {renderSqlEditorOverlay()} { - this.onQueryFormat(); + onQueryFormat(); }, }, ]} @@ -1942,22 +1868,21 @@ class DatasourceEditor extends PureComponent< offerEditInModal={false} minLines={10} maxLines={Infinity} - readOnly={!this.state.isEditMode} + readOnly={!isEditMode} resize="both" /> ) : ( css` - margin-top: ${theme.sizeUnit * 3}px; + css={themeParam => css` + margin-top: ${themeParam.sizeUnit * 3}px; `} hotkeys={[ { name: 'formatQuery', key: 'ctrl+shift+f', - descr: t('Format SQL query'), func: () => { - this.onQueryFormat(); + onQueryFormat(); }, }, ]} @@ -1965,7 +1890,7 @@ class DatasourceEditor extends PureComponent< offerEditInModal={false} minLines={10} maxLines={Infinity} - readOnly={!this.state.isEditMode} + readOnly={!isEditMode} resize="both" /> ) @@ -1981,64 +1906,62 @@ class DatasourceEditor extends PureComponent< `} >
} /> - {this.props.database?.queryResult && ( + {database?.queryResult && ( <>
css` - margin-bottom: ${theme.sizeUnit}px; + css={themeParam => css` + margin-bottom: ${themeParam.sizeUnit}px; `} > css` - color: ${theme.colorText}; - font-size: ${theme.fontSizeSM}px; + css={themeParam => css` + color: ${themeParam.colorText}; + font-size: ${themeParam.fontSizeSM}px; `} > {t( 'In this view you can preview the first 25 rows. ', )} - {this.renderOpenInSqlLabLink()} + {renderOpenInSqlLabLink()} css` - color: ${theme.colorText}; - font-size: ${theme.fontSizeSM}px; + css={themeParam => css` + color: ${themeParam.colorText}; + font-size: ${themeParam.fontSizeSM}px; `} > {t(' to see details.')}
col.column_name, ) ?? [] } - expandedColumns={this.props.database?.queryResult?.expanded_columns?.map( + expandedColumns={database?.queryResult?.expanded_columns?.map( col => col.column_name, )} height={300} @@ -2046,14 +1969,14 @@ class DatasourceEditor extends PureComponent< /> )} - {this.props.database?.error && this.renderSqlErrorMessage()} + {database?.error && renderSqlErrorMessage()} )} )} - {this.state.datasourceType === DATASOURCE_TYPES.physical.key && ( + {datasourceType === DATASOURCE_TYPES.physical.key && ( - {this.state.isSqla && ( + {isSqla && ( - this.onDatasourcePropChange('catalog', catalog) + onDatasourcePropChange('catalog', catalog) : undefined } onSchemaChange={ - this.state.isEditMode - ? schema => - this.onDatasourcePropChange('schema', schema) + isEditMode + ? schema => onDatasourcePropChange('schema', schema) : undefined } onDbChange={ - this.state.isEditMode - ? database => - this.onDatasourcePropChange( - 'database', - database, - ) + isEditMode + ? db => onDatasourcePropChange('database', db) : undefined } onTableSelectChange={ - this.state.isEditMode + isEditMode ? table => - this.onDatasourcePropChange('table_name', table) + onDatasourcePropChange('table_name', table) : undefined } - readOnly={!this.state.isEditMode} + readOnly={!isEditMode} /> } @@ -2119,18 +2037,36 @@ class DatasourceEditor extends PureComponent< )} - ); - } + ), + [ + onChangeEditMode, + isEditMode, + datasourceType, + onDatasourceTypeChange, + datasource, + onDatasourceChange, + isSqla, + addDangerToast, + onDatasourcePropChange, + database, + renderSqlEditorOverlay, + onQueryFormat, + openOnSqlLab, + onQueryRun, + renderOpenInSqlLabLink, + renderSqlErrorMessage, + ], + ); - renderErrors() { - if (this.state.errors.length > 0) { + const renderErrors = useCallback(() => { + if (errors.length > 0) { return ( ({ marginBottom: theme.sizeUnit * 4 })} + css={themeParam => ({ marginBottom: themeParam.sizeUnit * 4 })} type="error" message={ <> - {this.state.errors.map(err => ( + {errors.map(err => (
{err}
))} @@ -2139,18 +2075,17 @@ class DatasourceEditor extends PureComponent< ); } return null; - } + }, [errors]); - renderMetricCollection() { - const { datasource, metricSearchTerm } = this.state; + const renderMetricCollection = useCallback(() => { const { metrics } = datasource; - const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : []; + const sortedMetrics = metrics?.length ? sortMetrics(metrics) : []; return (
this.setState({ metricSearchTerm: e.target.value })} + onChange={e => setMetricSearchTerm(e.target.value)} style={{ marginBottom: 16, width: 300 }} allowClear /> @@ -2253,7 +2188,7 @@ class DatasourceEditor extends PureComponent< } collection={sortedMetrics} allowAddItem - onChange={this.onDatasourcePropChange.bind(this, 'metrics')} + onChange={(value: unknown) => onDatasourcePropChange('metrics', value)} itemGenerator={() => ({ metric_name: t(''), verbose_name: '', @@ -2265,7 +2200,7 @@ class DatasourceEditor extends PureComponent< }), }} itemRenderers={{ - metric_name: (v, onChange, _, record) => ( + metric_name: (v, onItemChange, _, record) => ( {record.is_certified && ( ), - verbose_name: (v, onChange) => ( - + verbose_name: (v, onItemChange) => ( + ), - expression: (v, onChange) => ( + expression: (v, onItemChange) => ( ), - description: (v, onChange, label) => ( + description: (v, onItemChange, label) => ( + } /> ), - d3format: (v, onChange, label) => ( + d3format: (v, onItemChange, label) => ( + } /> ), @@ -2324,297 +2259,240 @@ class DatasourceEditor extends PureComponent< />
); - } + }, [datasource, sortMetrics, onDatasourcePropChange]); - render() { - const { datasource, activeTabKey } = this.state; - const { metrics } = datasource; - const sortedMetrics = metrics?.length ? this.sortMetrics(metrics) : []; + const sortedMetrics = useMemo( + () => (datasource.metrics?.length ? sortMetrics(datasource.metrics) : []), + [datasource.metrics, sortMetrics], + ); - return ( - - {this.renderErrors()} - ({ marginBottom: theme.sizeUnit * 4 })} - type="warning" - message={ - <> - {' '} - {t('Be careful.')} - {t( - 'Changing these settings will affect all charts using this dataset, including charts owned by other people.', - )} - - } - /> - [ + { + key: TABS_KEYS.SOURCE, + label: t('Source'), + children: renderSourceFieldset(), + }, + { + key: TABS_KEYS.METRICS, + label: ( + + ), + children: renderMetricCollection(), + }, + { + key: TABS_KEYS.COLUMNS, + label: ( + + ), + children: ( + + {renderDefaultColumnSettings()} + + {t('Column Settings')} + + + + + + + setColumnSearchTerm(e.target.value)} + style={{ marginBottom: 16, width: 300 }} + allowClear + /> + setColumns({ databaseColumns: cols })} + onDatasourceChange={onDatasourceChange} + /> + {metadataLoading && } + + ), + }, + { + key: TABS_KEYS.CALCULATED_COLUMNS, + label: ( + + ), + children: ( + + {renderDefaultColumnSettings()} + + {t('Column Settings')} + + setCalculatedColumnSearchTerm(e.target.value)} + style={{ marginBottom: 16, width: 300 }} + allowClear + /> + setColumns({ calculatedColumns: cols })} + columnLabelTooltips={{ + column_name: t( + 'This field is used as a unique identifier to attach ' + + 'the calculated dimension to charts. It is also used ' + + 'as the alias in the SQL query.', + ), + }} + onDatasourceChange={onDatasourceChange} + datasource={datasource} + editableColumnName + showExpression + allowAddItem + allowEditDataType + itemGenerator={() => ({ + column_name: t(''), + filterable: true, + groupby: true, + expression: t(''), + expanded: true, + })} + /> + + ), + }, + { + key: TABS_KEYS.USAGE, + label: ( + + ), + children: ( + + ['charts'] + } + totalCount={usageChartsCount} + onFetchCharts={fetchUsageData} + addDangerToast={addDangerToast} + /> + + ), + }, + ...(isFeatureEnabled(FeatureFlag.DatasetFolders) + ? [ { - key: TABS_KEYS.SOURCE, - label: t('Source'), - children: this.renderSourceFieldset(), - }, - { - key: TABS_KEYS.METRICS, + key: TABS_KEYS.FOLDERS, label: ( - ), - children: this.renderMetricCollection(), - }, - { - key: TABS_KEYS.COLUMNS, - label: ( - ), children: ( - - {this.renderDefaultColumnSettings()} - - - - - - - this.setState({ columnSearchTerm: e.target.value }) - } - style={{ marginBottom: 16, width: 300 }} - allowClear - /> - - this.setColumns({ databaseColumns }) - } - onDatasourceChange={this.onDatasourceChange} - /> - {this.state.metadataLoading && } - - ), - }, - { - key: TABS_KEYS.CALCULATED_COLUMNS, - label: ( - ), - children: ( - - {this.renderDefaultColumnSettings()} - - this.setState({ - calculatedColumnSearchTerm: e.target.value, - }) - } - style={{ marginBottom: 16, width: 300 }} - allowClear - /> - - this.setColumns({ calculatedColumns }) - } - columnLabelTooltips={{ - column_name: t( - 'This field is used as a unique identifier to attach ' + - 'the calculated dimension to charts. It is also used ' + - 'as the alias in the SQL query.', - ), - }} - onDatasourceChange={this.onDatasourceChange} - datasource={datasource} - editableColumnName - showExpression - allowAddItem - allowEditDataType - itemGenerator={() => ({ - column_name: t(''), - filterable: true, - groupby: true, - expression: t(''), - expanded: true, - })} - /> - - ), }, - { - key: TABS_KEYS.USAGE, - label: ( - - ), - children: ( - - ['charts'] - } - totalCount={this.state.usageChartsCount} - onFetchCharts={this.fetchUsageData} - addDangerToast={this.props.addDangerToast} - /> - - ), - }, - ...(isFeatureEnabled(FeatureFlag.DatasetFolders) - ? [ - { - key: TABS_KEYS.FOLDERS, - label: ( - - ), - children: ( - - ), - }, - ] - : []), - { - key: TABS_KEYS.SETTINGS, - label: t('Settings'), - children: ( - - - - {this.renderSettingsFieldset()} - - - - - {this.renderAdvancedFieldset()} - - - - ), - }, - ]} - /> - - ); - } + ] + : []), + { + key: TABS_KEYS.SETTINGS, + label: t('Settings'), + children: ( + + + {renderSettingsFieldset()} + + + {renderAdvancedFieldset()} + + + ), + }, + ], + [ + renderSourceFieldset, + sortedMetrics, + renderMetricCollection, + databaseColumns, + renderDefaultColumnSettings, + syncMetadata, + isEditMode, + datasource, + setColumns, + onDatasourceChange, + metadataLoading, + calculatedColumns, + columnSearchTerm, + calculatedColumnSearchTerm, + usageChartsCount, + usageCharts, + fetchUsageData, + addDangerToast, + folders, + folderCount, + handleFoldersChange, + renderSettingsFieldset, + renderAdvancedFieldset, + ], + ); - componentDidUpdate(prevProps: DatasourceEditorProps): void { - // Preserve calculated columns order when props change to prevent jumping - if (this.props.datasource !== prevProps.datasource) { - const newCalculatedColumns = this.props.datasource.columns.filter( - col => !!col.expression, - ); - const currentCalculatedColumns = this.state.calculatedColumns; - - if (newCalculatedColumns.length === currentCalculatedColumns.length) { - // Try to preserve the order by matching with existing calculated columns - const orderedCalculatedColumns: Column[] = []; - const usedIds = new Set(); - - // First, add existing columns in their current order - currentCalculatedColumns.forEach(currentCol => { - const id = currentCol.id || currentCol.column_name; - const updatedCol = newCalculatedColumns.find( - newCol => (newCol.id || newCol.column_name) === id, - ); - if (updatedCol) { - orderedCalculatedColumns.push(updatedCol); - usedIds.add(id); - } - }); - - // Then add any new columns that weren't in the current list - newCalculatedColumns.forEach(newCol => { - const id = newCol.id || newCol.column_name; - if (!usedIds.has(id)) { - orderedCalculatedColumns.push(newCol); - } - }); - - this.setState({ - calculatedColumns: orderedCalculatedColumns, - databaseColumns: this.props.datasource.columns.filter( - col => !col.expression, - ), - }); - } - } - } - - componentDidMount() { - this.isComponentMounted = true; - Mousetrap.bind('ctrl+shift+f', e => { - e.preventDefault(); - if (this.state.isEditMode) { - this.onQueryFormat(); - } - return false; - }); - this.fetchUsageData().catch(error => { - if (error?.name !== 'AbortError') throw error; - }); - } - - componentWillUnmount() { - this.isComponentMounted = false; - - // Abort all pending requests - Object.values(this.abortControllers).forEach(controller => { - if (controller) controller.abort(); - }); - - Mousetrap.unbind('ctrl+shift+f'); - this.props.resetQuery(); - } + return ( + + {renderErrors()} + ({ marginBottom: themeParam.sizeUnit * 4 })} + type="warning" + message={ + <> + {' '} + {t('Be careful.')} + {t( + 'Changing these settings will affect all charts using this dataset, including charts owned by other people.', + )} + + } + /> + + + ); } -const DataSourceComponent = withTheme(DatasourceEditor); - -export default withToasts(connector(DataSourceComponent)); +export default withToasts(connector(DatasourceEditor)); diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx index a4bb7733337..97e1e5f5dfb 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditor.test.tsx @@ -337,7 +337,8 @@ test('calls onChange with empty SQL when switching to physical dataset', async ( // Assert that the latest onChange call has empty SQL expect(testProps.onChange).toHaveBeenCalled(); - const updatedDatasource = testProps.onChange.mock.calls[0]; + const lastCallIndex = testProps.onChange.mock.calls.length - 1; + const updatedDatasource = testProps.onChange.mock.calls[lastCallIndex]; expect(updatedDatasource[0].sql).toBe(''); }); diff --git a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditorCurrency.test.tsx b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditorCurrency.test.tsx index 6efa680965b..8406298ceb8 100644 --- a/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditorCurrency.test.tsx +++ b/superset-frontend/src/components/Datasource/components/DatasourceEditor/tests/DatasourceEditorCurrency.test.tsx @@ -105,11 +105,12 @@ test('changes currency position from prefix to suffix', async () => { await selectOption('Suffix', 'Currency prefix or suffix'); await waitFor(() => { - expect(testProps.onChange).toHaveBeenCalledTimes(1); + expect(testProps.onChange).toHaveBeenCalled(); }); - // Verify the exact call arguments - const callArg = testProps.onChange.mock.calls[0][0]; + // Verify the exact call arguments - check the latest call + const lastCallIndex = testProps.onChange.mock.calls.length - 1; + const callArg = testProps.onChange.mock.calls[lastCallIndex][0]; const metrics = callArg.metrics || []; const updatedMetric = metrics.find( (m: MetricType) => m.currency?.symbolPosition === 'suffix', @@ -126,11 +127,12 @@ test('changes currency symbol from USD to GBP', async () => { await selectOption('£ (GBP)', 'Currency symbol'); await waitFor(() => { - expect(testProps.onChange).toHaveBeenCalledTimes(1); + expect(testProps.onChange).toHaveBeenCalled(); }); - // Verify the exact call arguments - const callArg = testProps.onChange.mock.calls[0][0]; + // Verify the exact call arguments - check the latest call + const lastCallIndex = testProps.onChange.mock.calls.length - 1; + const callArg = testProps.onChange.mock.calls[lastCallIndex][0]; const metrics = callArg.metrics || []; const updatedMetric = metrics.find( (m: MetricType) => m.currency?.symbol === 'GBP', diff --git a/superset-frontend/src/components/ErrorBoundary/index.tsx b/superset-frontend/src/components/ErrorBoundary/index.tsx index 8dcef63b406..cf24d5f6268 100644 --- a/superset-frontend/src/components/ErrorBoundary/index.tsx +++ b/superset-frontend/src/components/ErrorBoundary/index.tsx @@ -21,6 +21,7 @@ import { t } from '@apache-superset/core'; import { ErrorAlert } from '../ErrorMessage'; import type { ErrorBoundaryProps, ErrorBoundaryState } from './types'; +// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- componentDidCatch requires class component export class ErrorBoundary extends Component< ErrorBoundaryProps, ErrorBoundaryState diff --git a/superset-frontend/src/dashboard/components/Dashboard.tsx b/superset-frontend/src/dashboard/components/Dashboard.tsx index f760156d95a..076c475b22b 100644 --- a/superset-frontend/src/dashboard/components/Dashboard.tsx +++ b/superset-frontend/src/dashboard/components/Dashboard.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, ReactNode } from 'react'; +import { ReactNode, useCallback, useContext, useEffect, useRef } from 'react'; import { t } from '@apache-superset/core/ui'; import { JsonObject } from '@superset-ui/core'; @@ -90,165 +90,61 @@ interface VisibilityEventData { ts: number; } -class Dashboard extends PureComponent { - static contextType = PluginContext; - - // Use type assertion when accessing context instead of declare field - // to avoid babel transformation issues in Jest - - static defaultProps = { - timeout: 60, - userId: '', - }; - - appliedFilters: ActiveFilters; - - appliedOwnDataCharts: JsonObject; - - visibilityEventData: VisibilityEventData; - - static onBeforeUnload(hasChanged: boolean): void { - if (hasChanged) { - window.addEventListener('beforeunload', Dashboard.unload); - } else { - window.removeEventListener('beforeunload', Dashboard.unload); - } +function onBeforeUnload(hasChanged: boolean): void { + if (hasChanged) { + window.addEventListener('beforeunload', unload); + } else { + window.removeEventListener('beforeunload', unload); } +} - static unload(): string { - const message = t('You have unsaved changes.'); - // Gecko + IE: returnValue is typed as boolean but historically accepts string - (window.event as BeforeUnloadEvent).returnValue = message; - return message; // Gecko + Webkit, Safari, Chrome etc. - } +function unload(): string { + const message = t('You have unsaved changes.'); + // Gecko + IE: returnValue is typed as boolean but historically accepts string + (window.event as BeforeUnloadEvent).returnValue = message; + return message; // Gecko + Webkit, Safari, Chrome etc. +} - constructor(props: DashboardProps) { - super(props); - this.appliedFilters = props.activeFilters ?? {}; - this.appliedOwnDataCharts = props.ownDataCharts ?? {}; - this.visibilityEventData = { start_offset: 0, ts: 0 }; - this.onVisibilityChange = this.onVisibilityChange.bind(this); - } +function Dashboard({ + actions, + dashboardId, + editMode, + isPublished, + hasUnsavedChanges, + slices, + activeFilters, + chartConfiguration, + datasources, + ownDataCharts, + layout, + impressionId, + timeout = 60, + userId = '', + children, +}: DashboardProps): JSX.Element { + const context = useContext(PluginContext) as PluginContextType; - componentDidMount(): void { - const bootstrapData = getBootstrapData(); - const { editMode, isPublished, layout } = this.props; - const eventData: Record = { - is_soft_navigation: Logger.timeOriginOffset > 0, - is_edit_mode: editMode, - mount_duration: Logger.getTimestamp(), - is_empty: isDashboardEmpty(layout), - is_published: isPublished, - bootstrap_data_length: JSON.stringify(bootstrapData).length, - }; - const directLinkComponentId = getLocationHash(); - if (directLinkComponentId) { - eventData.target_id = directLinkComponentId; - } - this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData); + // Use refs to track mutable values that persist across renders + const appliedFiltersRef = useRef(activeFilters ?? {}); + const appliedOwnDataChartsRef = useRef(ownDataCharts ?? {}); + const visibilityEventDataRef = useRef({ + start_offset: 0, + ts: 0, + }); + const prevLayoutRef = useRef(layout); + const prevDashboardIdRef = useRef(dashboardId); - // Handle browser tab visibility change - if (document.visibilityState === 'hidden') { - this.visibilityEventData = { - start_offset: Logger.getTimestamp(), - ts: new Date().getTime(), - }; - } - window.addEventListener('visibilitychange', this.onVisibilityChange); - this.applyCharts(); - } - - componentDidUpdate(prevProps: DashboardProps): void { - this.applyCharts(); - const currentChartIds = getChartIdsFromLayout(prevProps.layout); - const nextChartIds = getChartIdsFromLayout(this.props.layout); - - if (prevProps.dashboardId !== this.props.dashboardId) { - // single-page-app navigation check - return; - } - - if (currentChartIds.length < nextChartIds.length) { - const newChartIds = nextChartIds.filter( - key => currentChartIds.indexOf(key) === -1, - ); - newChartIds.forEach(newChartId => - this.props.actions.addSliceToDashboard( - newChartId, - getLayoutComponentFromChartId(this.props.layout, newChartId), - ), - ); - } else if (currentChartIds.length > nextChartIds.length) { - // remove chart - const removedChartIds = currentChartIds.filter( - key => nextChartIds.indexOf(key) === -1, - ); - removedChartIds.forEach(removedChartId => - this.props.actions.removeSliceFromDashboard(removedChartId), - ); - } - } - - applyCharts(): void { - const { - activeFilters, - ownDataCharts, - chartConfiguration, - hasUnsavedChanges, - editMode, - } = this.props; - const { appliedFilters, appliedOwnDataCharts } = this; - if (!chartConfiguration) { - // For a first loading we need to wait for cross filters charts data loaded to get all active filters - // for correct comparing of filters to avoid unnecessary requests - return; - } - - if ( - !editMode && - (!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, { - ignoreUndefined: true, - }) || - !areObjectsEqual(appliedFilters, activeFilters, { - ignoreUndefined: true, - })) - ) { - this.applyFilters(); - } - - if (hasUnsavedChanges) { - Dashboard.onBeforeUnload(true); - } else { - Dashboard.onBeforeUnload(false); - } - } - - componentWillUnmount(): void { - window.removeEventListener('visibilitychange', this.onVisibilityChange); - this.props.actions.clearDataMaskState(); - this.props.actions.clearAllChartStates(); - } - - onVisibilityChange(): void { - if (document.visibilityState === 'hidden') { - // from visible to hidden - this.visibilityEventData = { - start_offset: Logger.getTimestamp(), - ts: new Date().getTime(), - }; - } else if (document.visibilityState === 'visible') { - // from hidden to visible - const logStart = this.visibilityEventData.start_offset; - this.props.actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, { - ...this.visibilityEventData, - duration: Logger.getTimestamp() - logStart, + const refreshCharts = useCallback( + (ids: (string | number)[]): void => { + ids.forEach(id => { + actions.triggerQuery(true, id); }); - } - } + }, + [actions], + ); - applyFilters(): void { - const { appliedFilters } = this; - const { activeFilters, ownDataCharts, slices } = this.props; + const applyFilters = useCallback((): void => { + const appliedFilters = appliedFiltersRef.current; // refresh charts if a filter was removed, added, or changed @@ -258,7 +154,7 @@ class Dashboard extends PureComponent { const allKeys = new Set(currFilterKeys.concat(appliedFilterKeys)); const affectedChartIds: (string | number)[] = getAffectedOwnDataCharts( ownDataCharts, - this.appliedOwnDataCharts, + appliedOwnDataChartsRef.current, ); [...allKeys].forEach(filterKey => { @@ -321,24 +217,144 @@ class Dashboard extends PureComponent { }); // remove dup in affectedChartIds - this.refreshCharts([...new Set(affectedChartIds)]); - this.appliedFilters = activeFilters; - this.appliedOwnDataCharts = ownDataCharts; - } + refreshCharts([...new Set(affectedChartIds)]); + appliedFiltersRef.current = activeFilters; + appliedOwnDataChartsRef.current = ownDataCharts; + }, [activeFilters, ownDataCharts, slices, refreshCharts]); - refreshCharts(ids: (string | number)[]): void { - ids.forEach(id => { - this.props.actions.triggerQuery(true, id); - }); - } - - render(): ReactNode { - const context = this.context as PluginContextType; - if (context.loading) { - return ; + const applyCharts = useCallback((): void => { + if (!chartConfiguration) { + // For a first loading we need to wait for cross filters charts data loaded to get all active filters + // for correct comparing of filters to avoid unnecessary requests + return; } - return this.props.children; + + if ( + !editMode && + (!areObjectsEqual(appliedOwnDataChartsRef.current, ownDataCharts, { + ignoreUndefined: true, + }) || + !areObjectsEqual(appliedFiltersRef.current, activeFilters, { + ignoreUndefined: true, + })) + ) { + applyFilters(); + } + + if (hasUnsavedChanges) { + onBeforeUnload(true); + } else { + onBeforeUnload(false); + } + }, [ + chartConfiguration, + editMode, + ownDataCharts, + activeFilters, + hasUnsavedChanges, + applyFilters, + ]); + + const onVisibilityChange = useCallback((): void => { + if (document.visibilityState === 'hidden') { + // from visible to hidden + visibilityEventDataRef.current = { + start_offset: Logger.getTimestamp(), + ts: new Date().getTime(), + }; + } else if (document.visibilityState === 'visible') { + // from hidden to visible + const logStart = visibilityEventDataRef.current.start_offset; + actions.logEvent(LOG_ACTIONS_HIDE_BROWSER_TAB, { + ...visibilityEventDataRef.current, + duration: Logger.getTimestamp() - logStart, + }); + } + }, [actions]); + + // componentDidMount equivalent + useEffect(() => { + const bootstrapData = getBootstrapData(); + const eventData: Record = { + is_soft_navigation: Logger.timeOriginOffset > 0, + is_edit_mode: editMode, + mount_duration: Logger.getTimestamp(), + is_empty: isDashboardEmpty(layout), + is_published: isPublished, + bootstrap_data_length: JSON.stringify(bootstrapData).length, + }; + const directLinkComponentId = getLocationHash(); + if (directLinkComponentId) { + eventData.target_id = directLinkComponentId; + } + actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData); + + // Handle browser tab visibility change + if (document.visibilityState === 'hidden') { + visibilityEventDataRef.current = { + start_offset: Logger.getTimestamp(), + ts: new Date().getTime(), + }; + } + window.addEventListener('visibilitychange', onVisibilityChange); + + // componentWillUnmount equivalent + return () => { + window.removeEventListener('visibilitychange', onVisibilityChange); + actions.clearDataMaskState(); + actions.clearAllChartStates(); + }; + // Only run on mount/unmount - intentionally excluding deps that would cause re-runs + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Apply charts on every render (like componentDidMount + componentDidUpdate calling applyCharts) + useEffect(() => { + applyCharts(); + }, [applyCharts]); + + // componentDidUpdate equivalent for layout changes + useEffect(() => { + const prevLayout = prevLayoutRef.current; + const prevDashboardId = prevDashboardIdRef.current; + + // Update refs for next comparison + prevLayoutRef.current = layout; + prevDashboardIdRef.current = dashboardId; + + const currentChartIds = getChartIdsFromLayout(prevLayout); + const nextChartIds = getChartIdsFromLayout(layout); + + if (prevDashboardId !== dashboardId) { + // single-page-app navigation check + return; + } + + if (currentChartIds.length < nextChartIds.length) { + const newChartIds = nextChartIds.filter( + key => currentChartIds.indexOf(key) === -1, + ); + newChartIds.forEach(newChartId => + actions.addSliceToDashboard( + newChartId, + getLayoutComponentFromChartId(layout, newChartId), + ), + ); + } else if (currentChartIds.length > nextChartIds.length) { + // remove chart + const removedChartIds = currentChartIds.filter( + key => nextChartIds.indexOf(key) === -1, + ); + removedChartIds.forEach(removedChartId => + actions.removeSliceFromDashboard(removedChartId), + ); + } + }, [layout, dashboardId, actions]); + + if (context.loading) { + return ; } + return <>{children}; } export default Dashboard; diff --git a/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts b/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts index 8ba5405bf30..6b5836544ed 100644 --- a/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts +++ b/superset-frontend/src/dashboard/components/DashboardBuilder/utils.ts @@ -32,8 +32,9 @@ export const getRootLevelTabsComponent = (dashboardLayout: DashboardLayout) => { export const shouldFocusTabs = ( event: { target: { className: string } }, - container: { contains: (arg0: any) => any }, -) => + container: { contains: (arg0: any) => any } | null, + _menuRef: HTMLDivElement | null, +): boolean => // don't focus the tabs when we click on a tab event.target.className === 'ant-tabs-nav-wrap' || - container.contains(event.target); + (container?.contains(event.target) ?? false); diff --git a/superset-frontend/src/dashboard/components/DashboardGrid.tsx b/superset-frontend/src/dashboard/components/DashboardGrid.tsx index 5dde8ab06ab..7398a867570 100644 --- a/superset-frontend/src/dashboard/components/DashboardGrid.tsx +++ b/superset-frontend/src/dashboard/components/DashboardGrid.tsx @@ -16,11 +16,10 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, Fragment } from 'react'; -import { withTheme } from '@emotion/react'; +import { Fragment, useCallback, useRef, useState } from 'react'; import classNames from 'classnames'; import { addAlpha } from '@superset-ui/core'; -import { css, styled, t, type SupersetTheme } from '@apache-superset/core/ui'; +import { css, styled, t, useTheme } from '@apache-superset/core/ui'; import { EmptyState } from '@superset-ui/core/components'; import { Icons } from '@superset-ui/core/components/Icons'; import { navigateTo } from 'src/utils/navigationUtils'; @@ -47,11 +46,6 @@ interface DashboardGridProps { setEditMode?: (editMode: boolean) => void; width: number; dashboardId?: number; - theme: SupersetTheme; -} - -interface DashboardGridState { - isResizing: boolean; } interface DropProps { @@ -130,261 +124,235 @@ const GridColumnGuide = styled.div` `}; `; -class DashboardGrid extends PureComponent< - DashboardGridProps, - DashboardGridState -> { - grid: HTMLDivElement | null; +function DashboardGrid({ + depth, + editMode, + canEdit, + gridComponent, + handleComponentDrop, + isComponentVisible, + resizeComponent, + setDirectPathToChild, + setEditMode, + width, + dashboardId, +}: DashboardGridProps) { + const theme = useTheme(); + const [isResizing, setIsResizing] = useState(false); + const gridRef = useRef(null); - constructor(props: DashboardGridProps) { - super(props); - this.state = { - isResizing: false, - }; - this.grid = null; - this.handleResizeStart = this.handleResizeStart.bind(this); - this.handleResize = this.handleResize.bind(this); - this.handleResizeStop = this.handleResizeStop.bind(this); - this.handleTopDropTargetDrop = this.handleTopDropTargetDrop.bind(this); - this.getRowGuidePosition = this.getRowGuidePosition.bind(this); - this.setGridRef = this.setGridRef.bind(this); - this.handleChangeTab = this.handleChangeTab.bind(this); - } + const setGridRef = useCallback((ref: HTMLDivElement | null): void => { + gridRef.current = ref; + }, []); - getRowGuidePosition(resizeRef: HTMLElement | null): number | null { - if (resizeRef && this.grid) { - return ( - resizeRef.getBoundingClientRect().bottom - - this.grid.getBoundingClientRect().top - - 2 - ); - } - return null; - } + const handleResizeStart = useCallback((): void => { + setIsResizing(true); + }, []); - setGridRef(ref: HTMLDivElement | null): void { - this.grid = ref; - } + const handleResize = useCallback( + ( + _event: MouseEvent | TouchEvent, + _direction: string, + _elementRef: HTMLElement, + _delta: { width: number; height: number }, + ): void => { + // no-op: resize position tracking not implemented + }, + [], + ); - handleResizeStart(): void { - this.setState(() => ({ - isResizing: true, - })); - } - - handleResize( - _event: MouseEvent | TouchEvent, - _direction: string, - _elementRef: HTMLElement, - _delta: { width: number; height: number }, - ): void { - // no-op: resize position is tracked via getRowGuidePosition - } - - handleResizeStop( - _event: MouseEvent | TouchEvent, - _direction: string, - _elementRef: HTMLElement, - delta: { width: number; height: number }, - id: string, - ): void { - this.props.resizeComponent({ - id, - width: delta.width, - height: delta.height, - }); - - this.setState(() => ({ - isResizing: false, - })); - } - - handleTopDropTargetDrop(dropResult: DropResult): void { - if (dropResult?.destination) { - this.props.handleComponentDrop({ - ...dropResult, - destination: { - ...dropResult.destination, - // force appending as the first child if top drop target - index: 0, - }, + const handleResizeStop = useCallback( + ( + _event: MouseEvent | TouchEvent, + _direction: string, + _elementRef: HTMLElement, + delta: { width: number; height: number }, + id: string, + ): void => { + resizeComponent({ + id, + width: delta.width, + height: delta.height, }); - } - } - handleChangeTab({ pathToTabIndex }: { pathToTabIndex: string[] }): void { - this.props.setDirectPathToChild(pathToTabIndex); - } + setIsResizing(false); + }, + [resizeComponent], + ); - render() { - const { - gridComponent, - handleComponentDrop, - depth, - width, - isComponentVisible, - editMode, - canEdit, - setEditMode, - dashboardId, - theme, - } = this.props; - const columnPlusGutterWidth = - (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT; + const handleTopDropTargetDrop = useCallback( + (dropResult: DropResult): void => { + if (dropResult?.destination) { + handleComponentDrop({ + ...dropResult, + destination: { + ...dropResult.destination, + // force appending as the first child if top drop target + index: 0, + }, + }); + } + }, + [handleComponentDrop], + ); - const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE; - const { isResizing } = this.state; + const handleChangeTab = useCallback( + ({ pathToTabIndex }: { pathToTabIndex: string[] }): void => { + setDirectPathToChild(pathToTabIndex); + }, + [setDirectPathToChild], + ); - const shouldDisplayEmptyState = gridComponent?.children?.length === 0; - const shouldDisplayTopLevelTabEmptyState = - shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE; + const columnPlusGutterWidth = (width + GRID_GUTTER_SIZE) / GRID_COLUMN_COUNT; - const dashboardEmptyState = editMode && ( - - - {t('Create a new chart')} - - } - buttonAction={() => { - navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { - newWindow: true, - }); - }} - image="chart.svg" - /> - ); + const columnWidth = columnPlusGutterWidth - GRID_GUTTER_SIZE; - const topLevelTabEmptyState = editMode ? ( - - - {t('Create a new chart')} - - } - buttonAction={() => { - navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { - newWindow: true, - }); - }} - image="chart.svg" - /> - ) : ( - { - setEditMode?.(true); - } - : undefined - } - image="chart.svg" - /> - ); + const shouldDisplayEmptyState = gridComponent?.children?.length === 0; + const shouldDisplayTopLevelTabEmptyState = + shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE; - return width < 100 ? null : ( - <> - {shouldDisplayEmptyState && ( - - {shouldDisplayTopLevelTabEmptyState - ? topLevelTabEmptyState - : dashboardEmptyState} - - )} -
- - {/* make the area above components droppable */} - {editMode && ( - - {renderDraggableContent} - - )} - {gridComponent?.children?.map((id, index) => ( - - + + {t('Create a new chart')} + + } + buttonAction={() => { + navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { + newWindow: true, + }); + }} + image="chart.svg" + /> + ); + + const topLevelTabEmptyState = editMode ? ( + + + {t('Create a new chart')} + + } + buttonAction={() => { + navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { + newWindow: true, + }); + }} + image="chart.svg" + /> + ) : ( + { + setEditMode?.(true); + } + : undefined + } + image="chart.svg" + /> + ); + + return width < 100 ? null : ( + <> + {shouldDisplayEmptyState && ( + + {shouldDisplayTopLevelTabEmptyState + ? topLevelTabEmptyState + : dashboardEmptyState} + + )} +
+ + {/* make the area above components droppable */} + {editMode && ( + + {renderDraggableContent} + + )} + {gridComponent?.children?.map((id, index) => ( + + + {/* make the area below components droppable */} + {editMode && ( + + {renderDraggableContent} + + )} + + ))} + {isResizing && + Array(GRID_COLUMN_COUNT) + .fill(null) + .map((_, i) => ( + - {/* make the area below components droppable */} - {editMode && ( - - {renderDraggableContent} - - )} - - ))} - {isResizing && - Array(GRID_COLUMN_COUNT) - .fill(null) - .map((_, i) => ( - - ))} - -
- - ); - } + ))} +
+
+ + ); } -export default withTheme(DashboardGrid); +export default DashboardGrid; diff --git a/superset-frontend/src/dashboard/components/PublishedStatus/index.tsx b/superset-frontend/src/dashboard/components/PublishedStatus/index.tsx index b05cd30bf3b..dce12de6cbd 100644 --- a/superset-frontend/src/dashboard/components/PublishedStatus/index.tsx +++ b/superset-frontend/src/dashboard/components/PublishedStatus/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component } from 'react'; +import { useCallback } from 'react'; import { t } from '@apache-superset/core'; import { Tooltip, PublishedLabel } from '@superset-ui/core/components'; import { HeaderProps, HeaderDropdownProps } from '../Header/types'; @@ -43,70 +43,64 @@ const publishedTooltip = t( 'This dashboard is published. Click to make it a draft.', ); -export default class PublishedStatus extends Component { - constructor(props: DashboardPublishedStatusType) { - super(props); - this.togglePublished = this.togglePublished.bind(this); - } +export default function PublishedStatus({ + dashboardId, + userCanEdit, + userCanSave, + isPublished, + savePublished, +}: DashboardPublishedStatusType) { + const togglePublished = useCallback(() => { + savePublished(dashboardId, !isPublished); + }, [dashboardId, isPublished, savePublished]); - togglePublished() { - this.props.savePublished(this.props.dashboardId, !this.props.isPublished); - } - - render() { - const { isPublished, userCanEdit, userCanSave } = this.props; - - // Show everybody the draft badge - if (!isPublished) { - // if they can edit the dash, make the badge a button - if (userCanEdit && userCanSave) { - return ( - -
- -
-
- ); - } + // Show everybody the draft badge + if (!isPublished) { + // if they can edit the dash, make the badge a button + if (userCanEdit && userCanSave) { return ( -
- -
-
- ); - } - - // Show the published badge for the owner of the dashboard to toggle - if (userCanEdit && userCanSave) { - return ( -
); } - - // Don't show anything if one doesn't own the dashboard and it is published - return null; + return ( + +
+ +
+
+ ); } + + // Show the published badge for the owner of the dashboard to toggle + if (userCanEdit && userCanSave) { + return ( + +
+ +
+
+ ); + } + + // Don't show anything if one doesn't own the dashboard and it is published + return null; } diff --git a/superset-frontend/src/dashboard/components/SliceAdder.tsx b/superset-frontend/src/dashboard/components/SliceAdder.tsx index afe5d2056fb..b06875603a3 100644 --- a/superset-frontend/src/dashboard/components/SliceAdder.tsx +++ b/superset-frontend/src/dashboard/components/SliceAdder.tsx @@ -17,13 +17,13 @@ * under the License. */ /* eslint-env browser */ -import { Component } from 'react'; +import { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import AutoSizer from 'react-virtualized-auto-sizer'; import { FixedSizeList as List } from 'react-window'; // @ts-expect-error import { createFilter } from 'react-search-input'; import { t } from '@apache-superset/core'; -import { styled, css } from '@apache-superset/core/ui'; +import { styled, css, useTheme } from '@apache-superset/core/ui'; import { Button, Checkbox, @@ -49,7 +49,6 @@ import { import { debounce, pickBy } from 'lodash'; import { Dispatch } from 'redux'; import { Slice } from 'src/dashboard/types'; -import { withTheme, Theme } from '@emotion/react'; import { navigateTo } from 'src/utils/navigationUtils'; import type { ConnectDragSource } from 'react-dnd'; import AddSliceCard from './AddSliceCard'; @@ -57,7 +56,6 @@ import AddSliceDragPreview from './dnd/AddSliceDragPreview'; import { DragDroppable } from './dnd/DragDroppable'; export type SliceAdderProps = { - theme: Theme; fetchSlices: ( userId?: number, filter_value?: string, @@ -76,14 +74,6 @@ export type SliceAdderProps = { dashboardId: number; }; -type SliceAdderState = { - filteredSlices: Slice[]; - searchTerm: string; - sortBy: keyof Slice; - selectedSliceIdsSet: Set; - showOnlyMyCharts: boolean; -}; - const KEYS_TO_FILTERS = ['slice_name', 'viz_type', 'datasource_name']; const KEYS_TO_SORT = { slice_name: t('name'), @@ -173,295 +163,265 @@ function getFilteredSortedSlices( .filter(createFilter(searchTerm, KEYS_TO_FILTERS)) .sort(sortByComparator(sortBy)); } -class SliceAdder extends Component { - private slicesRequest?: AbortController | Promise; - static defaultProps = { - selectedSliceIds: [], - editMode: false, - errorMessage: '', - }; +function SliceAdder({ + fetchSlices, + updateSlices, + isLoading, + slices, + errorMessage = '', + userId, + selectedSliceIds = [], + editMode = false, + dashboardId, +}: SliceAdderProps) { + const theme = useTheme(); + const slicesRequestRef = useRef>(); - constructor(props: SliceAdderProps) { - super(props); - this.state = { - filteredSlices: [], - searchTerm: '', - sortBy: DEFAULT_SORT_KEY, - selectedSliceIdsSet: new Set(props.selectedSliceIds), - showOnlyMyCharts: getItem( - LocalStorageKeys.DashboardEditorShowOnlyMyCharts, - true, - ), - }; - this.rowRenderer = this.rowRenderer.bind(this); - this.searchUpdated = this.searchUpdated.bind(this); - this.handleSelect = this.handleSelect.bind(this); - this.userIdForFetch = this.userIdForFetch.bind(this); - this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this); - } + const [searchTerm, setSearchTerm] = useState(''); + const [sortBy, setSortBy] = useState(DEFAULT_SORT_KEY); + const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState( + () => new Set(selectedSliceIds), + ); + const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() => + getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true), + ); - userIdForFetch() { - return this.state.showOnlyMyCharts ? this.props.userId : undefined; - } - - componentDidMount() { - this.slicesRequest = this.props.fetchSlices( - this.userIdForFetch(), - '', - this.state.sortBy, - ); - } - - componentDidUpdate(prevProps: SliceAdderProps) { - const nextState: SliceAdderState = {} as SliceAdderState; - if (this.props.lastUpdated !== prevProps.lastUpdated) { - nextState.filteredSlices = getFilteredSortedSlices( - this.props.slices, - this.state.searchTerm, - this.state.sortBy, - this.state.showOnlyMyCharts, - this.props.userId, - ); - } - - if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) { - nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds); - } - - if (Object.keys(nextState).length) { - this.setState(nextState); - } - } - - componentWillUnmount() { - // Clears the redux store keeping only selected items - const selectedSlices = pickBy(this.props.slices, (value: Slice) => - this.state.selectedSliceIdsSet.has(value.slice_id), - ); - - this.props.updateSlices(selectedSlices); - if (this.slicesRequest instanceof AbortController) { - this.slicesRequest.abort(); - } - } - - handleChange = debounce(value => { - this.searchUpdated(value); - this.slicesRequest = this.props.fetchSlices( - this.userIdForFetch(), - value, - this.state.sortBy, - ); - }, 300); - - searchUpdated(searchTerm: string) { - this.setState(prevState => ({ - searchTerm, - filteredSlices: getFilteredSortedSlices( - this.props.slices, + const filteredSlices = useMemo( + () => + getFilteredSortedSlices( + slices, searchTerm, - prevState.sortBy, - prevState.showOnlyMyCharts, - this.props.userId, - ), - })); - } - - handleSelect(sortBy: keyof Slice) { - this.setState(prevState => ({ - sortBy, - filteredSlices: getFilteredSortedSlices( - this.props.slices, - prevState.searchTerm, sortBy, - prevState.showOnlyMyCharts, - this.props.userId, - ), - })); - this.slicesRequest = this.props.fetchSlices( - this.userIdForFetch(), - this.state.searchTerm, - sortBy, - ); - } - - rowRenderer({ index, style }: { index: number; style: React.CSSProperties }) { - const { filteredSlices, selectedSliceIdsSet } = this.state; - const cellData = filteredSlices[index]; - - const isSelected = selectedSliceIdsSet.has(cellData.slice_id); - const type = CHART_TYPE; - const id = NEW_CHART_ID; - - const meta = { - chartId: cellData.slice_id, - sliceName: cellData.slice_name, - }; - return ( - - {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( - - )} - - ); - } - - onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => { - if (!showOnlyMyCharts) { - this.slicesRequest = this.props.fetchSlices( - undefined, - this.state.searchTerm, - this.state.sortBy, - ); - } - this.setState(prevState => ({ - showOnlyMyCharts, - filteredSlices: getFilteredSortedSlices( - this.props.slices, - prevState.searchTerm, - prevState.sortBy, showOnlyMyCharts, - this.props.userId, + userId, ), - })); - setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, showOnlyMyCharts); - }; + [slices, searchTerm, sortBy, showOnlyMyCharts, userId], + ); - render() { - const { theme } = this.props; - return ( -
span > :first-of-type { - margin-right: 0; + const userIdForFetch = useCallback( + () => (showOnlyMyCharts ? userId : undefined), + [showOnlyMyCharts, userId], + ); + + // componentDidMount + useEffect(() => { + slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy); + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Update selectedSliceIdsSet when selectedSliceIds prop changes + useEffect(() => { + setSelectedSliceIdsSet(new Set(selectedSliceIds)); + }, [selectedSliceIds]); + + // componentWillUnmount + useEffect( + () => () => { + // Clears the redux store keeping only selected items + const selectedSlices = pickBy(slices, (value: Slice) => + selectedSliceIdsSet.has(value.slice_id), + ); + + updateSlices(selectedSlices); + if (slicesRequestRef.current instanceof AbortController) { + slicesRequestRef.current.abort(); + } + }, + // Only run on unmount - capture current values + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const searchUpdated = useCallback((term: string) => { + setSearchTerm(term); + }, []); + + const handleChange = useMemo( + () => + debounce((value: string) => { + searchUpdated(value); + slicesRequestRef.current = fetchSlices(userIdForFetch(), value, sortBy); + }, 300), + [fetchSlices, searchUpdated, sortBy, userIdForFetch], + ); + + const handleSelect = useCallback( + (newSortBy: keyof Slice) => { + setSortBy(newSortBy); + slicesRequestRef.current = fetchSlices( + userIdForFetch(), + searchTerm, + newSortBy, + ); + }, + [fetchSlices, searchTerm, userIdForFetch], + ); + + const onShowOnlyMyCharts = useCallback( + (checked: boolean) => { + if (!checked) { + slicesRequestRef.current = fetchSlices(undefined, searchTerm, sortBy); + } + setShowOnlyMyCharts(checked); + setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, checked); + }, + [fetchSlices, searchTerm, sortBy], + ); + + const rowRenderer = useCallback( + ({ index, style }: { index: number; style: React.CSSProperties }) => { + const cellData = filteredSlices[index]; + + const isSelected = selectedSliceIdsSet.has(cellData.slice_id); + const type = CHART_TYPE; + const id = NEW_CHART_ID; + + const meta = { + chartId: cellData.slice_id, + sliceName: cellData.slice_name, + }; + return ( + + {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( + + )} + + ); + }, + [filteredSlices, selectedSliceIdsSet, editMode], + ); + + return ( +
span > :first-of-type { + margin-right: 0; + } + `} + > + + } + onClick={() => + navigateTo(`/chart/add?dashboard_id=${dashboardId}`, { + newWindow: true, + }) + } + > + {t('Create new chart')} + + + + handleChange(ev.target.value)} + data-test="dashboard-charts-filter-search-input" + /> + ({ + label: t('Sort by %s', label), + value: key, + }))} + placeholder={t('Sort by')} + /> + +
css` + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: ${themeObj.sizeUnit}px; + padding: 0 ${themeObj.sizeUnit * 3}px ${themeObj.sizeUnit * 4}px + ${themeObj.sizeUnit * 3}px; `} > - - - } - onClick={() => - navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, { - newWindow: true, - }) - } - > - {t('Create new chart')} - - - - this.handleChange(ev.target.value)} - data-test="dashboard-charts-filter-search-input" - /> - ({ - label: t('Sort by %s', label), - value: key, - }))} - placeholder={t('Sort by')} - /> - + onShowOnlyMyCharts(e.target.checked)} + checked={showOnlyMyCharts} + /> + {t('Show only my charts')} + +
+ {isLoading && } + {!isLoading && filteredSlices.length > 0 && ( + + + {({ height, width }: { height: number; width: number }) => ( + filteredSlices[index].slice_id} + > + {rowRenderer} + + )} + + + )} + {errorMessage && (
css` - display: flex; - flex-direction: row; - justify-content: flex-start; - align-items: center; - gap: ${theme.sizeUnit}px; - padding: 0 ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px - ${theme.sizeUnit * 3}px; + css={css` + padding: 16px; `} > - this.onShowOnlyMyCharts(e.target.checked)} - checked={this.state.showOnlyMyCharts} - /> - {t('Show only my charts')} - + {errorMessage}
- {this.props.isLoading && } - {!this.props.isLoading && this.state.filteredSlices.length > 0 && ( - - - {({ height, width }: { height: number; width: number }) => ( - this.state.filteredSlices[index].slice_id} - > - {this.rowRenderer} - - )} - - - )} - {this.props.errorMessage && ( -
- {this.props.errorMessage} -
- )} - {/* Drag preview is just a single fixed-position element */} - -
- ); - } + )} + {/* Drag preview is just a single fixed-position element */} + +
+ ); } -export default withTheme(SliceAdder); +export default SliceAdder; diff --git a/superset-frontend/src/dashboard/components/UndoRedoKeyListeners/index.tsx b/superset-frontend/src/dashboard/components/UndoRedoKeyListeners/index.tsx index 89dd62694ed..7417dcdbf63 100644 --- a/superset-frontend/src/dashboard/components/UndoRedoKeyListeners/index.tsx +++ b/superset-frontend/src/dashboard/components/UndoRedoKeyListeners/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { useCallback, useEffect } from 'react'; import { HeaderProps } from '../Header/types'; type UndoRedoKeyListenersProps = { @@ -24,43 +24,38 @@ type UndoRedoKeyListenersProps = { onRedo: HeaderProps['onRedo']; }; -class UndoRedoKeyListeners extends PureComponent { - constructor(props: UndoRedoKeyListenersProps) { - super(props); - this.handleKeydown = this.handleKeydown.bind(this); - } +function UndoRedoKeyListeners({ onUndo, onRedo }: UndoRedoKeyListenersProps) { + const handleKeydown = useCallback( + (event: KeyboardEvent) => { + const controlOrCommand = event.ctrlKey || event.metaKey; + if (controlOrCommand) { + const isZChar = event.key === 'z' || event.keyCode === 90; + const isYChar = event.key === 'y' || event.keyCode === 89; + const isEditingMarkdown = document?.querySelector( + '.dashboard-markdown--editing', + ); + const isEditingTitle = document?.querySelector( + '.editable-title--editing', + ); - componentDidMount() { - document.addEventListener('keydown', this.handleKeydown); - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.handleKeydown); - } - - handleKeydown(event: KeyboardEvent) { - const controlOrCommand = event.ctrlKey || event.metaKey; - if (controlOrCommand) { - const isZChar = event.key === 'z' || event.keyCode === 90; - const isYChar = event.key === 'y' || event.keyCode === 89; - const isEditingMarkdown = document?.querySelector( - '.dashboard-markdown--editing', - ); - const isEditingTitle = document?.querySelector( - '.editable-title--editing', - ); - - if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) { - event.preventDefault(); - const func = isZChar ? this.props.onUndo : this.props.onRedo; - func(); + if (!isEditingMarkdown && !isEditingTitle && (isZChar || isYChar)) { + event.preventDefault(); + const func = isZChar ? onUndo : onRedo; + func(); + } } - } - } + }, + [onUndo, onRedo], + ); - render() { - return null; - } + useEffect(() => { + document.addEventListener('keydown', handleKeydown); + return () => { + document.removeEventListener('keydown', handleKeydown); + }; + }, [handleKeydown]); + + return null; } export default UndoRedoKeyListeners; diff --git a/superset-frontend/src/dashboard/components/dnd/DragDroppable.tsx b/superset-frontend/src/dashboard/components/dnd/DragDroppable.tsx index 7b1d60e59ff..d1daab5115e 100644 --- a/superset-frontend/src/dashboard/components/dnd/DragDroppable.tsx +++ b/superset-frontend/src/dashboard/components/dnd/DragDroppable.tsx @@ -113,15 +113,22 @@ const DragDroppableStyles = styled.div` } `}; `; + +/** + * Note: This component remains a class component because it is tightly integrated + * with react-dnd's class-based HOC system (DragSource/DropTarget). The HOCs + * access component instance properties directly (mounted, ref, props, setState) + * in the hover/drop callbacks defined in dragDroppableConfig.ts. + * + * Converting to a function component would require migrating to react-dnd's + * hooks API (useDrag/useDrop), which would be a more extensive refactor. + */ // export unwrapped component for testing +// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- react-dnd class-based HOC requires class component instance properties export class UnwrappedDragDroppable extends PureComponent< DragDroppableAllProps, DragDroppableState > { - mounted: boolean; - - ref: HTMLDivElement | null; - static defaultProps = { className: null, style: null, @@ -143,6 +150,10 @@ export class UnwrappedDragDroppable extends PureComponent< dragPreviewRef() {}, }; + mounted: boolean; + + ref: HTMLDivElement | null; + constructor(props: DragDroppableAllProps) { super(props); this.state = { @@ -274,7 +285,6 @@ export class UnwrappedDragDroppable extends PureComponent< // react-dnd's DragSource/DropTarget HOC types don't play well with // class components using spread config tuples, so we use type assertions here -// eslint-disable-next-line @typescript-eslint/no-explicit-any const DragDroppableAsAny = UnwrappedDragDroppable as unknown as ReactComponentType< Record diff --git a/superset-frontend/src/dashboard/components/filterscope/FilterScopeModal.tsx b/superset-frontend/src/dashboard/components/filterscope/FilterScopeModal.tsx index 428daf4ec33..13cedc322a4 100644 --- a/superset-frontend/src/dashboard/components/filterscope/FilterScopeModal.tsx +++ b/superset-frontend/src/dashboard/components/filterscope/FilterScopeModal.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { createRef, PureComponent } from 'react'; +import { useRef, useCallback } from 'react'; import { styled } from '@apache-superset/core/ui'; import { ModalTrigger, @@ -33,39 +33,29 @@ const FilterScopeModalBody = styled.div(({ theme: { sizeUnit } }) => ({ paddingBottom: sizeUnit * 3, })); -export default class FilterScopeModal extends PureComponent< - FilterScopeModalProps, - {} -> { - modal: ModalTriggerRef; +export default function FilterScopeModal({ + triggerNode, +}: FilterScopeModalProps) { + const modalRef = useRef(null); - constructor(props: FilterScopeModalProps) { - super(props); + const handleCloseModal = useCallback((): void => { + modalRef.current?.close?.(); + }, []); - this.modal = createRef() as ModalTriggerRef; - this.handleCloseModal = this.handleCloseModal.bind(this); - } + const filterScopeProps = { + onCloseModal: handleCloseModal, + }; - handleCloseModal(): void { - this?.modal?.current?.close?.(); - } - - render() { - const filterScopeProps = { - onCloseModal: this.handleCloseModal, - }; - - return ( - - - - } - width="80%" - /> - ); - } + return ( + + + + } + width="80%" + /> + ); } diff --git a/superset-frontend/src/dashboard/components/filterscope/FilterScopeSelector.tsx b/superset-frontend/src/dashboard/components/filterscope/FilterScopeSelector.tsx index 0e4eca61ee9..9717c536cc1 100644 --- a/superset-frontend/src/dashboard/components/filterscope/FilterScopeSelector.tsx +++ b/superset-frontend/src/dashboard/components/filterscope/FilterScopeSelector.tsx @@ -16,7 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, ChangeEvent, type ReactElement } from 'react'; +import { + useState, + useCallback, + useMemo, + ChangeEvent, + type ReactElement, +} from 'react'; import cx from 'classnames'; import { Button, Input } from '@superset-ui/core/components'; import { css, styled, t } from '@apache-superset/core/ui'; @@ -89,30 +95,6 @@ interface FilterScopeSelectorProps { onCloseModal: () => void; } -interface FilterScopeSelectorStateWithSelector { - showSelector: true; - activeFilterField: string | null; - searchText: string; - filterScopeMap: FilterScopeMap; - filterFieldNodes: FilterFieldNode[]; - checkedFilterFields: string[]; - expandedFilterIds: (string | number)[]; -} - -interface FilterScopeSelectorStateWithoutSelector { - showSelector: false; - activeFilterField?: undefined; - searchText?: undefined; - filterScopeMap?: undefined; - filterFieldNodes?: undefined; - checkedFilterFields?: undefined; - expandedFilterIds?: undefined; -} - -type FilterScopeSelectorState = - | FilterScopeSelectorStateWithSelector - | FilterScopeSelectorStateWithoutSelector; - const ScopeContainer = styled.div` ${({ theme }) => css` display: flex; @@ -388,271 +370,358 @@ const ActionsContainer = styled.div` `} `; -export default class FilterScopeSelector extends PureComponent< - FilterScopeSelectorProps, - FilterScopeSelectorState -> { - allfilterFields: string[]; +function initializeState( + dashboardFilters: Record, + layout: DashboardLayout, +) { + if (Object.keys(dashboardFilters).length === 0) { + return { + showSelector: false as const, + allFilterFields: [] as string[], + defaultFilterKey: '', + }; + } - defaultFilterKey: string; + // display filter fields in tree structure + const filterFieldNodes = getFilterFieldNodesTree({ + dashboardFilters, + }); + // filterFieldNodes root node is dashboard_root component, + // so that we can offer a select/deselect all link + const filtersNodes = filterFieldNodes[0].children ?? []; + const allFilterFields: string[] = []; + filtersNodes.forEach(({ children }) => { + (children ?? []).forEach(child => { + allFilterFields.push(String(child.value)); + }); + }); + const defaultFilterKey = String(filtersNodes[0]?.children?.[0]?.value ?? ''); - constructor(props: FilterScopeSelectorProps) { - super(props); - - this.allfilterFields = []; - this.defaultFilterKey = ''; - - const { dashboardFilters, layout } = props; - - if (Object.keys(dashboardFilters).length > 0) { - // display filter fields in tree structure - const filterFieldNodes = getFilterFieldNodesTree({ - dashboardFilters, - }); - // filterFieldNodes root node is dashboard_root component, - // so that we can offer a select/deselect all link - const filtersNodes = filterFieldNodes[0].children ?? []; - this.allfilterFields = []; - filtersNodes.forEach(({ children }) => { - (children ?? []).forEach(child => { - this.allfilterFields.push(String(child.value)); + // build FilterScopeTree object for each filterKey + const filterScopeMap: FilterScopeMap = Object.values( + dashboardFilters, + ).reduce((map, { chartId: filterId, columns }) => { + const filterScopeByChartId = Object.keys(columns).reduce( + (mapByChartId, columnName) => { + const filterKey = getDashboardFilterKey({ + chartId: String(filterId), + column: columnName, }); - }); - this.defaultFilterKey = String( - filtersNodes[0]?.children?.[0]?.value ?? '', - ); - - // build FilterScopeTree object for each filterKey - const filterScopeMap: FilterScopeMap = Object.values( - dashboardFilters, - ).reduce((map, { chartId: filterId, columns }) => { - const filterScopeByChartId = Object.keys( - columns, - ).reduce((mapByChartId, columnName) => { - const filterKey = getDashboardFilterKey({ - chartId: String(filterId), - column: columnName, - }); - const nodes = getFilterScopeNodesTree({ - components: layout, - filterFields: [filterKey], - selectedChartId: filterId, - }); - const expanded = getFilterScopeParentNodes(nodes, 1); - const chartIdsInFilterScope = ( - getChartIdsInFilterScope({ - filterScope: dashboardFilters[filterId].scopes[columnName], - }) || [] - ).filter((id: number) => id !== filterId); - - return { - ...mapByChartId, - [filterKey]: { - // unfiltered nodes - nodes, - // filtered nodes in display if searchText is not empty - nodesFiltered: [...nodes], - checked: chartIdsInFilterScope, - expanded, - }, - }; - }, {}); + const nodes = getFilterScopeNodesTree({ + components: layout, + filterFields: [filterKey], + selectedChartId: filterId, + }); + const expanded = getFilterScopeParentNodes(nodes, 1); + const chartIdsInFilterScope = ( + getChartIdsInFilterScope({ + filterScope: dashboardFilters[filterId].scopes[columnName], + }) || [] + ).filter((id: number) => id !== filterId); return { - ...map, - ...filterScopeByChartId, + ...mapByChartId, + [filterKey]: { + // unfiltered nodes + nodes, + // filtered nodes in display if searchText is not empty + nodesFiltered: [...nodes], + checked: chartIdsInFilterScope, + expanded, + }, }; - }, {}); - - // initial state: active defaultFilerKey - const { chartId } = getChartIdAndColumnFromFilterKey( - this.defaultFilterKey, - ); - const checkedFilterFields: string[] = []; - const activeFilterField = this.defaultFilterKey; - // expand defaultFilterKey in filter field tree - const expandedFilterIds: (string | number)[] = [ - ALL_FILTERS_ROOT, - chartId, - ]; - - const filterScopeTreeEntry = buildFilterScopeTreeEntry({ - checkedFilterFields, - activeFilterField, - filterScopeMap, - layout, - }); - this.state = { - showSelector: true, - activeFilterField, - searchText: '', - filterScopeMap: { - ...filterScopeMap, - ...filterScopeTreeEntry, - } as FilterScopeMap, - filterFieldNodes, - checkedFilterFields, - expandedFilterIds, - }; - } else { - this.state = { - showSelector: false, - }; - } - - this.filterNodes = this.filterNodes.bind(this); - this.onChangeFilterField = this.onChangeFilterField.bind(this); - this.onCheckFilterScope = this.onCheckFilterScope.bind(this); - this.onExpandFilterScope = this.onExpandFilterScope.bind(this); - this.onSearchInputChange = this.onSearchInputChange.bind(this); - this.onCheckFilterField = this.onCheckFilterField.bind(this); - this.onExpandFilterField = this.onExpandFilterField.bind(this); - this.onClose = this.onClose.bind(this); - this.onSave = this.onSave.bind(this); - } - - onCheckFilterScope(checked: (string | number)[] = []): void { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { activeFilterField, filterScopeMap, checkedFilterFields } = state; - - const key = getKeyForFilterScopeTree({ - activeFilterField: activeFilterField ?? undefined, - checkedFilterFields, - }); - const editingList = activeFilterField - ? [activeFilterField] - : checkedFilterFields; - const updatedEntry = { - ...filterScopeMap[key], - checked, - }; - - const updatedFilterScopeMap = getRevertedFilterScope({ - checked, - filterFields: editingList, - filterScopeMap, - }); - - this.setState(() => ({ - filterScopeMap: { - ...filterScopeMap, - ...updatedFilterScopeMap, - [key]: updatedEntry, - } as FilterScopeMap, - })); - } - - onExpandFilterScope(expanded: string[] = []): void { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { activeFilterField, checkedFilterFields, filterScopeMap } = state; - const key = getKeyForFilterScopeTree({ - activeFilterField: activeFilterField ?? undefined, - checkedFilterFields, - }); - const updatedEntry = { - ...filterScopeMap[key], - expanded, - }; - this.setState(() => ({ - filterScopeMap: { - ...filterScopeMap, - [key]: updatedEntry, }, - })); - } + {}, + ); - onCheckFilterField(checkedFilterFields: string[] = []): void { - const { layout } = this.props; - const state = this.state as FilterScopeSelectorStateWithSelector; - const { filterScopeMap } = state; - const filterScopeTreeEntry = buildFilterScopeTreeEntry({ - checkedFilterFields, - activeFilterField: undefined, - filterScopeMap, - layout, - }); + return { + ...map, + ...filterScopeByChartId, + }; + }, {}); - this.setState(() => ({ - activeFilterField: null, - checkedFilterFields, + // initial state: active defaultFilerKey + const { chartId } = getChartIdAndColumnFromFilterKey(defaultFilterKey); + const checkedFilterFields: string[] = []; + const activeFilterField = defaultFilterKey; + // expand defaultFilterKey in filter field tree + const expandedFilterIds: (string | number)[] = [ALL_FILTERS_ROOT, chartId]; + + const filterScopeTreeEntry = buildFilterScopeTreeEntry({ + checkedFilterFields, + activeFilterField, + filterScopeMap, + layout, + }); + + return { + showSelector: true as const, + allFilterFields, + defaultFilterKey, + initialState: { + activeFilterField, + searchText: '', filterScopeMap: { ...filterScopeMap, ...filterScopeTreeEntry, - }, - })); - } - - onExpandFilterField(expandedFilterIds: (string | number)[] = []): void { - this.setState(() => ({ - expandedFilterIds, - })); - } - - onChangeFilterField(filterField: { value?: string } = {}): void { - const { layout } = this.props; - const nextActiveFilterField = filterField.value; - const state = this.state as FilterScopeSelectorStateWithSelector; - const { - activeFilterField: currentActiveFilterField, + } as FilterScopeMap, + filterFieldNodes, checkedFilterFields, - filterScopeMap, - } = state; + expandedFilterIds, + }, + }; +} - // we allow single edit and multiple edit in the same view. - // if user click on the single filter field, - // will show filter scope for the single field. - // if user click on the same filter filed again, - // will toggle off the single filter field, - // and allow multi-edit all checked filter fields. - if (nextActiveFilterField === currentActiveFilterField) { - const filterScopeTreeEntry = buildFilterScopeTreeEntry({ +export default function FilterScopeSelector({ + dashboardFilters, + layout, + updateDashboardFiltersScope, + setUnsavedChanges, + onCloseModal, +}: FilterScopeSelectorProps): ReactElement { + const initialized = useMemo( + () => initializeState(dashboardFilters, layout), + // Only initialize once on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + [], + ); + + const { showSelector, allFilterFields } = initialized; + + const [activeFilterField, setActiveFilterField] = useState( + () => + initialized.showSelector + ? initialized.initialState.activeFilterField + : null, + ); + const [searchText, setSearchText] = useState(() => + initialized.showSelector ? initialized.initialState.searchText : '', + ); + const [filterScopeMap, setFilterScopeMap] = useState(() => + initialized.showSelector ? initialized.initialState.filterScopeMap : {}, + ); + const [filterFieldNodes] = useState(() => + initialized.showSelector ? initialized.initialState.filterFieldNodes : [], + ); + const [checkedFilterFields, setCheckedFilterFields] = useState( + () => + initialized.showSelector + ? initialized.initialState.checkedFilterFields + : [], + ); + const [expandedFilterIds, setExpandedFilterIds] = useState< + (string | number)[] + >(() => + initialized.showSelector ? initialized.initialState.expandedFilterIds : [], + ); + + const filterNodes = useCallback( + ( + filtered: FilterScopeTreeNode[] = [], + node: FilterScopeTreeNode = { value: '', label: '' }, + currentSearchText: string, + ): FilterScopeTreeNode[] => { + const filterNodesRecursive = ( + f: FilterScopeTreeNode[], + n: FilterScopeTreeNode, + ): FilterScopeTreeNode[] => filterNodes(f, n, currentSearchText); + + const children = (node.children || []).reduce( + filterNodesRecursive, + [], + ); + + if ( + // Node's label matches the search string + node.label + .toLocaleLowerCase() + .indexOf((currentSearchText ?? '').toLocaleLowerCase()) > -1 || + // Or a children has a matching node + children.length + ) { + filtered.push({ ...node, children }); + } + + return filtered; + }, + [], + ); + + const filterTree = useCallback( + (currentSearchText: string) => { + const key = getKeyForFilterScopeTree({ + activeFilterField: activeFilterField ?? undefined, checkedFilterFields, + }); + + // Reset nodes back to unfiltered state + if (!currentSearchText) { + setFilterScopeMap(prev => ({ + ...prev, + [key]: { + ...prev[key], + nodesFiltered: prev[key].nodes, + }, + })); + } else { + setFilterScopeMap(prev => { + const nodesFiltered = prev[key].nodes.reduce( + (filtered, node) => filterNodes(filtered, node, currentSearchText), + [], + ); + const expanded = getFilterScopeParentNodes([...nodesFiltered]); + + return { + ...prev, + [key]: { + ...prev[key], + nodesFiltered, + expanded, + }, + }; + }); + } + }, + [activeFilterField, checkedFilterFields, filterNodes], + ); + + const onCheckFilterScope = useCallback( + (checked: (string | number)[] = []): void => { + const key = getKeyForFilterScopeTree({ + activeFilterField: activeFilterField ?? undefined, + checkedFilterFields, + }); + const editingList = activeFilterField + ? [activeFilterField] + : checkedFilterFields; + + const updatedFilterScopeMap = getRevertedFilterScope({ + checked, + filterFields: editingList, + filterScopeMap, + }); + + setFilterScopeMap({ + ...filterScopeMap, + ...updatedFilterScopeMap, + [key]: { + ...filterScopeMap[key], + checked, + }, + } as FilterScopeMap); + }, + [activeFilterField, checkedFilterFields, filterScopeMap], + ); + + const onExpandFilterScope = useCallback( + (expanded: string[] = []): void => { + const key = getKeyForFilterScopeTree({ + activeFilterField: activeFilterField ?? undefined, + checkedFilterFields, + }); + + setFilterScopeMap(prev => ({ + ...prev, + [key]: { + ...prev[key], + expanded, + }, + })); + }, + [activeFilterField, checkedFilterFields], + ); + + const onCheckFilterField = useCallback( + (newCheckedFilterFields: string[] = []): void => { + const filterScopeTreeEntry = buildFilterScopeTreeEntry({ + checkedFilterFields: newCheckedFilterFields, activeFilterField: undefined, filterScopeMap, layout, }); - this.setState({ - activeFilterField: null, - filterScopeMap: { + setActiveFilterField(null); + setCheckedFilterFields(newCheckedFilterFields); + setFilterScopeMap({ + ...filterScopeMap, + ...filterScopeTreeEntry, + }); + }, + [filterScopeMap, layout], + ); + + const onExpandFilterField = useCallback( + (newExpandedFilterIds: (string | number)[] = []): void => { + setExpandedFilterIds(newExpandedFilterIds); + }, + [], + ); + + const onChangeFilterField = useCallback( + (filterField: { value?: string } = {}): void => { + const nextActiveFilterField = filterField.value; + + // we allow single edit and multiple edit in the same view. + // if user click on the single filter field, + // will show filter scope for the single field. + // if user click on the same filter filed again, + // will toggle off the single filter field, + // and allow multi-edit all checked filter fields. + if (nextActiveFilterField === activeFilterField) { + const filterScopeTreeEntry = buildFilterScopeTreeEntry({ + checkedFilterFields, + activeFilterField: undefined, + filterScopeMap, + layout, + }); + + setActiveFilterField(null); + setFilterScopeMap({ ...filterScopeMap, ...filterScopeTreeEntry, - } as FilterScopeMap, - }); - } else if ( - nextActiveFilterField && - this.allfilterFields.includes(nextActiveFilterField) - ) { - const filterScopeTreeEntry = buildFilterScopeTreeEntry({ - checkedFilterFields, - activeFilterField: nextActiveFilterField, - filterScopeMap, - layout, - }); + }); + } else if ( + nextActiveFilterField && + allFilterFields.includes(nextActiveFilterField) + ) { + const filterScopeTreeEntry = buildFilterScopeTreeEntry({ + checkedFilterFields, + activeFilterField: nextActiveFilterField, + filterScopeMap, + layout, + }); - this.setState({ - activeFilterField: nextActiveFilterField, - filterScopeMap: { + setActiveFilterField(nextActiveFilterField); + setFilterScopeMap({ ...filterScopeMap, ...filterScopeTreeEntry, - } as FilterScopeMap, - }); - } - } + }); + } + }, + [ + activeFilterField, + allFilterFields, + checkedFilterFields, + filterScopeMap, + layout, + ], + ); - onSearchInputChange(e: ChangeEvent): void { - this.setState({ searchText: e.target.value }, this.filterTree); - } + const onSearchInputChange = useCallback( + (e: ChangeEvent): void => { + const newSearchText = e.target.value; + setSearchText(newSearchText); + filterTree(newSearchText); + }, + [filterTree], + ); - onClose(): void { - this.props.onCloseModal(); - } + const onClose = useCallback((): void => { + onCloseModal(); + }, [onCloseModal]); - onSave(): void { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { filterScopeMap } = state; - - const allFilterFieldScopes = this.allfilterFields.reduce< + const onSave = useCallback((): void => { + const allFilterFieldScopes = allFilterFields.reduce< Record> >((map, filterKey) => { const { nodes } = filterScopeMap[filterKey]; @@ -668,124 +737,32 @@ export default class FilterScopeSelector extends PureComponent< }; }, {}); - this.props.updateDashboardFiltersScope(allFilterFieldScopes); - this.props.setUnsavedChanges(true); + updateDashboardFiltersScope(allFilterFieldScopes); + setUnsavedChanges(true); // click Save button will do save and close modal - this.props.onCloseModal(); - } + onCloseModal(); + }, [ + allFilterFields, + filterScopeMap, + onCloseModal, + setUnsavedChanges, + updateDashboardFiltersScope, + ]); - filterTree(): void { - const state = this.state as FilterScopeSelectorStateWithSelector; - // Reset nodes back to unfiltered state - if (!state.searchText) { - this.setState(prevState => { - const prev = prevState as FilterScopeSelectorStateWithSelector; - const { activeFilterField, checkedFilterFields, filterScopeMap } = prev; - const key = getKeyForFilterScopeTree({ - activeFilterField: activeFilterField ?? undefined, - checkedFilterFields, - }); - - const updatedEntry = { - ...filterScopeMap[key], - nodesFiltered: filterScopeMap[key].nodes, - }; - return { - filterScopeMap: { - ...filterScopeMap, - [key]: updatedEntry, - }, - } as Partial as FilterScopeSelectorState; - }); - } else { - const updater = ( - prevState: FilterScopeSelectorState, - ): FilterScopeSelectorState => { - const prev = prevState as FilterScopeSelectorStateWithSelector; - const { activeFilterField, checkedFilterFields, filterScopeMap } = prev; - const key = getKeyForFilterScopeTree({ - activeFilterField: activeFilterField ?? undefined, - checkedFilterFields, - }); - - const nodesFiltered = filterScopeMap[key].nodes.reduce< - FilterScopeTreeNode[] - >(this.filterNodes, []); - const expanded = getFilterScopeParentNodes([...nodesFiltered]); - const updatedEntry = { - ...filterScopeMap[key], - nodesFiltered, - expanded, - }; - - return { - filterScopeMap: { - ...filterScopeMap, - [key]: updatedEntry, - }, - } as Partial as FilterScopeSelectorState; - }; - - this.setState(updater); - } - } - - filterNodes( - filtered: FilterScopeTreeNode[] = [], - node: FilterScopeTreeNode = { value: '', label: '' }, - ): FilterScopeTreeNode[] { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { searchText } = state; - const children = (node.children || []).reduce( - this.filterNodes, - [], - ); - - if ( - // Node's label matches the search string - node.label - .toLocaleLowerCase() - .indexOf((searchText ?? '').toLocaleLowerCase()) > -1 || - // Or a children has a matching node - children.length - ) { - filtered.push({ ...node, children }); - } - - return filtered; - } - - renderFilterFieldList(): ReactElement | null { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { - activeFilterField, - filterFieldNodes, - checkedFilterFields, - expandedFilterIds, - } = state; - return ( - - ); - } - - renderFilterScopeTree(): ReactElement { - const state = this.state as FilterScopeSelectorStateWithSelector; - const { - filterScopeMap, - activeFilterField, - checkedFilterFields, - searchText, - } = state; + const renderFilterFieldList = (): ReactElement | null => ( + + ); + const renderFilterScopeTree = (): ReactElement => { const key = getKeyForFilterScopeTree({ activeFilterField: activeFilterField ?? undefined, checkedFilterFields, @@ -802,26 +779,23 @@ export default class FilterScopeSelector extends PureComponent< placeholder={t('Search...')} type="text" value={searchText} - onChange={this.onSearchInputChange} + onChange={onSearchInputChange} /> ); - } + }; - renderEditingFiltersName(): ReactElement { - const { dashboardFilters } = this.props; - const state = this.state as FilterScopeSelectorStateWithSelector; - const { activeFilterField, checkedFilterFields } = state; + const renderEditingFiltersName = (): ReactElement => { const currentFilterLabels = ([] as string[]) .concat(activeFilterField || checkedFilterFields) .filter(Boolean) @@ -841,50 +815,42 @@ export default class FilterScopeSelector extends PureComponent< ); - } + }; - render(): ReactElement { - const { showSelector } = this.state; + return ( + + +

{t('Configure filter scopes')}

+ {showSelector && renderEditingFiltersName()} +
- return ( - - -

{t('Configure filter scopes')}

- {showSelector && this.renderEditingFiltersName()} -
- - - {!showSelector ? ( -
- {t('There are no filters in this dashboard.')} + + {!showSelector ? ( +
+ {t('There are no filters in this dashboard.')} +
+ ) : ( + +
+ {renderFilterFieldList()}
- ) : ( - -
- {this.renderFilterFieldList()} -
-
- {this.renderFilterScopeTree()} -
-
- )} -
+
+ {renderFilterScopeTree()} +
+ + )} + - - + {showSelector && ( + - {showSelector && ( - - )} - - - ); - } + )} + + + ); } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index 0dda657a02d..3e1e3c6c19b 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -749,11 +749,11 @@ const Chart = (props: ChartProps) => { }, slice.viz_type, )} - queriesResponse={chart.queriesResponse ?? undefined} + queriesResponse={chart.queriesResponse ?? null} timeout={timeout} triggerQuery={chart.triggerQuery} vizType={slice.viz_type} - setControlValue={props.setControlValue} + setControlValue={props.setControlValue ?? (() => {})} datasetsStatus={ datasetsStatus as 'loading' | 'error' | 'complete' | undefined } diff --git a/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.tsx b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.tsx index a241f3dd627..f597feabca0 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Divider/Divider.tsx @@ -17,7 +17,7 @@ * under the License. */ -import { PureComponent } from 'react'; +import { useCallback, memo } from 'react'; import { css, styled } from '@apache-superset/core/ui'; import { Draggable } from '../../dnd/DragDroppable'; @@ -63,50 +63,43 @@ const DividerLine = styled.div` `} `; -class Divider extends PureComponent { - constructor(props: DividerProps) { - super(props); - this.handleDeleteComponent = this.handleDeleteComponent.bind(this); - } - - handleDeleteComponent() { - const { deleteComponent, id, parentId } = this.props; +function Divider({ + id, + parentId, + component, + depth, + parentComponent, + index, + editMode, + handleComponentDrop, + deleteComponent, +}: DividerProps) { + const handleDeleteComponent = useCallback(() => { deleteComponent(id, parentId); - } + }, [deleteComponent, id, parentId]); - render() { - const { - component, - depth, - parentComponent, - index, - handleComponentDrop, - editMode, - } = this.props; - - return ( - - {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( -
- {editMode && ( - - - - )} - -
- )} -
- ); - } + return ( + + {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( +
+ {editMode && ( + + + + )} + +
+ )} +
+ ); } -export default Divider; +export default memo(Divider); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.tsx b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.tsx index f61a2d1de14..76750c8fc5c 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Header/Header.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Header/Header.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { useState, useCallback, memo } from 'react'; import cx from 'classnames'; import { css, styled } from '@apache-superset/core/ui'; @@ -85,10 +85,6 @@ interface HeaderProps { updateComponents: (changes: Record) => void; } -interface HeaderState { - isFocused: boolean; -} - const HeaderStyles = styled.div` ${({ theme }) => css` font-weight: ${theme.fontWeightStrong}; @@ -159,149 +155,141 @@ const HeaderStyles = styled.div` `} `; -class Header extends PureComponent { - handleChangeSize: (nextValue: string) => void; - handleChangeBackground: (nextValue: string) => void; - handleChangeText: (nextValue: string) => void; +function Header({ + id, + dashboardId, + parentId, + component, + depth, + parentComponent, + index, + editMode, + embeddedMode, + handleComponentDrop, + deleteComponent, + updateComponents, +}: HeaderProps) { + const [isFocused, setIsFocused] = useState(false); - constructor(props: HeaderProps) { - super(props); - this.state = { - isFocused: false, - }; - this.handleDeleteComponent = this.handleDeleteComponent.bind(this); - this.handleChangeFocus = this.handleChangeFocus.bind(this); - this.handleUpdateMeta = this.handleUpdateMeta.bind(this); + const handleChangeFocus = useCallback((nextFocus: boolean): void => { + setIsFocused(nextFocus); + }, []); - this.handleChangeSize = (nextValue: string) => - this.handleUpdateMeta('headerSize', nextValue); - this.handleChangeBackground = (nextValue: string) => - this.handleUpdateMeta('background', nextValue); - this.handleChangeText = (nextValue: string) => - this.handleUpdateMeta('text', nextValue); - } - - handleChangeFocus(nextFocus: boolean): void { - this.setState(() => ({ isFocused: nextFocus })); - } - - handleUpdateMeta(metaKey: keyof ComponentMeta, nextValue: string): void { - const { updateComponents, component } = this.props; - if (nextValue && component.meta[metaKey] !== nextValue) { - updateComponents({ - [component.id]: { - ...component, - meta: { - ...component.meta, - [metaKey]: nextValue, + const handleUpdateMeta = useCallback( + (metaKey: keyof ComponentMeta, nextValue: string): void => { + if (nextValue && component.meta[metaKey] !== nextValue) { + updateComponents({ + [component.id]: { + ...component, + meta: { + ...component.meta, + [metaKey]: nextValue, + }, }, - }, - } as Record); - } - } + } as Record); + } + }, + [component, updateComponents], + ); - handleDeleteComponent(): void { - const { deleteComponent, id, parentId } = this.props; + const handleChangeSize = useCallback( + (nextValue: string) => handleUpdateMeta('headerSize', nextValue), + [handleUpdateMeta], + ); + + const handleChangeBackground = useCallback( + (nextValue: string) => handleUpdateMeta('background', nextValue), + [handleUpdateMeta], + ); + + const handleChangeText = useCallback( + (nextValue: string) => handleUpdateMeta('text', nextValue), + [handleUpdateMeta], + ); + + const handleDeleteComponent = useCallback((): void => { deleteComponent(id, parentId); - } + }, [deleteComponent, id, parentId]); - render() { - const { isFocused } = this.state; + const headerStyle = headerStyleOptions.find( + opt => opt.value === (component.meta.headerSize || SMALL_HEADER), + ); - const { - dashboardId, - component, - depth, - parentComponent, - index, - handleComponentDrop, - editMode, - embeddedMode, - } = this.props; + const rowStyle = backgroundStyleOptions.find( + opt => opt.value === (component.meta.background || BACKGROUND_TRANSPARENT), + ); - const headerStyle = headerStyleOptions.find( - opt => opt.value === (component.meta.headerSize || SMALL_HEADER), - ); - - const rowStyle = backgroundStyleOptions.find( - opt => - opt.value === (component.meta.background || BACKGROUND_TRANSPARENT), - ); - - return ( - - {({ - dragSourceRef, - }: { - dragSourceRef: React.Ref | undefined; - }) => ( -
- {editMode && - depth <= 2 && ( // drag handle looks bad when nested - - + return ( + + {({ + dragSourceRef, + }: { + dragSourceRef: React.Ref | undefined; + }) => ( +
+ {editMode && + depth <= 2 && ( // drag handle looks bad when nested + + + + )} + , + , + ]} + editMode={editMode} + > + + {editMode && ( + + )} - , - , - ]} - editMode={editMode} - > - - {editMode && ( - - - - )} - + {!editMode && !embeddedMode && ( + - {!editMode && !embeddedMode && ( - - )} - - -
- )} -
- ); - } + )} + + +
+ )} +
+ ); } -export default Header; +export default memo(Header); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx index fd931625d69..3639ed49e63 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.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 { connect } from 'react-redux'; import cx from 'classnames'; import type { JsonObject } from '@superset-ui/core'; @@ -81,16 +81,6 @@ interface MarkdownStateProps { type MarkdownProps = MarkdownOwnProps & MarkdownStateProps; -interface MarkdownState { - isFocused: boolean; - markdownSource: string; - editor: EditorInstance | null; - editorMode: 'preview' | 'edit'; - undoLength: number; - redoLength: number; - hasError?: boolean; -} - // TODO: localize const MARKDOWN_PLACE_HOLDER = `# ✨Header 1 ## ✨Header 2 @@ -139,193 +129,186 @@ interface DragChildProps { dragSourceRef: React.RefCallback; } -class Markdown extends PureComponent { - renderStartTime: number; +function Markdown({ + id, + parentId, + component, + parentComponent, + index, + depth, + editMode, + availableColumnCount, + columnWidth, + onResizeStart, + onResize, + onResizeStop, + deleteComponent, + handleComponentDrop, + updateComponents, + logEvent, + addDangerToast, + undoLength, + redoLength, + htmlSanitization, + htmlSchemaOverrides, +}: MarkdownProps) { + const [isFocused, setIsFocused] = useState(false); + const [markdownSource, setMarkdownSource] = useState( + component.meta.code as string, + ); + const [editor, setEditorState] = useState(null); + const [editorMode, setEditorMode] = useState<'preview' | 'edit'>('preview'); + const [hasError, setHasError] = useState(false); - constructor(props: MarkdownProps) { - super(props); - this.state = { - isFocused: false, - markdownSource: props.component.meta.code as string, - editor: null, - editorMode: 'preview', - undoLength: props.undoLength, - redoLength: props.redoLength, - }; - this.renderStartTime = Logger.getTimestamp(); + const renderStartTimeRef = useRef(Logger.getTimestamp()); + const prevUndoLengthRef = useRef(undoLength); + const prevRedoLengthRef = useRef(redoLength); + const prevComponentWidthRef = useRef(component.meta.width); + const prevColumnWidthRef = useRef(columnWidth); - this.handleChangeFocus = this.handleChangeFocus.bind(this); - this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this); - this.handleMarkdownChange = this.handleMarkdownChange.bind(this); - this.handleDeleteComponent = this.handleDeleteComponent.bind(this); - this.handleResizeStart = this.handleResizeStart.bind(this); - this.setEditor = this.setEditor.bind(this); - this.shouldFocusMarkdown = this.shouldFocusMarkdown.bind(this); - } - - componentDidMount(): void { - this.props.logEvent(LOG_ACTIONS_RENDER_CHART, { - viz_type: 'markdown', - start_offset: this.renderStartTime, - ts: new Date().getTime(), - duration: Logger.getTimestamp() - this.renderStartTime, - }); - } - - static getDerivedStateFromProps( - nextProps: MarkdownProps, - state: MarkdownState, - ): MarkdownState | null { - const { hasError, editorMode, markdownSource, undoLength, redoLength } = - state; - const { - component: nextComponent, - undoLength: nextUndoLength, - redoLength: nextRedoLength, - } = nextProps; - // user click undo or redo ? - if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) { - return { - ...state, - undoLength: nextUndoLength, - redoLength: nextRedoLength, - markdownSource: nextComponent.meta.code as string, - hasError: false, - }; - } + // getDerivedStateFromProps equivalent: handle undo/redo and external code changes + useEffect(() => { + // user click undo or redo? if ( + undoLength !== prevUndoLengthRef.current || + redoLength !== prevRedoLengthRef.current + ) { + setMarkdownSource(component.meta.code as string); + setHasError(false); + prevUndoLengthRef.current = undoLength; + prevRedoLengthRef.current = redoLength; + } else if ( !hasError && editorMode === 'preview' && - nextComponent.meta.code !== markdownSource + component.meta.code !== markdownSource ) { - return { - ...state, - markdownSource: nextComponent.meta.code as string, - }; + setMarkdownSource(component.meta.code as string); } + }, [ + undoLength, + redoLength, + component.meta.code, + hasError, + editorMode, + markdownSource, + ]); - return state; - } + // componentDidMount equivalent: log render event + useEffect(() => { + logEvent(LOG_ACTIONS_RENDER_CHART, { + viz_type: 'markdown', + start_offset: renderStartTimeRef.current, + ts: new Date().getTime(), + duration: Logger.getTimestamp() - renderStartTimeRef.current, + }); + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - static getDerivedStateFromError(): { hasError: boolean } { - return { - hasError: true, - }; - } - - componentDidUpdate(prevProps: MarkdownProps): void { + // componentDidUpdate equivalent: resize editor when width changes + useEffect(() => { if ( - this.state.editor && - (prevProps.component.meta.width !== this.props.component.meta.width || - prevProps.columnWidth !== this.props.columnWidth) + editor && + (prevComponentWidthRef.current !== component.meta.width || + prevColumnWidthRef.current !== columnWidth) ) { // Handle both Ace editor (resize method) and EditorHandle (no resize needed) - if (typeof this.state.editor.resize === 'function') { - this.state.editor.resize(true); + if (typeof editor.resize === 'function') { + editor.resize(true); } } - } + prevComponentWidthRef.current = component.meta.width; + prevColumnWidthRef.current = columnWidth; + }, [editor, component.meta.width, columnWidth]); - componentDidCatch(): void { - if (this.state.editor && this.state.editorMode === 'preview') { - this.props.addDangerToast( - t( - 'This markdown component has an error. Please revert your recent changes.', - ), - ); - } - } - - setEditor(editor: EditorInstance): void { - // EditorHandle or Ace editor instance - // For Ace: editor.getSession().setUseWrapMode(true) - // For EditorHandle: wrapEnabled is handled via options - if (editor?.getSession) { - editor.getSession!().setUseWrapMode(true); - } - this.setState({ - editor, - }); - } - - handleChangeFocus(nextFocus: boolean | number): void { - const nextFocused = !!nextFocus; - const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview'; - this.setState(() => ({ isFocused: nextFocused })); - this.handleChangeEditorMode(nextEditMode); - } - - handleChangeEditorMode(mode: 'edit' | 'preview'): void { - const nextState: MarkdownState = { - ...this.state, - editorMode: mode, - }; - if (mode === 'preview') { - this.updateMarkdownContent(); - nextState.hasError = false; - } - - this.setState(nextState); - } - - updateMarkdownContent(): void { - const { updateComponents, component } = this.props; - if (component.meta.code !== this.state.markdownSource) { + const updateMarkdownContent = useCallback((): void => { + if (component.meta.code !== markdownSource) { updateComponents({ [component.id]: { ...component, meta: { ...component.meta, - code: this.state.markdownSource, + code: markdownSource, }, }, }); } - } + }, [component, markdownSource, updateComponents]); - handleMarkdownChange(nextValue: string): void { - this.setState({ - markdownSource: nextValue, - }); - } - - handleDeleteComponent(): void { - const { deleteComponent, id, parentId } = this.props; - deleteComponent(id, parentId); - } - - handleResizeStart(...args: Parameters): void { - const { editorMode } = this.state; - const { editMode, onResizeStart } = this.props; - const isEditing = editorMode === 'edit'; - onResizeStart(...args); - if (editMode && isEditing) { - this.updateMarkdownContent(); + const setEditor = useCallback((editorInstance: EditorInstance): void => { + // EditorHandle or Ace editor instance + // For Ace: editor.getSession().setUseWrapMode(true) + // For EditorHandle: wrapEnabled is handled via options + if (editorInstance?.getSession) { + editorInstance.getSession!().setUseWrapMode(true); } - } + setEditorState(editorInstance); + }, []); - shouldFocusMarkdown( - event: MouseEvent, - container: HTMLElement | null, - menuRef: HTMLElement | null, - ): boolean { - if (container?.contains(event.target as Node)) return true; - if (menuRef?.contains(event.target as Node)) return true; + const handleChangeEditorMode = useCallback( + (mode: 'edit' | 'preview'): void => { + if (mode === 'preview') { + updateMarkdownContent(); + setHasError(false); + } + setEditorMode(mode); + }, + [updateMarkdownContent], + ); - return false; - } + const handleChangeFocus = useCallback( + (nextFocus: boolean | number): void => { + const nextFocused = !!nextFocus; + const nextEditMode: 'edit' | 'preview' = nextFocused ? 'edit' : 'preview'; + setIsFocused(nextFocused); + handleChangeEditorMode(nextEditMode); + }, + [handleChangeEditorMode], + ); - renderEditMode(): JSX.Element { - return ( + const handleMarkdownChange = useCallback((nextValue: string): void => { + setMarkdownSource(nextValue); + }, []); + + const handleDeleteComponent = useCallback((): void => { + deleteComponent(id, parentId); + }, [deleteComponent, id, parentId]); + + const handleResizeStart = useCallback( + (...args: Parameters): void => { + const isEditing = editorMode === 'edit'; + onResizeStart(...args); + if (editMode && isEditing) { + updateMarkdownContent(); + } + }, + [editorMode, editMode, onResizeStart, updateMarkdownContent], + ); + + const shouldFocusMarkdown = useCallback( + ( + event: MouseEvent, + container: HTMLElement | null, + menuRef: HTMLElement | null, + ): boolean => { + if (container?.contains(event.target as Node)) return true; + if (menuRef?.contains(event.target as Node)) return true; + return false; + }, + [], + ); + + const renderEditMode = useMemo( + () => ( delete" to give an empty editor - typeof this.state.markdownSource === 'string' - ? this.state.markdownSource + typeof markdownSource === 'string' + ? markdownSource : MARKDOWN_PLACE_HOLDER } language="markdown" @@ -335,126 +318,110 @@ class Markdown extends PureComponent { onReady={(handle: EditorInstance) => { // The handle provides access to the underlying editor for resize if (handle && typeof handle.focus === 'function') { - this.setEditor(handle); + setEditor(handle); } }} data-test="editor" /> - ); - } + ), + [id, markdownSource, handleMarkdownChange, setEditor], + ); - renderPreviewMode(): JSX.Element { - const { hasError } = this.state; - - return ( + const renderPreviewMode = useMemo( + () => ( - ); - } + ), + [hasError, markdownSource, htmlSanitization, htmlSchemaOverrides], + ); - render() { - const { isFocused, editorMode } = this.state; + // inherit the size of parent columns + const widthMultiple = + parentComponent.type === COLUMN_TYPE + ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT + : component.meta.width || GRID_MIN_COLUMN_COUNT; - const { - component, - parentComponent, - index, - depth, - availableColumnCount, - columnWidth, - onResize, - onResizeStop, - handleComponentDrop, - editMode, - } = this.props; + const isEditing = editorMode === 'edit'; - // inherit the size of parent columns - const widthMultiple = - parentComponent.type === COLUMN_TYPE - ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT - : component.meta.width || GRID_MIN_COLUMN_COUNT; + const menuItems = useMemo( + () => [ + , + ], + [component.id, editorMode, handleChangeEditorMode], + ); - const isEditing = editorMode === 'edit'; - - return ( - - {({ dragSourceRef }: DragChildProps) => ( - , - ]} - editMode={editMode} + return ( + + {({ dragSourceRef }: DragChildProps) => ( + + - - -
- {editMode && ( - - - - )} - {editMode && isEditing - ? this.renderEditMode() - : this.renderPreviewMode()} -
-
-
-
- )} -
- ); - } + {editMode && ( + + + + )} + {editMode && isEditing ? renderEditMode : renderPreviewMode} +
+ + + + )} + + ); } interface ReduxState { diff --git a/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx b/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx index 542de90040e..0aa019b7d4d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/new/DraggableNewComponent.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { memo } from 'react'; import cx from 'classnames'; import { css, styled } from '@apache-superset/core/ui'; import { DragDroppable } from 'src/dashboard/components/dnd/DragDroppable'; @@ -62,37 +62,37 @@ const NewComponentPlaceholder = styled.div` `} `; -export default class DraggableNewComponent extends PureComponent { - static defaultProps = { - className: null, - IconComponent: undefined, - }; - - render() { - const { label, id, type, className, meta, IconComponent } = this.props; - - return ( - - {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( - - - {IconComponent && } - - {label} - - )} - - ); - } +function DraggableNewComponent({ + label, + id, + type, + className, + meta, + IconComponent, +}: DraggableNewComponentProps) { + return ( + + {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => ( + + + {IconComponent && } + + {label} + + )} + + ); } + +export default memo(DraggableNewComponent); diff --git a/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx b/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx index 6c20491a5c0..94188095bb0 100644 --- a/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx +++ b/superset-frontend/src/dashboard/components/menu/BackgroundStyleDropdown.tsx @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; import cx from 'classnames'; import { t } from '@apache-superset/core'; import { css, styled } from '@apache-superset/core/ui'; @@ -90,18 +89,19 @@ function renderOption(option: OptionProps) { ); } -export default class BackgroundStyleDropdown extends PureComponent { - render() { - const { id, value, onChange } = this.props; - return ( - - ); - } +export default function BackgroundStyleDropdown({ + id, + value, + onChange, +}: BackgroundStyleDropdownProps) { + return ( + + ); } diff --git a/superset-frontend/src/dashboard/components/menu/HoverMenu.tsx b/superset-frontend/src/dashboard/components/menu/HoverMenu.tsx index 4ee784fd2ba..0a161de68b5 100644 --- a/superset-frontend/src/dashboard/components/menu/HoverMenu.tsx +++ b/superset-frontend/src/dashboard/components/menu/HoverMenu.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unused-state */ /** * Licensed to the Apache Software Foundation (ASF) under one * or more contributor license agreements. See the NOTICE file @@ -17,15 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { RefObject, ReactNode, PureComponent } from 'react'; +import { RefObject, ReactNode, useCallback, memo } from 'react'; import { styled } from '@apache-superset/core/ui'; import cx from 'classnames'; interface HoverMenuProps { - position: 'left' | 'top'; - innerRef: RefObject; - children: ReactNode; + position?: 'left' | 'top'; + innerRef?: RefObject | null; + children?: ReactNode; onHover?: (data: { isHovered: boolean }) => void; } @@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div` } `; -export default class HoverMenu extends PureComponent { - static defaultProps = { - position: 'left', - innerRef: null, - children: null, - }; - - handleMouseEnter = () => { - const { onHover } = this.props; +function HoverMenu({ + position = 'left', + innerRef = null, + children = null, + onHover, +}: HoverMenuProps) { + const handleMouseEnter = useCallback(() => { if (onHover) { onHover({ isHovered: true }); } - }; + }, [onHover]); - handleMouseLeave = () => { - const { onHover } = this.props; + const handleMouseLeave = useCallback(() => { if (onHover) { onHover({ isHovered: false }); } - }; + }, [onHover]); - render() { - const { innerRef, position, children } = this.props; - return ( - -
- {children} -
-
- ); - } + return ( + +
+ {children} +
+
+ ); } + +export default memo(HoverMenu); diff --git a/superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx b/superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx index 3cd434aaae4..c9800b565db 100644 --- a/superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx +++ b/superset-frontend/src/dashboard/components/menu/MarkdownModeDropdown.tsx @@ -16,7 +16,6 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; import { t } from '@apache-superset/core'; import PopoverDropdown, { @@ -40,18 +39,18 @@ const dropdownOptions = [ }, ]; -export default class MarkdownModeDropdown extends PureComponent { - render() { - const { id, value, onChange } = this.props; - - return ( - - ); - } +export default function MarkdownModeDropdown({ + id, + value, + onChange, +}: MarkdownModeDropdownProps) { + return ( + + ); } diff --git a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.test.tsx b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.test.tsx index 97c0f0bf2fa..1e391b720ff 100644 --- a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.test.tsx +++ b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.test.tsx @@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => { container?.contains(event.target)} + shouldFocus={(event, container, _menuRef) => + container?.contains(event.target) ?? false + } onChangeFocus={onChangeFocusA} >
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => { container?.contains(event.target)} + shouldFocus={(event, container, _menuRef) => + container?.contains(event.target) ?? false + } onChangeFocus={onChangeFocusB} >
diff --git a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx index 4644bc3a7d8..a71ef1667c0 100644 --- a/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx +++ b/superset-frontend/src/dashboard/components/menu/WithPopoverMenu.tsx @@ -16,7 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { ReactNode, CSSProperties, PureComponent } from 'react'; +import { + ReactNode, + CSSProperties, + useCallback, + useEffect, + useRef, + useState, + memo, +} from 'react'; import cx from 'classnames'; import { addAlpha } from '@superset-ui/core'; import { css, styled } from '@apache-superset/core/ui'; @@ -26,26 +34,32 @@ type ShouldFocusContainer = HTMLDivElement & { }; interface WithPopoverMenuProps { - children: ReactNode; - disableClick: boolean; - menuItems: ReactNode[]; - onChangeFocus: (focus: boolean) => void; - isFocused: boolean; - // Event argument is left as "any" because of the clash. In defaultProps it seems + children?: ReactNode; + disableClick?: boolean; + menuItems?: ReactNode[]; + onChangeFocus?: ((focus: boolean) => void) | null; + isFocused?: boolean; + // Event argument is left as "any" because of the clash. In props it seems // like it should be React.FocusEvent<>, however from handleClick() we can also // derive that type is EventListenerOrEventListenerObject. - shouldFocus: ( + shouldFocus?: ( event: any, - container: ShouldFocusContainer, + container: ShouldFocusContainer | null, menuRef: HTMLDivElement | null, ) => boolean; - editMode: boolean; - style: CSSProperties; + editMode?: boolean; + style?: CSSProperties | null; } -interface WithPopoverMenuState { - isFocused: boolean; -} +const defaultShouldFocus = ( + event: any, + container: ShouldFocusContainer | null, + menuRef: HTMLDivElement | null, +): boolean => { + if (container?.contains(event.target)) return true; + if (menuRef?.contains(event.target)) return true; + return false; +}; const WithPopoverMenuStyles = styled.div` ${({ theme }) => css` @@ -104,136 +118,90 @@ const PopoverMenuStyles = styled.div` `} `; -export default class WithPopoverMenu extends PureComponent< - WithPopoverMenuProps, - WithPopoverMenuState -> { - container: ShouldFocusContainer; +function WithPopoverMenu({ + children = null, + disableClick = false, + menuItems = [], + onChangeFocus = null, + isFocused: isFocusedProp = false, + shouldFocus: shouldFocusFunc = defaultShouldFocus, + editMode = false, + style = null, +}: WithPopoverMenuProps) { + const [isFocused, setIsFocused] = useState(isFocusedProp); + const containerRef = useRef(null); + const menuRef = useRef(null); - menuRef: HTMLDivElement | null; + const handleClick = useCallback( + (event: any) => { + if (!editMode) { + return; + } - static defaultProps = { - children: null, - disableClick: false, - onChangeFocus: null, - menuItems: [], - isFocused: false, - shouldFocus: ( - event: any, - container: ShouldFocusContainer, - menuRef: HTMLDivElement | null, - ) => { - if (container?.contains(event.target)) return true; - if (menuRef?.contains(event.target)) return true; - return false; + const shouldFocusResult = shouldFocusFunc( + event, + containerRef.current, + menuRef.current, + ); + + if (shouldFocusResult === isFocused) return; + + if (!disableClick && shouldFocusResult && !isFocused) { + setIsFocused(true); + if (onChangeFocus) onChangeFocus(true); + } else if (!shouldFocusResult && isFocused) { + setIsFocused(false); + if (onChangeFocus) onChangeFocus(false); + } }, - style: null, - }; + [editMode, shouldFocusFunc, isFocused, disableClick, onChangeFocus], + ); - constructor(props: WithPopoverMenuProps) { - super(props); - this.state = { - isFocused: props.isFocused!, + // Handle prop-driven focus changes and add/remove document listeners + useEffect(() => { + if (editMode && isFocusedProp && !isFocused) { + setIsFocused(true); + } else if (isFocused && !editMode) { + setIsFocused(false); + } + }, [editMode, isFocusedProp, isFocused]); + + // Add/remove document event listeners based on focus state + useEffect(() => { + if (isFocused && editMode) { + document.addEventListener('click', handleClick); + document.addEventListener('drag', handleClick); + } + + return () => { + document.removeEventListener('click', handleClick); + document.removeEventListener('drag', handleClick); }; - this.menuRef = null; - this.setRef = this.setRef.bind(this); - this.setMenuRef = this.setMenuRef.bind(this); - this.handleClick = this.handleClick.bind(this); - } + }, [isFocused, editMode, handleClick]); - componentDidUpdate(prevProps: WithPopoverMenuProps) { - if (this.props.editMode && this.props.isFocused && !this.state.isFocused) { - document.addEventListener('click', this.handleClick); - document.addEventListener('drag', this.handleClick); - this.setState({ isFocused: true }); - } else if (this.state.isFocused && !this.props.editMode) { - document.removeEventListener('click', this.handleClick); - document.removeEventListener('drag', this.handleClick); - this.setState({ isFocused: false }); - } - } - - componentWillUnmount() { - document.removeEventListener('click', this.handleClick); - document.removeEventListener('drag', this.handleClick); - } - - setRef(ref: ShouldFocusContainer) { - this.container = ref; - } - - setMenuRef(ref: HTMLDivElement | null) { - this.menuRef = ref; - } - - shouldHandleFocusChange(shouldFocus: boolean): boolean { - const { disableClick } = this.props; - const { isFocused } = this.state; - - return ( - (!disableClick && shouldFocus && !isFocused) || - (!shouldFocus && isFocused) - ); - } - - handleClick(event: any) { - if (!this.props.editMode) { - return; - } - - const { - onChangeFocus, - shouldFocus: shouldFocusFunc, - disableClick, - } = this.props; - - const shouldFocus = shouldFocusFunc(event, this.container, this.menuRef); - - if (shouldFocus === this.state.isFocused) return; - - if (!disableClick && shouldFocus && !this.state.isFocused) { - document.addEventListener('click', this.handleClick); - document.addEventListener('drag', this.handleClick); - - this.setState(() => ({ isFocused: true })); - - if (onChangeFocus) onChangeFocus(true); - } else if (!shouldFocus && this.state.isFocused) { - document.removeEventListener('click', this.handleClick); - document.removeEventListener('drag', this.handleClick); - - this.setState(() => ({ isFocused: false })); - - if (onChangeFocus) onChangeFocus(false); - } - } - - render() { - const { children, menuItems, editMode, style } = this.props; - const { isFocused } = this.state; - - return ( - - {children} - {editMode && isFocused && (menuItems?.length ?? 0) > 0 && ( - - {menuItems.map((node: ReactNode, i: number) => ( -
- {node} -
- ))} -
- )} -
- ); - } + return ( + + {children} + {editMode && isFocused && (menuItems?.length ?? 0) > 0 && ( + + {menuItems.map((node: ReactNode, i: number) => ( +
+ {node} +
+ ))} +
+ )} +
+ ); } + +export default memo(WithPopoverMenu); diff --git a/superset-frontend/src/explore/components/SaveModal.tsx b/superset-frontend/src/explore/components/SaveModal.tsx index 91c27f90002..7759c3dd346 100644 --- a/superset-frontend/src/explore/components/SaveModal.tsx +++ b/superset-frontend/src/explore/components/SaveModal.tsx @@ -17,12 +17,19 @@ * under the License. */ /* eslint camelcase: 0 */ -import { ChangeEvent, FormEvent, Component } from 'react'; +import { + ChangeEvent, + FormEvent, + useState, + useEffect, + useCallback, + useMemo, +} from 'react'; import { Dispatch } from 'redux'; import { nanoid } from 'nanoid'; import rison from 'rison'; -import { connect } from 'react-redux'; -import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { connect, useDispatch } from 'react-redux'; +import { useHistory } from 'react-router-dom'; import { InfoTooltip, Button, @@ -41,9 +48,8 @@ import { DatasourceType, isDefined, SupersetClient } from '@superset-ui/core'; import { css, styled, - withTheme, + useTheme, Alert, - type SupersetTheme, } from '@apache-superset/core/ui'; import { Radio } from '@superset-ui/core/components/Radio'; import { GRID_COLUMN_COUNT } from 'src/dashboard/util/constants'; @@ -62,7 +68,7 @@ import { CHART_WIDTH, CHART_HEIGHT } from 'src/dashboard/constants'; // Session storage key for recent dashboard const SK_DASHBOARD_ID = 'save_chart_recent_dashboard'; -interface SaveModalProps extends RouteComponentProps { +interface SaveModalProps { addDangerToast: (msg: string) => void; actions: Record; form_data?: Record; @@ -74,20 +80,8 @@ interface SaveModalProps extends RouteComponentProps { dashboardId: '' | number | null; isVisible: boolean; dispatch: Dispatch; - theme: SupersetTheme; } -type SaveModalState = { - newSliceName?: string; - datasetName: string; - action: SaveActionType; - isLoading: boolean; - saveStatus?: string | null; - dashboard?: { label: string; value: string | number }; - selectedTab?: { label: string; value: string | number }; - tabsData: TabTreeNode[]; -}; - export const StyledModal = styled(Modal)` .ant-modal-body { overflow: visible; @@ -99,645 +93,679 @@ export const StyledModal = styled(Modal)` } `; -class SaveModal extends Component { - constructor(props: SaveModalProps) { - super(props); - this.state = { - newSliceName: props.sliceName, - datasetName: props.datasource?.name, - action: this.canOverwriteSlice() - ? ChartStatusType.overwrite - : ChartStatusType.saveas, - isLoading: false, - dashboard: undefined, - tabsData: [], - selectedTab: undefined, - }; - this.onDashboardChange = this.onDashboardChange.bind(this); - this.onSliceNameChange = this.onSliceNameChange.bind(this); - this.changeAction = this.changeAction.bind(this); - this.saveOrOverwrite = this.saveOrOverwrite.bind(this); - this.isNewDashboard = this.isNewDashboard.bind(this); - this.onHide = this.onHide.bind(this); - } +const SaveModal = ({ + addDangerToast, + actions, + form_data, + user, + alert: alertProp, + sliceName = '', + slice, + datasource, + dashboardId: dashboardIdProp, + isVisible, +}: SaveModalProps) => { + const dispatch = useDispatch(); + const history = useHistory(); + const theme = useTheme(); - isNewDashboard(): boolean { - const { dashboard } = this.state; - return typeof dashboard?.value === 'string'; - } + const canOverwriteSlice = useCallback( + (): boolean => + slice?.owners?.includes(user.userId) && !slice?.is_managed_externally, + [slice, user.userId], + ); - canOverwriteSlice(): boolean { - return ( - this.props.slice?.owners?.includes(this.props.user.userId) && - !this.props.slice?.is_managed_externally - ); - } + const [newSliceName, setNewSliceName] = useState( + sliceName, + ); + const [datasetName, setDatasetName] = useState(datasource?.name); + const [action, setAction] = useState( + canOverwriteSlice() ? ChartStatusType.overwrite : ChartStatusType.saveas, + ); + const [isLoading, setIsLoading] = useState(false); + const [dashboard, setDashboard] = useState< + { label: string; value: string | number } | undefined + >(undefined); + const [tabsData, setTabsData] = useState([]); + const [selectedTab, setSelectedTab] = useState< + { label: string; value: string | number } | undefined + >(undefined); - async componentDidMount() { - let { dashboardId } = this.props; - if (!dashboardId) { - let lastDashboard = null; - try { - lastDashboard = sessionStorage.getItem(SK_DASHBOARD_ID); - } catch (error) { - // continue regardless of error - } - dashboardId = lastDashboard && parseInt(lastDashboard, 10); - } - if (dashboardId) { - try { - const result = (await this.loadDashboard(dashboardId)) as Dashboard; - if (canUserEditDashboard(result, this.props.user)) { - this.setState({ - dashboard: { label: result.dashboard_title, value: result.id }, - }); - await this.loadTabs(dashboardId); - } - } catch (error) { - logging.warn(error); - this.props.addDangerToast( - t('An error occurred while loading dashboard information.'), - ); - } - } - } + const isNewDashboard = useCallback( + (): boolean => typeof dashboard?.value === 'string', + [dashboard?.value], + ); - handleDatasetNameChange = (e: FormEvent) => { - // @ts-expect-error - this.setState({ datasetName: e.target.value }); - }; - - onSliceNameChange(event: ChangeEvent) { - this.setState({ newSliceName: event.target.value }); - } - - onDashboardChange = async (dashboard: { - label: string; - value: string | number; - }) => { - this.setState({ - dashboard, - tabsData: [], - selectedTab: undefined, - }); - - if (typeof dashboard.value === 'number') { - await this.loadTabs(dashboard.value); - } - }; - changeAction(action: SaveActionType) { - this.setState({ action }); - } - - onHide() { - this.props.dispatch(setSaveChartModalVisibility(false)); - } - - handleRedirect = (windowLocationSearch: string, chart: any) => { - const searchParams = new URLSearchParams(windowLocationSearch); - searchParams.set('save_action', this.state.action); - - searchParams.delete('form_data_key'); - - searchParams.set('slice_id', chart.id.toString()); - return searchParams; - }; - - async saveOrOverwrite(gotodash: boolean) { - this.setState({ isLoading: true }); - const tableState = this.props.form_data?.table_state; - const sliceId = this.props.slice?.slice_id; - const vizType = this.props.form_data?.viz_type; - if (sliceId && vizType && tableState) { - this.props.dispatch(updateChartState(sliceId, vizType, tableState)); - } - - // Create or retrieve dashboard - type DashboardGetResponse = { - id: number; - url: string; - dashboard_title: string; - }; - - try { - if (this.props.datasource?.type === DatasourceType.Query) { - const { schema, sql, database } = this.props.datasource; - const { templateParams } = this.props.datasource; - - await this.props.actions.saveDataset({ - schema, - sql, - database, - templateParams, - datasourceName: this.state.datasetName, - }); - } - - // Get chart dashboards - let sliceDashboards: number[] = []; - if (this.props.slice && this.state.action === 'overwrite') { - sliceDashboards = await this.props.actions.getSliceDashboards( - this.props.slice, - ); - } - - const formData = this.props.form_data || {}; - delete formData.url_params; - - let dashboard: DashboardGetResponse | null = null; - let selectedTabId: string | undefined; - if (this.state.dashboard) { - let validId = this.state.dashboard.value; - if (this.isNewDashboard()) { - const response = await this.props.actions.createDashboard( - this.state.dashboard.label, - ); - validId = response.id; - } - - try { - dashboard = await this.loadDashboard(validId as number); - } catch (error) { - this.props.actions.saveSliceFailed(); - return; - } - - if (isDefined(dashboard) && isDefined(dashboard?.id)) { - sliceDashboards = sliceDashboards.includes(dashboard.id) - ? sliceDashboards - : [...sliceDashboards, dashboard.id]; - formData.dashboards = sliceDashboards; - if ( - this.state.action === ChartStatusType.saveas && - this.state.selectedTab?.value !== 'OUT_OF_TAB' - ) { - selectedTabId = this.state.selectedTab?.value as string; - } - } - } - - // Sets the form data - this.props.actions.setFormData({ ...formData }); - - // Update or create slice - let value: { id: number }; - if (this.state.action === 'overwrite') { - value = await this.props.actions.updateSlice( - this.props.slice, - this.state.newSliceName, - sliceDashboards, - dashboard - ? { - title: dashboard.dashboard_title, - new: this.isNewDashboard(), - } - : null, - ); - } else { - value = await this.props.actions.createSlice( - this.state.newSliceName, - sliceDashboards, - dashboard - ? { - title: dashboard.dashboard_title, - new: this.isNewDashboard(), - } - : null, - ); - if (dashboard && selectedTabId) { - try { - await this.addChartToDashboardTab( - dashboard.id, - value.id, - selectedTabId, - this.state.newSliceName, - ); - } catch (error) { - logging.error('Error adding chart to dashboard tab:', error); - this.props.addDangerToast( - t('Chart was saved but could not be added to the selected tab.'), - ); - } - } - } - - try { - if (dashboard) { - sessionStorage.setItem(SK_DASHBOARD_ID, `${dashboard.id}`); - } else { - sessionStorage.removeItem(SK_DASHBOARD_ID); - } - } catch (error) { - // continue regardless of error - } - - // Go to new dashboard url - if (gotodash && dashboard) { - let { url } = dashboard; - if (this.state.selectedTab?.value) { - url += `#${this.state.selectedTab.value}`; - } - this.props.dispatch(removeChartState(value.id)); - this.props.history.push(url); - return; - } - const searchParams = this.handleRedirect(window.location.search, value); - this.props.history.replace(`/explore/?${searchParams.toString()}`); - - this.setState({ isLoading: false }); - this.onHide(); - } finally { - this.setState({ isLoading: false }); - } - } - - /* Adds a chart to the specified dashboard tab. If an existing row has space, the chart is added there; otherwise, a new row is created. - * @param {number} dashboardId - ID of the dashboard. - * @param {number} chartId - ID of the chart to add. - * @param {string} tabId - ID of the dashboard tab where the chart is added. - * @param {string | undefined} sliceName - Chart name - */ - addChartToDashboardTab = async ( - dashboardId: number, - chartId: number, - tabId: string, - sliceName: string | undefined, - ) => { - try { - const dashboardResponse = await SupersetClient.get({ - endpoint: `/api/v1/dashboard/${dashboardId}`, - }); - - const dashboard = dashboardResponse.json.result; - - let positionJson = dashboard.position_json; - if (typeof positionJson === 'string') { - positionJson = JSON.parse(positionJson); - } - positionJson = positionJson || {}; - - const chartKey = `CHART-${chartId}`; - - // Find a row in the tab with available space - const tabChildren = positionJson[tabId]?.children || []; - let targetRowKey: string | null = null; - - for (const childKey of tabChildren) { - const child = positionJson[childKey]; - if (child?.type === 'ROW') { - const rowChildren = child.children || []; - const totalWidth = rowChildren.reduce((sum: number, key: string) => { - const component = positionJson[key]; - return sum + (component?.meta?.width || 0); - }, 0); - - if (totalWidth + CHART_WIDTH <= GRID_COLUMN_COUNT) { - targetRowKey = childKey; - break; - } - } - } - - const updatedPositionJson = { ...positionJson }; - - // Create a new row if no existing row has space - if (!targetRowKey) { - targetRowKey = `ROW-${nanoid()}`; - updatedPositionJson[targetRowKey] = { - type: 'ROW', - id: targetRowKey, - children: [], - parents: ['ROOT_ID', 'GRID_ID', tabId], - meta: { - background: 'BACKGROUND_TRANSPARENT', - }, - }; - - if (positionJson[tabId]) { - updatedPositionJson[tabId] = { - ...positionJson[tabId], - children: [...(positionJson[tabId].children || []), targetRowKey], - }; - } else { - throw new Error(`Tab ${tabId} not found in positionJson`); - } - } - - updatedPositionJson[chartKey] = { - type: 'CHART', - id: chartKey, - children: [], - parents: ['ROOT_ID', 'GRID_ID', tabId, targetRowKey], - meta: { - width: CHART_WIDTH, - height: CHART_HEIGHT, - chartId, - sliceName: sliceName ?? `Chart ${chartId}`, - }, - }; - - // Add chart to the target row - updatedPositionJson[targetRowKey] = { - ...updatedPositionJson[targetRowKey], - children: [ - ...(updatedPositionJson[targetRowKey].children || []), - chartKey, - ], - }; - - const response = await SupersetClient.put({ - endpoint: `/api/v1/dashboard/${dashboardId}`, - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - position_json: JSON.stringify(updatedPositionJson), - }), - }); - - return response; - } catch (error) { - throw new Error(`Error adding chart to dashboard tab: ${error}`); - } - }; - - loadDashboard = async (id: number) => { + const loadDashboard = useCallback(async (id: number) => { const response = await SupersetClient.get({ endpoint: `/api/v1/dashboard/${id}`, }); return response.json.result; - }; + }, []); - loadDashboards = async (search: string, page: number, pageSize: number) => { - const queryParams = rison.encode({ - columns: ['id', 'dashboard_title'], - filters: [ - { - col: 'dashboard_title', - opr: 'ct', - value: search, - }, - { - col: 'owners', - opr: 'rel_m_m', - value: this.props.user.userId, - }, - ], - page, - page_size: pageSize, - order_column: 'dashboard_title', - }); + const loadTabs = useCallback( + async (dashboardId: number) => { + try { + const response = await SupersetClient.get({ + endpoint: `/api/v1/dashboard/${dashboardId}/tabs`, + }); - const { json } = await SupersetClient.get({ - endpoint: `/api/v1/dashboard/?q=${queryParams}`, - }); - const { result, count } = json; - return { - data: result.map( - (dashboard: { id: number; dashboard_title: string }) => ({ - value: dashboard.id, - label: dashboard.dashboard_title, - }), - ), - totalCount: count, - }; - }; - // Loads dashboard tabs and returns the tab hierarchy for display. - loadTabs = async (dashboardId: number) => { - try { - const response = await SupersetClient.get({ - endpoint: `/api/v1/dashboard/${dashboardId}/tabs`, - }); + const { result } = response.json; + if (!result || !Array.isArray(result.tab_tree)) { + logging.warn('Invalid tabs response format'); + setTabsData([]); + return []; + } + const tabTree = result.tab_tree; + const gridTabIds = new Set(); + const convertToTreeData = (nodes: TabNode[]): TabTreeNode[] => + nodes.map(node => { + const isGridTab = + Array.isArray(node.parents) && node.parents.includes('GRID_ID'); + if (isGridTab) { + gridTabIds.add(node.value); + } + return { + value: node.value, + title: node.title, + key: node.value, + children: + node.children && node.children.length > 0 + ? convertToTreeData(node.children) + : undefined, + }; + }); - const { result } = response.json; - if (!result || !Array.isArray(result.tab_tree)) { - logging.warn('Invalid tabs response format'); - this.setState({ tabsData: [] }); + const treeData = convertToTreeData(tabTree); + + // Add "Out of tab" option at the beginning + if (gridTabIds.size > 0) { + const tabsDataWithOutOfTab = [ + { + value: 'OUT_OF_TAB', + title: 'Out of tab', + key: 'OUT_OF_TAB', + children: undefined, + }, + ...treeData, + ]; + + setTabsData(tabsDataWithOutOfTab); + setSelectedTab({ value: 'OUT_OF_TAB', label: 'Out of tab' }); + } else { + const firstTab = treeData[0]; + setTabsData(treeData); + setSelectedTab({ value: firstTab.value, label: firstTab.title }); + } + + return treeData; + } catch (error) { + logging.error('Error loading tabs:', error); + setTabsData([]); return []; } - const tabTree = result.tab_tree; - const gridTabIds = new Set(); - const convertToTreeData = (nodes: TabNode[]): TabTreeNode[] => - nodes.map(node => { - const isGridTab = - Array.isArray(node.parents) && node.parents.includes('GRID_ID'); - if (isGridTab) { - gridTabIds.add(node.value); - } - return { - value: node.value, - title: node.title, - key: node.value, - children: - node.children && node.children.length > 0 - ? convertToTreeData(node.children) - : undefined, - }; - }); + }, + [setTabsData, setSelectedTab], + ); - const treeData = convertToTreeData(tabTree); - - // Add "Out of tab" option at the beginning - if (gridTabIds.size > 0) { - const tabsDataWithOutOfTab = [ - { - value: 'OUT_OF_TAB', - title: 'Out of tab', - key: 'OUT_OF_TAB', - children: undefined, - }, - ...treeData, - ]; - - this.setState({ - tabsData: tabsDataWithOutOfTab, - selectedTab: { value: 'OUT_OF_TAB', label: 'Out of tab' }, - }); - } else { - const firstTab = treeData[0]; - this.setState({ - tabsData: treeData, - selectedTab: { value: firstTab.value, label: firstTab.title }, - }); + useEffect(() => { + const initializeDashboard = async () => { + let dashboardId = dashboardIdProp; + if (!dashboardId) { + let lastDashboard = null; + try { + lastDashboard = sessionStorage.getItem(SK_DASHBOARD_ID); + } catch (error) { + // continue regardless of error + } + dashboardId = lastDashboard && parseInt(lastDashboard, 10); } - - return treeData; - } catch (error) { - logging.error('Error loading tabs:', error); - this.setState({ tabsData: [] }); - return []; - } - }; - - onTabChange = (value: string) => { - if (value) { - const findTabInTree = (data: TabTreeNode[]): TabTreeNode | null => { - for (const item of data) { - if (item.value === value) { - return item; + if (dashboardId) { + try { + const result = (await loadDashboard(dashboardId)) as Dashboard; + if (canUserEditDashboard(result, user)) { + setDashboard({ label: result.dashboard_title, value: result.id }); + await loadTabs(dashboardId); } - if (item.children) { - const found = findTabInTree(item.children); - if (found) return found; + } catch (error) { + logging.warn(error); + addDangerToast( + t('An error occurred while loading dashboard information.'), + ); + } + } + }; + initializeDashboard(); + }, [dashboardIdProp, user, loadDashboard, loadTabs, addDangerToast]); + + const handleDatasetNameChange = useCallback( + (e: FormEvent) => { + // @ts-expect-error + setDatasetName(e.target.value); + }, + [], + ); + + const onSliceNameChange = useCallback( + (event: ChangeEvent) => { + setNewSliceName(event.target.value); + }, + [], + ); + + const onDashboardChange = useCallback( + async (newDashboard: { label: string; value: string | number }) => { + setDashboard(newDashboard); + setTabsData([]); + setSelectedTab(undefined); + + if (typeof newDashboard.value === 'number') { + await loadTabs(newDashboard.value); + } + }, + [loadTabs], + ); + + const changeAction = useCallback((newAction: SaveActionType) => { + setAction(newAction); + }, []); + + const onHide = useCallback(() => { + dispatch(setSaveChartModalVisibility(false)); + }, [dispatch]); + + const handleRedirect = useCallback( + (windowLocationSearch: string, chart: any) => { + const searchParams = new URLSearchParams(windowLocationSearch); + searchParams.set('save_action', action); + + searchParams.delete('form_data_key'); + + searchParams.set('slice_id', chart.id.toString()); + return searchParams; + }, + [action], + ); + + const addChartToDashboardTab = useCallback( + async ( + dashboardId: number, + chartId: number, + tabId: string, + sliceNameParam: string | undefined, + ) => { + try { + const dashboardResponse = await SupersetClient.get({ + endpoint: `/api/v1/dashboard/${dashboardId}`, + }); + + const dashboardData = dashboardResponse.json.result; + + let positionJson = dashboardData.position_json; + if (typeof positionJson === 'string') { + positionJson = JSON.parse(positionJson); + } + positionJson = positionJson || {}; + + const chartKey = `CHART-${chartId}`; + + // Find a row in the tab with available space + const tabChildren = positionJson[tabId]?.children || []; + let targetRowKey: string | null = null; + + for (const childKey of tabChildren) { + const child = positionJson[childKey]; + if (child?.type === 'ROW') { + const rowChildren = child.children || []; + const totalWidth = rowChildren.reduce( + (sum: number, key: string) => { + const component = positionJson[key]; + return sum + (component?.meta?.width || 0); + }, + 0, + ); + + if (totalWidth + CHART_WIDTH <= GRID_COLUMN_COUNT) { + targetRowKey = childKey; + break; + } } } - return null; + + const updatedPositionJson = { ...positionJson }; + + // Create a new row if no existing row has space + if (!targetRowKey) { + targetRowKey = `ROW-${nanoid()}`; + updatedPositionJson[targetRowKey] = { + type: 'ROW', + id: targetRowKey, + children: [], + parents: ['ROOT_ID', 'GRID_ID', tabId], + meta: { + background: 'BACKGROUND_TRANSPARENT', + }, + }; + + if (positionJson[tabId]) { + updatedPositionJson[tabId] = { + ...positionJson[tabId], + children: [...(positionJson[tabId].children || []), targetRowKey], + }; + } else { + throw new Error(`Tab ${tabId} not found in positionJson`); + } + } + + updatedPositionJson[chartKey] = { + type: 'CHART', + id: chartKey, + children: [], + parents: ['ROOT_ID', 'GRID_ID', tabId, targetRowKey], + meta: { + width: CHART_WIDTH, + height: CHART_HEIGHT, + chartId, + sliceName: sliceNameParam ?? `Chart ${chartId}`, + }, + }; + + // Add chart to the target row + updatedPositionJson[targetRowKey] = { + ...updatedPositionJson[targetRowKey], + children: [ + ...(updatedPositionJson[targetRowKey].children || []), + chartKey, + ], + }; + + const response = await SupersetClient.put({ + endpoint: `/api/v1/dashboard/${dashboardId}`, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + position_json: JSON.stringify(updatedPositionJson), + }), + }); + + return response; + } catch (error) { + throw new Error(`Error adding chart to dashboard tab: ${error}`); + } + }, + [], + ); + + const saveOrOverwrite = useCallback( + async (gotodash: boolean) => { + setIsLoading(true); + const tableState = form_data?.table_state; + const sliceId = slice?.slice_id; + const vizType = form_data?.viz_type; + if (sliceId && vizType && tableState) { + dispatch(updateChartState(sliceId, vizType, tableState)); + } + + // Create or retrieve dashboard + type DashboardGetResponse = { + id: number; + url: string; + dashboard_title: string; }; - const selectedTab = findTabInTree(this.state.tabsData); - if (selectedTab) { - this.setState({ - selectedTab: { - value: selectedTab.value, - label: selectedTab.title, - }, - }); + try { + if (datasource?.type === DatasourceType.Query) { + const { schema, sql, database } = datasource; + const { templateParams } = datasource; + + await actions.saveDataset({ + schema, + sql, + database, + templateParams, + datasourceName: datasetName, + }); + } + + // Get chart dashboards + let sliceDashboards: number[] = []; + if (slice && action === 'overwrite') { + sliceDashboards = await actions.getSliceDashboards(slice); + } + + const formData = form_data || {}; + delete formData.url_params; + + let dashboardResult: DashboardGetResponse | null = null; + let selectedTabId: string | undefined; + if (dashboard) { + let validId = dashboard.value; + if (isNewDashboard()) { + const response = await actions.createDashboard(dashboard.label); + validId = response.id; + } + + try { + dashboardResult = await loadDashboard(validId as number); + } catch (error) { + actions.saveSliceFailed(); + return; + } + + if (isDefined(dashboardResult) && isDefined(dashboardResult?.id)) { + sliceDashboards = sliceDashboards.includes(dashboardResult.id) + ? sliceDashboards + : [...sliceDashboards, dashboardResult.id]; + formData.dashboards = sliceDashboards; + if ( + action === ChartStatusType.saveas && + selectedTab?.value !== 'OUT_OF_TAB' + ) { + selectedTabId = selectedTab?.value as string; + } + } + } + + // Sets the form data + actions.setFormData({ ...formData }); + + // Update or create slice + let value: { id: number }; + if (action === 'overwrite') { + value = await actions.updateSlice( + slice, + newSliceName, + sliceDashboards, + dashboardResult + ? { + title: dashboardResult.dashboard_title, + new: isNewDashboard(), + } + : null, + ); + } else { + value = await actions.createSlice( + newSliceName, + sliceDashboards, + dashboardResult + ? { + title: dashboardResult.dashboard_title, + new: isNewDashboard(), + } + : null, + ); + if (dashboardResult && selectedTabId) { + try { + await addChartToDashboardTab( + dashboardResult.id, + value.id, + selectedTabId, + newSliceName, + ); + } catch (error) { + logging.error('Error adding chart to dashboard tab:', error); + addDangerToast( + t( + 'Chart was saved but could not be added to the selected tab.', + ), + ); + } + } + } + + try { + if (dashboardResult) { + sessionStorage.setItem(SK_DASHBOARD_ID, `${dashboardResult.id}`); + } else { + sessionStorage.removeItem(SK_DASHBOARD_ID); + } + } catch (error) { + // continue regardless of error + } + + // Go to new dashboard url + if (gotodash && dashboardResult) { + let { url } = dashboardResult; + if (selectedTab?.value) { + url += `#${selectedTab.value}`; + } + dispatch(removeChartState(value.id)); + history.push(url); + return; + } + const searchParams = handleRedirect(window.location.search, value); + history.replace(`/explore/?${searchParams.toString()}`); + + setIsLoading(false); + onHide(); + } finally { + setIsLoading(false); } - } else { - this.setState({ selectedTab: undefined }); - } - }; + }, + [ + form_data, + slice, + dispatch, + datasource, + datasetName, + actions, + action, + dashboard, + isNewDashboard, + loadDashboard, + selectedTab, + newSliceName, + addChartToDashboardTab, + addDangerToast, + history, + handleRedirect, + onHide, + ], + ); - renderSaveChartModal = () => { - const info = this.info(); - return ( -
- - this.changeAction('overwrite')} - data-test="save-overwrite-radio" - > - {t('Save (Overwrite)')} - - this.changeAction('saveas')} - > - {t('Save as...')} - - - - - - - {this.props.datasource?.type === 'query' && ( - - {t('Dataset Name')} - - - } - required - > - - - )} - - - {t('Select')} - {t(' a dashboard OR ')} - {t('create')} - {t(' a new one')} -
- } - /> - - {this.state.action === ChartStatusType.saveas && ( - - - - )} - {info && } - {this.props.alert && ( - - )} - - ); - }; + const loadDashboards = useCallback( + async (search: string, page: number, pageSize: number) => { + const queryParams = rison.encode({ + columns: ['id', 'dashboard_title'], + filters: [ + { + col: 'dashboard_title', + opr: 'ct', + value: search, + }, + { + col: 'owners', + opr: 'rel_m_m', + value: user.userId, + }, + ], + page, + page_size: pageSize, + order_column: 'dashboard_title', + }); - info = () => { - const isNewDashboard = this.isNewDashboard(); + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/dashboard/?q=${queryParams}`, + }); + const { result, count } = json; + return { + data: result.map( + (dashboardItem: { id: number; dashboard_title: string }) => ({ + value: dashboardItem.id, + label: dashboardItem.dashboard_title, + }), + ), + totalCount: count, + }; + }, + [user.userId], + ); + + const onTabChange = useCallback( + (value: string) => { + if (value) { + const findTabInTree = (data: TabTreeNode[]): TabTreeNode | null => { + for (const item of data) { + if (item.value === value) { + return item; + } + if (item.children) { + const found = findTabInTree(item.children); + if (found) return found; + } + } + return null; + }; + + const foundTab = findTabInTree(tabsData); + if (foundTab) { + setSelectedTab({ + value: foundTab.value, + label: foundTab.title, + }); + } + } else { + setSelectedTab(undefined); + } + }, + [tabsData], + ); + + const info = useMemo(() => { + const newDashboard = isNewDashboard(); let chartWillBeCreated = false; - if ( - this.props.slice && - (this.state.action !== 'overwrite' || !this.canOverwriteSlice()) - ) { + if (slice && (action !== 'overwrite' || !canOverwriteSlice())) { chartWillBeCreated = true; } - if (chartWillBeCreated && isNewDashboard) { + if (chartWillBeCreated && newDashboard) { return t('A new chart and dashboard will be created.'); } if (chartWillBeCreated) { return t('A new chart will be created.'); } - if (isNewDashboard) { + if (newDashboard) { return t('A new dashboard will be created.'); } return null; - }; + }, [isNewDashboard, slice, action, canOverwriteSlice]); - renderFooter = () => ( + const renderSaveChartModal = () => ( +
+ + changeAction('overwrite')} + data-test="save-overwrite-radio" + > + {t('Save (Overwrite)')} + + changeAction('saveas')} + > + {t('Save as...')} + + + + + + + {datasource?.type === 'query' && ( + + {t('Dataset Name')} + + + } + required + > + + + )} + + + {t('Select')} + {t(' a dashboard OR ')} + {t('create')} + {t(' a new one')} +
+ } + /> + + {action === ChartStatusType.saveas && ( + + + + )} + {info && } + {alertProp && ( + + )} + + ); + + const renderFooter = () => (
@@ -759,12 +786,11 @@ class SaveModal extends Component { id="btn_modal_save" buttonSize="small" buttonStyle="primary" - onClick={() => this.saveOrOverwrite(false)} + onClick={() => saveOrOverwrite(false)} disabled={ - this.state.isLoading || - !this.state.newSliceName || - (this.props.datasource?.type !== DatasourceType.Table && - !this.state.datasetName) + isLoading || + !newSliceName || + (datasource?.type !== DatasourceType.Table && !datasetName) } data-test="btn-modal-save" > @@ -773,30 +799,28 @@ class SaveModal extends Component {
); - render() { - return ( - - {this.state.isLoading ? ( -
- -
- ) : ( - this.renderSaveChartModal() - )} -
- ); - } -} + return ( + + {isLoading ? ( +
+ +
+ ) : ( + renderSaveChartModal() + )} +
+ ); +}; interface StateProps { datasource: any; @@ -822,7 +846,7 @@ function mapStateToProps({ }; } -export default withRouter(connect(mapStateToProps)(withTheme(SaveModal))); +export default connect(mapStateToProps)(SaveModal); // User for testing purposes need to revisit once we convert this to functional component export { SaveModal as PureSaveModal }; diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx index e7b7494be01..8a0c23c05d5 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/AnnotationLayer.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React, { PureComponent } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import rison from 'rison'; import { Button, @@ -35,11 +35,7 @@ import { type QueryFormColumn, } from '@superset-ui/core'; import { t } from '@apache-superset/core'; -import { - styled, - withTheme, - type SupersetTheme, -} from '@apache-superset/core/ui'; +import { styled, useTheme } from '@apache-superset/core/ui'; import SelectControl from 'src/explore/components/controls/SelectControl'; import TextControl from 'src/explore/components/controls/TextControl'; import CheckboxControl from 'src/explore/components/controls/CheckboxControl'; @@ -99,34 +95,11 @@ interface AnnotationLayerProps { vizType?: string; error?: string; colorScheme?: string; - theme: SupersetTheme; addAnnotationLayer?: (annotation: Record) => void; removeAnnotationLayer?: () => void; close?: () => void; } -interface AnnotationLayerState { - name: string; - annotationType: string; - sourceType: string | null; - value: string | number | SelectOption | null; - overrides: AnnotationOverrides; - show: boolean; - showLabel: boolean; - titleColumn: string; - descriptionColumns: string[]; - timeColumn: string; - intervalEndColumn: string; - color: string; - opacity: string; - style: string; - width: number; - showMarkers: boolean; - hideLine: boolean; - isNew: boolean; - slice: SliceData | null; -} - const AUTOMATIC_COLOR = ''; const NotFoundContentWrapper = styled.div` @@ -159,357 +132,220 @@ const NotFoundContent = () => ( ); -class AnnotationLayer extends PureComponent< - AnnotationLayerProps, - AnnotationLayerState -> { - static defaultProps = { - name: '', - annotationType: DEFAULT_ANNOTATION_TYPE, - sourceType: '', - color: AUTOMATIC_COLOR, - opacity: '', - style: 'solid', - width: 1, - showMarkers: false, - hideLine: false, - overrides: {}, - colorScheme: 'd3Category10', - show: true, - showLabel: false, - titleColumn: '', - descriptionColumns: [], - timeColumn: '', - intervalEndColumn: '', - addAnnotationLayer: () => {}, - removeAnnotationLayer: () => {}, - close: () => {}, - }; +function AnnotationLayer({ + name: propName = '', + annotationType: propAnnotationType = DEFAULT_ANNOTATION_TYPE, + sourceType: propSourceType = '', + color: propColor = AUTOMATIC_COLOR, + opacity: propOpacity = '', + style: propStyle = 'solid', + width: propWidth = 1, + showMarkers: propShowMarkers = false, + hideLine: propHideLine = false, + value: propValue, + overrides: propOverrides = {}, + show: propShow = true, + showLabel: propShowLabel = false, + titleColumn: propTitleColumn = '', + descriptionColumns: propDescriptionColumns = [], + timeColumn: propTimeColumn = '', + intervalEndColumn: propIntervalEndColumn = '', + vizType, + error, + colorScheme = 'd3Category10', + addAnnotationLayer = () => {}, + removeAnnotationLayer = () => {}, + close = () => {}, +}: AnnotationLayerProps) { + const theme = useTheme(); - constructor(props: AnnotationLayerProps) { - super(props); - const { - name, - annotationType, - sourceType, - color, - opacity, - style, - width, - showMarkers, - hideLine, - value, - overrides, - show, - showLabel, - titleColumn, - descriptionColumns, - timeColumn, - intervalEndColumn, - vizType, - } = props; - - // Only allow override whole time_range - const processedOverrides: AnnotationOverrides = overrides - ? { ...overrides } + // Process overrides - only allow override whole time_range + const processedOverrides = useMemo((): AnnotationOverrides => { + const result: AnnotationOverrides = propOverrides + ? { ...propOverrides } : {}; - if ('since' in processedOverrides || 'until' in processedOverrides) { - processedOverrides.time_range = null; - delete processedOverrides.since; - delete processedOverrides.until; + if ('since' in result || 'until' in result) { + result.time_range = null; + delete result.since; + delete result.until; } + return result; + }, [propOverrides]); - // Check if annotationType is supported by this chart + // Check if annotationType is supported by this chart + const validAnnotationType = useMemo(() => { const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null; const supportedAnnotationTypes = metadata?.supportedAnnotationTypes || []; - const resolvedAnnotationType = annotationType || DEFAULT_ANNOTATION_TYPE; - const validAnnotationType = supportedAnnotationTypes.includes( - resolvedAnnotationType, - ) + const resolvedAnnotationType = + propAnnotationType || DEFAULT_ANNOTATION_TYPE; + const isValid = supportedAnnotationTypes.includes(resolvedAnnotationType); + return isValid ? resolvedAnnotationType - : supportedAnnotationTypes[0]; + : supportedAnnotationTypes[0] || DEFAULT_ANNOTATION_TYPE; + }, [vizType, propAnnotationType]); - this.state = { - // base - name: name || '', - annotationType: validAnnotationType || DEFAULT_ANNOTATION_TYPE, - sourceType: sourceType || null, - value: value || null, - overrides: processedOverrides, - show: show ?? true, - showLabel: showLabel ?? false, - // slice - titleColumn: titleColumn || '', - descriptionColumns: descriptionColumns || [], - timeColumn: timeColumn || '', - intervalEndColumn: intervalEndColumn || '', - // display - color: color || AUTOMATIC_COLOR, - opacity: opacity || '', - style: style || 'solid', - width: width ?? 1, - showMarkers: showMarkers ?? false, - hideLine: hideLine ?? false, - // refData - isNew: !name, - slice: null, - }; - this.submitAnnotation = this.submitAnnotation.bind(this); - this.deleteAnnotation = this.deleteAnnotation.bind(this); - this.applyAnnotation = this.applyAnnotation.bind(this); - this.isValidForm = this.isValidForm.bind(this); - // Handlers - this.handleAnnotationType = this.handleAnnotationType.bind(this); - this.handleAnnotationSourceType = - this.handleAnnotationSourceType.bind(this); - this.handleSelectValue = this.handleSelectValue.bind(this); - this.handleTextValue = this.handleTextValue.bind(this); - // Fetch related functions - this.fetchOptions = this.fetchOptions.bind(this); - this.fetchCharts = this.fetchCharts.bind(this); - this.fetchNativeAnnotations = this.fetchNativeAnnotations.bind(this); - this.fetchAppliedAnnotation = this.fetchAppliedAnnotation.bind(this); - this.fetchSliceData = this.fetchSliceData.bind(this); - this.shouldFetchSliceData = this.shouldFetchSliceData.bind(this); - this.fetchAppliedChart = this.fetchAppliedChart.bind(this); - this.fetchAppliedNativeAnnotation = - this.fetchAppliedNativeAnnotation.bind(this); - this.shouldFetchAppliedAnnotation = - this.shouldFetchAppliedAnnotation.bind(this); - } + // State + const [name, setName] = useState(propName || ''); + const [annotationType, setAnnotationType] = useState(validAnnotationType); + const [sourceType, setSourceType] = useState( + propSourceType || null, + ); + const [value, setValue] = useState( + propValue || null, + ); + const [overrides, setOverrides] = + useState(processedOverrides); + const [show, setShow] = useState(propShow ?? true); + const [showLabel, setShowLabel] = useState(propShowLabel ?? false); + const [titleColumn, setTitleColumn] = useState(propTitleColumn || ''); + const [descriptionColumns, setDescriptionColumns] = useState( + propDescriptionColumns || [], + ); + const [timeColumn, setTimeColumn] = useState(propTimeColumn || ''); + const [intervalEndColumn, setIntervalEndColumn] = useState( + propIntervalEndColumn || '', + ); + const [color, setColor] = useState(propColor || AUTOMATIC_COLOR); + const [opacity, setOpacity] = useState(propOpacity || ''); + const [style, setStyle] = useState(propStyle || 'solid'); + const [width, setWidth] = useState(propWidth ?? 1); + const [showMarkers, setShowMarkers] = useState(propShowMarkers ?? false); + const [hideLine, setHideLine] = useState(propHideLine ?? false); + const [isNew, setIsNew] = useState(!propName); + const [slice, setSlice] = useState(null); - componentDidMount() { - if (this.shouldFetchAppliedAnnotation()) { - const { value } = this.state; - /* The value prop is the id of the chart/native. This function will set - value in state to an object with the id as value.value to be used by - AsyncSelect */ - if (value !== null && typeof value !== 'object') { - this.fetchAppliedAnnotation(value); + const getSupportedSourceTypes = useCallback( + (annoType: string): SelectOption[] => { + // Get vis types that can be source. + const sources = getChartMetadataRegistry() + .entries() + .filter(({ value: chartMetadata }) => + chartMetadata?.canBeAnnotationType(annoType), + ) + .map(({ key, value: chartMetadata }) => ({ + value: key === VizType.Line ? 'line' : key, + label: chartMetadata?.name || key, + })); + // Prepend native source if applicable + const annotationMeta = + ANNOTATION_TYPES_METADATA[ + annoType as keyof typeof ANNOTATION_TYPES_METADATA + ]; + if (annotationMeta && 'supportNativeSource' in annotationMeta) { + sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE); } - } - } + return sources; + }, + [], + ); - componentDidUpdate( - _prevProps: AnnotationLayerProps, - prevState: AnnotationLayerState, - ): void { - if (this.shouldFetchSliceData(prevState)) { - const { value } = this.state; - if (value && typeof value === 'object' && 'value' in value) { - this.fetchSliceData(value.value); - } - } - } + const shouldFetchAppliedAnnotation = useCallback( + (): boolean => !!value && requiresQuery(sourceType ?? undefined), + [value, sourceType], + ); - getSupportedSourceTypes(annotationType: string): SelectOption[] { - // Get vis types that can be source. - const sources = getChartMetadataRegistry() - .entries() - .filter(({ value: chartMetadata }) => - chartMetadata?.canBeAnnotationType(annotationType), - ) - .map(({ key, value: chartMetadata }) => ({ - value: key === VizType.Line ? 'line' : key, - label: chartMetadata?.name || key, - })); - // Prepend native source if applicable - const annotationMeta = - ANNOTATION_TYPES_METADATA[ - annotationType as keyof typeof ANNOTATION_TYPES_METADATA - ]; - if (annotationMeta && 'supportNativeSource' in annotationMeta) { - sources.unshift(ANNOTATION_SOURCE_TYPES_METADATA.NATIVE); - } - return sources; - } - - shouldFetchAppliedAnnotation(): boolean { - const { value, sourceType } = this.state; - return !!value && requiresQuery(sourceType ?? undefined); - } - - shouldFetchSliceData(prevState: AnnotationLayerState): boolean { - const { value, sourceType } = this.state; - const isChart = - sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && - requiresQuery(sourceType ?? undefined); - const valueIsNew = value && prevState.value !== value; - return !!valueIsNew && isChart; - } - - isValidFormulaAnnotation( - expression: string | number | SelectOption | null, - annotationType: string, - ): boolean { - if (annotationType === ANNOTATION_TYPES.FORMULA) { - return isValidExpression(expression as string); - } - return true; - } - - isValidForm(): boolean { - const { - name, - annotationType, - sourceType, - value, - timeColumn, - intervalEndColumn, - } = this.state; - const errors = [ - validateNonEmpty(name), - validateNonEmpty(annotationType), - validateNonEmpty(value), - ]; - if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) { - if (annotationType === ANNOTATION_TYPES.EVENT) { - errors.push(validateNonEmpty(timeColumn)); - } - if (annotationType === ANNOTATION_TYPES.INTERVAL) { - errors.push(validateNonEmpty(timeColumn)); - errors.push(validateNonEmpty(intervalEndColumn)); - } - } - if (!this.isValidFormulaAnnotation(value, annotationType)) { - errors.push(t('Invalid formula expression')); - } - return !errors.filter(x => x).length; - } - - handleAnnotationType(annotationType: string): void { - this.setState({ - annotationType, - sourceType: null, - value: null, - slice: null, - }); - } - - handleAnnotationSourceType(sourceType: string): void { - const { sourceType: prevSourceType } = this.state; - - if (prevSourceType !== sourceType) { - this.setState({ - sourceType, - value: null, - slice: null, + const fetchNativeAnnotations = useCallback( + async ( + search: string, + page: number, + pageSize: number, + ): Promise<{ data: SelectOption[]; totalCount: number }> => { + const queryParams = rison.encode({ + filters: [ + { + col: 'name', + opr: 'ct', + value: search, + }, + ], + columns: ['id', 'name'], + page, + page_size: pageSize, }); - } - } - handleSelectValue(selectedValueObject: SelectOption): void { - this.setState({ - value: selectedValueObject, - descriptionColumns: [], - intervalEndColumn: '', - timeColumn: '', - titleColumn: '', - overrides: { time_range: null }, - }); - } + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/annotation_layer/?q=${queryParams}`, + }); - handleTextValue(inputValue: string): void { - this.setState({ - value: inputValue, - }); - } + const { result, count } = json; - fetchNativeAnnotations = async ( - search: string, - page: number, - pageSize: number, - ): Promise<{ data: SelectOption[]; totalCount: number }> => { - const queryParams = rison.encode({ - filters: [ - { - col: 'name', - opr: 'ct', - value: search, - }, - ], - columns: ['id', 'name'], - page, - page_size: pageSize, - }); - - const { json } = await SupersetClient.get({ - endpoint: `/api/v1/annotation_layer/?q=${queryParams}`, - }); - - const { result, count } = json; - - const layersArray = result.map((layer: { id: number; name: string }) => ({ - value: layer.id, - label: layer.name, - })); - - return { - data: layersArray, - totalCount: count, - }; - }; - - fetchCharts = async ( - search: string, - page: number, - pageSize: number, - ): Promise<{ data: SelectOption[]; totalCount: number }> => { - const { annotationType } = this.state; - - const queryParams = rison.encode({ - filters: [ - { col: 'slice_name', opr: 'chart_all_text', value: search }, - { - col: 'id', - opr: 'chart_owned_created_favored_by_me', - value: true, - }, - ], - columns: ['id', 'slice_name', 'viz_type'], - order_column: 'slice_name', - order_direction: 'asc', - page, - page_size: pageSize, - }); - const { json } = await SupersetClient.get({ - endpoint: `/api/v1/chart/?q=${queryParams}`, - }); - - const { result, count } = json; - const registry = getChartMetadataRegistry(); - - const chartsArray = result - .filter((chart: { id: number; slice_name: string; viz_type: string }) => { - const metadata = registry.get(chart.viz_type); - return metadata && metadata.canBeAnnotationType(annotationType); - }) - .map((chart: { id: number; slice_name: string; viz_type: string }) => ({ - value: chart.id, - label: chart.slice_name, - viz_type: chart.viz_type, + const layersArray = result.map((layer: { id: number; name: string }) => ({ + value: layer.id, + label: layer.name, })); - return { - data: chartsArray, - totalCount: count, - }; - }; + return { + data: layersArray, + totalCount: count, + }; + }, + [], + ); - fetchOptions = ( - search: string, - page: number, - pageSize: number, - ): Promise<{ data: SelectOption[]; totalCount: number }> => { - const { sourceType } = this.state; + const fetchCharts = useCallback( + async ( + search: string, + page: number, + pageSize: number, + ): Promise<{ data: SelectOption[]; totalCount: number }> => { + const queryParams = rison.encode({ + filters: [ + { col: 'slice_name', opr: 'chart_all_text', value: search }, + { + col: 'id', + opr: 'chart_owned_created_favored_by_me', + value: true, + }, + ], + columns: ['id', 'slice_name', 'viz_type'], + order_column: 'slice_name', + order_direction: 'asc', + page, + page_size: pageSize, + }); + const { json } = await SupersetClient.get({ + endpoint: `/api/v1/chart/?q=${queryParams}`, + }); - if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { - return this.fetchNativeAnnotations(search, page, pageSize); - } - return this.fetchCharts(search, page, pageSize); - }; + const { result, count } = json; + const registry = getChartMetadataRegistry(); - fetchSliceData = (id: string | number): void => { + const chartsArray = result + .filter( + (chart: { id: number; slice_name: string; viz_type: string }) => { + const metadata = registry.get(chart.viz_type); + return metadata && metadata.canBeAnnotationType(annotationType); + }, + ) + .map((chart: { id: number; slice_name: string; viz_type: string }) => ({ + value: chart.id, + label: chart.slice_name, + viz_type: chart.viz_type, + })); + + return { + data: chartsArray, + totalCount: count, + }; + }, + [annotationType], + ); + + const fetchOptions = useCallback( + ( + search: string, + page: number, + pageSize: number, + ): Promise<{ data: SelectOption[]; totalCount: number }> => { + if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { + return fetchNativeAnnotations(search, page, pageSize); + } + return fetchCharts(search, page, pageSize); + }, + [sourceType, fetchNativeAnnotations, fetchCharts], + ); + + const fetchSliceData = useCallback((id: string | number): void => { const queryParams = rison.encode({ columns: ['query_context'], }); @@ -527,103 +363,215 @@ class AnnotationLayer extends PureComponent< ), }, }; - this.setState({ - slice: dataObject, - }); + setSlice(dataObject); }); - }; + }, []); - fetchAppliedChart(id: string | number): void { - const { annotationType } = this.state; - const registry = getChartMetadataRegistry(); - const queryParams = rison.encode({ - columns: ['slice_name', 'query_context', 'viz_type'], - }); - SupersetClient.get({ - endpoint: `/api/v1/chart/${id}?q=${queryParams}`, - }).then(({ json }) => { - const { result } = json; - const sliceName = result.slice_name; - const queryContext = result.query_context; - const vizType = result.viz_type; - const formData = JSON.parse(queryContext).form_data; - const metadata = registry.get(vizType); - const canBeAnnotationType = - metadata && metadata.canBeAnnotationType(annotationType); - if (canBeAnnotationType) { - this.setState({ - value: { + const fetchAppliedChart = useCallback( + (id: string | number): void => { + const registry = getChartMetadataRegistry(); + const queryParams = rison.encode({ + columns: ['slice_name', 'query_context', 'viz_type'], + }); + SupersetClient.get({ + endpoint: `/api/v1/chart/${id}?q=${queryParams}`, + }).then(({ json }) => { + const { result } = json; + const sliceName = result.slice_name; + const queryContext = result.query_context; + const chartVizType = result.viz_type; + const formData = JSON.parse(queryContext).form_data; + const metadata = registry.get(chartVizType); + const canBeAnnotationType = + metadata && metadata.canBeAnnotationType(annotationType); + if (canBeAnnotationType) { + setValue({ value: id, label: sliceName, - }, - slice: { + }); + setSlice({ data: { ...formData, groupby: formData.groupby?.map((column: QueryFormColumn) => getColumnLabel(column), ), }, - }, - }); - } - }); - } + }); + } + }); + }, + [annotationType], + ); - fetchAppliedNativeAnnotation(id: string | number): void { - SupersetClient.get({ - endpoint: `/api/v1/annotation_layer/${id}`, - }).then(({ json }) => { - const { result } = json; - const layer = result; - this.setState({ - value: { + const fetchAppliedNativeAnnotation = useCallback( + (id: string | number): void => { + SupersetClient.get({ + endpoint: `/api/v1/annotation_layer/${id}`, + }).then(({ json }) => { + const { result } = json; + const layer = result; + setValue({ value: layer.id, label: layer.name, - }, + }); }); - }); - } + }, + [], + ); - fetchAppliedAnnotation(id: string | number): void { - const { sourceType } = this.state; + const fetchAppliedAnnotation = useCallback( + (id: string | number): void => { + if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { + fetchAppliedNativeAnnotation(id); + } else { + fetchAppliedChart(id); + } + }, + [sourceType, fetchAppliedNativeAnnotation, fetchAppliedChart], + ); - if (sourceType === ANNOTATION_SOURCE_TYPES.NATIVE) { - return this.fetchAppliedNativeAnnotation(id); + // componentDidMount - fetch applied annotation if needed + useEffect(() => { + if (shouldFetchAppliedAnnotation()) { + /* The value prop is the id of the chart/native. This function will set + value in state to an object with the id as value.value to be used by + AsyncSelect */ + if ( + propValue !== null && + propValue !== undefined && + typeof propValue !== 'object' + ) { + fetchAppliedAnnotation(propValue); + } } - return this.fetchAppliedChart(id); - } + // Only run on mount + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - deleteAnnotation(): void { - this.props.removeAnnotationLayer?.(); - this.props.close?.(); - } + // Track previous value for componentDidUpdate comparison + const [prevValue, setPrevValue] = useState< + string | number | SelectOption | null + >(value); + + // componentDidUpdate - fetch slice data when value changes + useEffect(() => { + if (value !== prevValue) { + const isChart = + sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE && + requiresQuery(sourceType ?? undefined); + if (isChart && value && typeof value === 'object' && 'value' in value) { + fetchSliceData(value.value); + } + setPrevValue(value); + } + }, [value, prevValue, sourceType, fetchSliceData]); + + const isValidFormulaAnnotation = useCallback( + ( + expression: string | number | SelectOption | null, + annoType: string, + ): boolean => { + if (annoType === ANNOTATION_TYPES.FORMULA) { + return isValidExpression(expression as string); + } + return true; + }, + [], + ); + + const isValidForm = useCallback((): boolean => { + const errors = [ + validateNonEmpty(name), + validateNonEmpty(annotationType), + validateNonEmpty(value), + ]; + if (sourceType !== ANNOTATION_SOURCE_TYPES.NATIVE) { + if (annotationType === ANNOTATION_TYPES.EVENT) { + errors.push(validateNonEmpty(timeColumn)); + } + if (annotationType === ANNOTATION_TYPES.INTERVAL) { + errors.push(validateNonEmpty(timeColumn)); + errors.push(validateNonEmpty(intervalEndColumn)); + } + } + if (!isValidFormulaAnnotation(value, annotationType)) { + errors.push(t('Invalid formula expression')); + } + return !errors.filter(x => x).length; + }, [ + name, + annotationType, + sourceType, + value, + timeColumn, + intervalEndColumn, + isValidFormulaAnnotation, + ]); + + const handleAnnotationType = useCallback((annoType: string): void => { + setAnnotationType(annoType); + setSourceType(null); + setValue(null); + setSlice(null); + }, []); + + const handleAnnotationSourceType = useCallback( + (newSourceType: string): void => { + if (sourceType !== newSourceType) { + setSourceType(newSourceType); + setValue(null); + setSlice(null); + } + }, + [sourceType], + ); + + const handleSelectValue = useCallback( + (selectedValueObject: SelectOption): void => { + setValue(selectedValueObject); + setDescriptionColumns([]); + setIntervalEndColumn(''); + setTimeColumn(''); + setTitleColumn(''); + setOverrides({ time_range: null }); + }, + [], + ); + + const handleTextValue = useCallback((inputValue: string): void => { + setValue(inputValue); + }, []); + + const deleteAnnotation = useCallback((): void => { + removeAnnotationLayer?.(); + close?.(); + }, [removeAnnotationLayer, close]); + + const applyAnnotation = useCallback((): void => { + if (isValidForm()) { + const stateValues = { + name, + annotationType, + sourceType, + color, + opacity, + style, + width, + showMarkers, + hideLine, + overrides, + show, + showLabel, + titleColumn, + descriptionColumns, + timeColumn, + intervalEndColumn, + }; - applyAnnotation(): void { - const { value, sourceType } = this.state; - if (this.isValidForm()) { - const annotationFields = [ - 'name', - 'annotationType', - 'sourceType', - 'color', - 'opacity', - 'style', - 'width', - 'showMarkers', - 'hideLine', - 'overrides', - 'show', - 'showLabel', - 'titleColumn', - 'descriptionColumns', - 'timeColumn', - 'intervalEndColumn', - ]; const newAnnotation: Record = {}; - annotationFields.forEach(field => { - const stateValue = this.state[field as keyof AnnotationLayerState]; - if (stateValue !== null) { - newAnnotation[field] = stateValue; + Object.entries(stateValues).forEach(([field, fieldValue]) => { + if (fieldValue !== null) { + newAnnotation[field] = fieldValue; } }); @@ -640,33 +588,53 @@ class AnnotationLayer extends PureComponent< newAnnotation.color = null; } - this.props.addAnnotationLayer?.(newAnnotation); - this.setState({ isNew: false }); + addAnnotationLayer?.(newAnnotation); + setIsNew(false); } - } + }, [ + isValidForm, + name, + annotationType, + sourceType, + color, + opacity, + style, + width, + showMarkers, + hideLine, + overrides, + show, + showLabel, + titleColumn, + descriptionColumns, + timeColumn, + intervalEndColumn, + value, + addAnnotationLayer, + ]); - submitAnnotation(): void { - this.applyAnnotation(); - this.props.close?.(); - } + const submitAnnotation = useCallback((): void => { + applyAnnotation(); + close?.(); + }, [applyAnnotation, close]); - renderChartHeader( - label: string, - description: string, - value: string | number | SelectOption | null, - ): React.ReactNode { - return ( + const renderChartHeader = useCallback( + ( + label: string, + description: string, + val: string | number | SelectOption | null, + ): React.ReactNode => ( - ); - } + ), + [], + ); - renderValueConfiguration(): React.ReactNode { - const { annotationType, sourceType, value } = this.state; + const renderValueConfiguration = useCallback((): React.ReactNode => { let label = ''; let description = ''; if (requiresQuery(sourceType ?? undefined)) { @@ -678,7 +646,7 @@ class AnnotationLayer extends PureComponent< description = t( `Use another existing chart as a source for annotations and overlays. Your chart must be one of these visualization types: [%s]`, - this.getSupportedSourceTypes(annotationType) + getSupportedSourceTypes(annotationType) .map(x => x.label) .join(', '), ); @@ -696,10 +664,10 @@ class AnnotationLayer extends PureComponent< key={sourceType} ariaLabel={t('Annotation layer value')} name="annotation-layer-value" - header={this.renderChartHeader(label, description, value)} - options={this.fetchOptions} + header={renderChartHeader(label, description, value)} + options={fetchOptions} value={value || null} - onChange={this.handleSelectValue} + onChange={handleSelectValue} notFoundContent={} /> ); @@ -716,9 +684,9 @@ class AnnotationLayer extends PureComponent< label={label} placeholder="" value={textValue} - onChange={this.handleTextValue} + onChange={handleTextValue} validationErrors={ - !this.isValidFormulaAnnotation(value, annotationType) + !isValidFormulaAnnotation(value, annotationType) ? [t('Bad formula.')] : [] } @@ -726,21 +694,19 @@ class AnnotationLayer extends PureComponent< ); } return ''; - } - - renderSliceConfiguration(): React.ReactNode { - const { - annotationType, - sourceType, - value, - slice, - overrides, - titleColumn, - timeColumn, - intervalEndColumn, - descriptionColumns, - } = this.state; + }, [ + sourceType, + annotationType, + value, + getSupportedSourceTypes, + renderChartHeader, + fetchOptions, + handleSelectValue, + handleTextValue, + isValidFormulaAnnotation, + ]); + const renderSliceConfiguration = useCallback((): React.ReactNode => { if (!slice || !value) { return ''; } @@ -780,7 +746,7 @@ class AnnotationLayer extends PureComponent< value={timeColumn} onChange={( v: string | number | (string | number)[] | null | undefined, - ) => this.setState({ timeColumn: String(v ?? '') })} + ) => setTimeColumn(String(v ?? ''))} /> )} {annotationType === ANNOTATION_TYPES.INTERVAL && ( @@ -796,13 +762,8 @@ class AnnotationLayer extends PureComponent< options={columns} value={intervalEndColumn} onChange={( - value: - | string - | number - | (string | number)[] - | null - | undefined, - ) => this.setState({ intervalEndColumn: String(value ?? '') })} + v: string | number | (string | number)[] | null | undefined, + ) => setIntervalEndColumn(String(v ?? ''))} /> )} this.setState({ titleColumn: String(value ?? '') })} + v: string | number | (string | number)[] | null | undefined, + ) => setTitleColumn(String(v ?? ''))} /> {annotationType !== ANNOTATION_TYPES.TIME_SERIES && ( { - const cols = Array.isArray(value) ? value.map(String) : []; - this.setState({ descriptionColumns: cols }); + const cols = Array.isArray(v) ? v.map(String) : []; + setDescriptionColumns(cols); }} /> )} @@ -851,13 +807,12 @@ class AnnotationLayer extends PureComponent< view should be passed down to the chart containing the annotation data.`)} value={'time_range' in overrides} onChange={v => { - delete overrides.time_range; + const newOverrides = { ...overrides }; + delete newOverrides.time_range; if (v) { - this.setState({ - overrides: { ...overrides, time_range: null }, - }); + setOverrides({ ...newOverrides, time_range: null }); } else { - this.setState({ overrides: { ...overrides } }); + setOverrides({ ...newOverrides }); } }} /> @@ -869,18 +824,17 @@ class AnnotationLayer extends PureComponent< view should be passed down to the chart containing the annotation data.`)} value={'time_grain_sqla' in overrides} onChange={v => { - delete overrides.time_grain_sqla; - delete overrides.granularity; + const newOverrides = { ...overrides }; + delete newOverrides.time_grain_sqla; + delete newOverrides.granularity; if (v) { - this.setState({ - overrides: { - ...overrides, - time_grain_sqla: null, - granularity: null, - }, + setOverrides({ + ...newOverrides, + time_grain_sqla: null, + granularity: null, }); } else { - this.setState({ overrides: { ...overrides } }); + setOverrides({ ...newOverrides }); } }} /> @@ -892,9 +846,7 @@ class AnnotationLayer extends PureComponent< (example: 24 hours, 7 days, 56 weeks, 365 days)`)} placeholder="" value={overrides.time_shift} - onChange={v => - this.setState({ overrides: { ...overrides, time_shift: v } }) - } + onChange={v => setOverrides({ ...overrides, time_shift: v })} /> @@ -902,28 +854,27 @@ class AnnotationLayer extends PureComponent< ); } return ''; - } + }, [ + slice, + value, + sourceType, + annotationType, + timeColumn, + intervalEndColumn, + titleColumn, + descriptionColumns, + overrides, + ]); - renderDisplayConfiguration(): React.ReactNode { - const { - color, - opacity, - style, - width, - showMarkers, - hideLine, - annotationType, - } = this.state; - const colorScheme = - getCategoricalSchemeRegistry() - .get(this.props.colorScheme) - ?.colors.concat() ?? []; + const renderDisplayConfiguration = useCallback((): React.ReactNode => { + const schemeColors = + getCategoricalSchemeRegistry().get(colorScheme)?.colors.concat() ?? []; if ( color && color !== AUTOMATIC_COLOR && - !colorScheme.some(x => x.toLowerCase() === color.toLowerCase()) + !schemeColors.find(x => x.toLowerCase() === color.toLowerCase()) ) { - colorScheme.push(color); + schemeColors.push(color); } return ( this.setState({ style: String(v ?? 'solid') })} + ) => setStyle(String(v ?? 'solid'))} /> this.setState({ opacity: String(value ?? '') })} + v: string | number | (string | number)[] | null | undefined, + ) => setOpacity(String(v ?? ''))} />
{ if (useAutomatic) { - this.setState({ color: AUTOMATIC_COLOR }); + setColor(AUTOMATIC_COLOR); } else { // Set to first theme color or dark color as fallback - this.setState({ - color: colorScheme[0] || this.props.theme.colorTextBase, - }); + setColor(schemeColors[0] || theme.colorTextBase); } }} /> {color !== AUTOMATIC_COLOR && ( -
+
- this.setState({ color: colorValue.toHexString() }) + setColor(colorValue.toHexString()) } showText /> @@ -1004,7 +953,7 @@ class AnnotationLayer extends PureComponent< label={t('Line width')} isInt value={width} - onChange={v => this.setState({ width: v })} + onChange={v => setWidth(v)} /> {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( this.setState({ showMarkers: v })} + onChange={v => setShowMarkers(v)} /> )} {annotationType === ANNOTATION_TYPES.TIME_SERIES && ( @@ -1023,137 +972,141 @@ class AnnotationLayer extends PureComponent< label={t('Hide Line')} description={t('Hides the Line for the time series')} value={hideLine} - onChange={v => this.setState({ hideLine: v })} + onChange={v => setHideLine(v)} /> )} ); - } + }, [ + colorScheme, + color, + opacity, + style, + width, + showMarkers, + hideLine, + annotationType, + theme, + ]); - render(): React.ReactNode { - const { isNew, name, annotationType, sourceType, show, showLabel } = - this.state; - const isValid = this.isValidForm(); - const metadata = this.props.vizType - ? getChartMetadataRegistry().get(this.props.vizType) - : null; - const supportedAnnotationTypes = metadata - ? metadata.supportedAnnotationTypes.map( - type => - ANNOTATION_TYPES_METADATA[ - type as keyof typeof ANNOTATION_TYPES_METADATA - ], - ) - : []; - const supportedSourceTypes = this.getSupportedSourceTypes(annotationType); + const isValid = isValidForm(); + const metadata = vizType ? getChartMetadataRegistry().get(vizType) : null; + const supportedAnnotationTypes = metadata + ? metadata.supportedAnnotationTypes.map( + type => + ANNOTATION_TYPES_METADATA[ + type as keyof typeof ANNOTATION_TYPES_METADATA + ], + ) + : []; + const supportedSourceTypes = getSupportedSourceTypes(annotationType); - return ( - <> - {this.props.error && ( - - {t('ERROR')}: {this.props.error} - - )} -
-
- - this.setState({ name: v })} - validationErrors={!name ? [t('Mandatory')] : []} - /> - this.setState({ show: !v })} - /> - this.setState({ showLabel: v })} - /> + return ( + <> + {error && ( + + {t('ERROR')}: {error} + + )} +
+
+ + setName(v)} + validationErrors={!name ? [t('Mandatory')] : []} + /> + setShow(!v)} + /> + setShowLabel(v)} + /> + + {supportedSourceTypes.length > 0 && ( } + value={sourceType} + onChange={handleAnnotationSourceType} + validationErrors={!sourceType ? [t('Mandatory')] : []} /> - {supportedSourceTypes.length > 0 && ( - } - value={sourceType} - onChange={this.handleAnnotationSourceType} - validationErrors={!sourceType ? [t('Mandatory')] : []} - /> - )} - {this.renderValueConfiguration()} - -
- {this.renderSliceConfiguration()} - {this.renderDisplayConfiguration()} + )} + {renderValueConfiguration()} +
-
- {isNew ? ( - - ) : ( - - )} -
- + {renderSliceConfiguration()} + {renderDisplayConfiguration()} +
+
+ {isNew ? ( + + ) : ( + + )} +
+ - -
+
- - ); - } +
+ + ); } -export default withTheme(AnnotationLayer); +export default AnnotationLayer; diff --git a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx index 9339f7433fe..27ab27ab662 100644 --- a/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/AnnotationLayerControl/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import { connect } from 'react-redux'; -import { PureComponent } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { t } from '@apache-superset/core'; import { HandlerFunction, @@ -25,7 +25,7 @@ import { Payload, QueryFormData, } from '@superset-ui/core'; -import { SupersetTheme, withTheme } from '@apache-superset/core/ui'; +import { SupersetTheme, useTheme } from '@apache-superset/core/ui'; import { AsyncEsmComponent, List, @@ -72,7 +72,7 @@ export interface Props { value: Annotation[]; onChange: (annotations: Annotation[]) => void; refreshAnnotationData: (payload: Payload) => void; - theme: SupersetTheme; + theme?: SupersetTheme; } export interface PopoverState { @@ -80,200 +80,200 @@ export interface PopoverState { addedAnnotationIndex: number | null; } -const defaultProps = { - vizType: '', - value: [], - annotationError: {}, - annotationQuery: {}, - onChange: () => {}, -}; -class AnnotationLayerControl extends PureComponent { - static defaultProps = defaultProps; +function AnnotationLayerControl({ + colorScheme, + annotationError = {}, + annotationQuery = {}, + vizType = '', + validationErrors, + name, + actions, + value = [], + onChange = () => {}, + refreshAnnotationData, +}: Props) { + const theme = useTheme(); + const [popoverVisible, setPopoverVisible] = useState< + Record + >({}); + const [addedAnnotationIndex, setAddedAnnotationIndex] = useState< + number | null + >(null); - constructor(props: Props) { - super(props); - this.state = { - popoverVisible: {}, - addedAnnotationIndex: null, - }; - this.addAnnotationLayer = this.addAnnotationLayer.bind(this); - this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this); - this.handleVisibleChange = this.handleVisibleChange.bind(this); - } - - componentDidMount() { - // preload the AnnotationLayer component and dependent libraries i.e. mathjs + // componentDidMount - preload the AnnotationLayer component and dependent libraries i.e. mathjs + useEffect(() => { AnnotationLayer.preload(); - } + }, []); - componentDidUpdate(prevProps: Props) { - const { name, annotationError, validationErrors, value } = this.props; + // componentDidUpdate - sync validation errors + useEffect(() => { if ( (Object.keys(annotationError).length && !validationErrors.length) || (!Object.keys(annotationError).length && validationErrors.length) ) { - if ( - annotationError !== prevProps.annotationError || - validationErrors !== prevProps.validationErrors || - value !== prevProps.value - ) { - this.props.actions.setControlValue( - name, - value, - Object.keys(annotationError), + actions.setControlValue(name, value, Object.keys(annotationError)); + } + }, [annotationError, validationErrors, value, actions, name]); + + const addAnnotationLayer = useCallback( + (originalAnnotation: Annotation | null, newAnnotation: Annotation) => { + let annotations = value; + if (originalAnnotation && annotations.includes(originalAnnotation)) { + annotations = annotations.map(anno => + anno === originalAnnotation ? newAnnotation : anno, + ); + } else { + annotations = [...annotations, newAnnotation]; + setAddedAnnotationIndex(annotations.length - 1); + } + + refreshAnnotationData({ + annotation: newAnnotation, + force: true, + }); + + onChange(annotations); + }, + [value, refreshAnnotationData, onChange], + ); + + const handleVisibleChange = useCallback( + (visible: boolean, popoverKey: number | string) => { + setPopoverVisible(prev => ({ + ...prev, + [popoverKey]: visible, + })); + }, + [], + ); + + const removeAnnotationLayer = useCallback( + (annotation: Annotation | null) => { + const annotations = value.filter(anno => anno !== annotation); + // So scrollbar doesnt get stuck on hidden + const element = getSectionContainerElement(); + if (element) { + element.style.setProperty('overflow-y', 'auto', 'important'); + } + onChange(annotations); + }, + [value, onChange], + ); + + const renderPopover = useCallback( + ( + popoverKey: number | string, + annotation: Annotation | null, + error: string, + ) => { + const id = annotation?.name || '_new'; + + return ( +
+ + addAnnotationLayer(annotation, newAnnotation) + } + removeAnnotationLayer={() => removeAnnotationLayer(annotation)} + close={() => { + handleVisibleChange(false, popoverKey); + setAddedAnnotationIndex(null); + }} + /> +
+ ); + }, + [ + colorScheme, + vizType, + addAnnotationLayer, + removeAnnotationLayer, + handleVisibleChange, + ], + ); + + const renderInfo = useCallback( + (anno: Annotation) => { + if (annotationQuery[anno.name]) { + return ( + ); } - } - } + if (annotationError[anno.name]) { + return ( + + ); + } + if (!anno.show) { + return {t('Hidden')} ; + } + return ''; + }, + [annotationQuery, annotationError, theme], + ); - addAnnotationLayer = ( - originalAnnotation: Annotation | null, - newAnnotation: Annotation, - ) => { - let annotations = this.props.value; - if (originalAnnotation && annotations.includes(originalAnnotation)) { - annotations = annotations.map(anno => - anno === originalAnnotation ? newAnnotation : anno, - ); - } else { - annotations = [...annotations, newAnnotation]; - this.setState({ addedAnnotationIndex: annotations.length - 1 }); - } + const addedAnnotation = useMemo( + () => (addedAnnotationIndex !== null ? value[addedAnnotationIndex] : null), + [addedAnnotationIndex, value], + ); - this.props.refreshAnnotationData({ - annotation: newAnnotation, - force: true, - }); + const annotations = value.map((anno, i) => ( + ({ + '&:hover': { + cursor: 'pointer', + backgroundColor: thm.colorFillContentHover, + }, + })} + content={renderPopover(i, anno, annotationError[anno.name])} + open={popoverVisible[i]} + onOpenChange={visible => handleVisibleChange(visible, i)} + > + + {anno.name} + {renderInfo(anno)} + + + )); - this.props.onChange(annotations); - }; + const addLayerPopoverKey = 'add'; - handleVisibleChange = (visible: boolean, popoverKey: number | string) => { - this.setState(prevState => ({ - popoverVisible: { ...prevState.popoverVisible, [popoverKey]: visible }, - })); - }; - - removeAnnotationLayer(annotation: Annotation | null) { - const annotations = this.props.value.filter(anno => anno !== annotation); - // So scrollbar doesnt get stuck on hidden - const element = getSectionContainerElement(); - if (element) { - element.style.setProperty('overflow-y', 'auto', 'important'); - } - this.props.onChange(annotations); - } - - renderPopover = ( - popoverKey: number | string, - annotation: Annotation | null, - error: string, - ) => { - const id = annotation?.name || '_new'; - - return ( -
- - this.addAnnotationLayer(annotation, newAnnotation) + return ( +
+ ({ borderRadius: thm.borderRadius })}> + {annotations} + + handleVisibleChange(visible, addLayerPopoverKey) } - removeAnnotationLayer={() => this.removeAnnotationLayer(annotation)} - close={() => { - this.handleVisibleChange(false, popoverKey); - this.setState({ addedAnnotationIndex: null }); - }} - /> -
- ); - }; - - renderInfo(anno: Annotation) { - const { annotationError, annotationQuery, theme } = this.props; - if (annotationQuery[anno.name]) { - return ; - } - if (annotationError[anno.name]) { - return ( - - ); - } - if (!anno.show) { - return {t('Hidden')} ; - } - return ''; - } - - render() { - const { addedAnnotationIndex } = this.state; - const addedAnnotation = - addedAnnotationIndex !== null - ? this.props.value[addedAnnotationIndex] - : null; - const annotations = this.props.value.map((anno, i) => ( - ({ - '&:hover': { - cursor: 'pointer', - backgroundColor: theme.colorFillContentHover, - }, - })} - content={this.renderPopover( - i, - anno, - this.props.annotationError[anno.name], - )} - open={this.state.popoverVisible[i]} - onOpenChange={visible => this.handleVisibleChange(visible, i)} - > - - {anno.name} - {this.renderInfo(anno)} - - - )); - const addLayerPopoverKey = 'add'; - - return ( -
- ({ borderRadius: theme.borderRadius })}> - {annotations} - - this.handleVisibleChange(visible, addLayerPopoverKey) - } - > - - - {t('Add annotation layer')} - - - -
- ); - } + > + + + {t('Add annotation layer')} + + + +
+ ); } // Tried to hook this up through stores/control.jsx instead of using redux @@ -316,9 +316,7 @@ function mapDispatchToProps( }; } -const themedAnnotationLayerControl = withTheme(AnnotationLayerControl); - export default connect( mapStateToProps, mapDispatchToProps, -)(themedAnnotationLayerControl); +)(AnnotationLayerControl); diff --git a/superset-frontend/src/explore/components/controls/CheckboxControl.tsx b/superset-frontend/src/explore/components/controls/CheckboxControl.tsx index f9120159e90..76492ecaac6 100644 --- a/superset-frontend/src/explore/components/controls/CheckboxControl.tsx +++ b/superset-frontend/src/explore/components/controls/CheckboxControl.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component, type ReactNode } from 'react'; +import { useCallback, type ReactNode } from 'react'; import { styled, css } from '@apache-superset/core/ui'; import { Checkbox } from '@superset-ui/core/components'; import ControlHeader from '../ControlHeader'; @@ -47,32 +47,29 @@ const CheckBoxControlWrapper = styled.div` `} `; -export default class CheckboxControl extends Component { - static defaultProps = { - value: false, - onChange: () => {}, - }; +export default function CheckboxControl({ + value = false, + label, + onChange = () => {}, + ...restProps +}: CheckboxControlProps): JSX.Element { + const handleChange = useCallback((): void => { + onChange(!value); + }, [onChange, value]); - onChange = (): void => { - this.props.onChange?.(!this.props.value); - }; + const checkbox = ; - renderCheckbox(): ReactNode { - return ; - } - - render(): ReactNode { - if (this.props.label) { - return ( - - - - ); - } - return this.renderCheckbox(); + if (label) { + return ( + + + + ); } + return checkbox; } diff --git a/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx index 0243b669f2b..2cb30fd68a4 100644 --- a/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/CollectionControl/index.tsx @@ -16,11 +16,11 @@ * specific language governing permissions and limitations * under the License. */ -import React, { Component } from 'react'; +import React, { useCallback } from 'react'; import { IconTooltip, List } from '@superset-ui/core/components'; import { nanoid } from 'nanoid'; import { t } from '@apache-superset/core'; -import { withTheme, type SupersetTheme } from '@apache-superset/core/ui'; +import { useTheme, type SupersetTheme } from '@apache-superset/core/ui'; import { SortableContainer, SortableHandle, @@ -54,19 +54,8 @@ interface CollectionControlProps { isFloat?: boolean; isInt?: boolean; controlName: string; - theme: SupersetTheme; } -const defaultProps: Partial = { - label: null, - description: null, - onChange: () => {}, - placeholder: t('Empty collection'), - itemGenerator: () => ({ key: nanoid(11) }), - keyAccessor: (o: CollectionItem) => o.key ?? '', - value: [], - addTooltip: t('Add an item'), -}; const SortableListItem = SortableElement(CustomListItem); const SortableList = SortableContainer(List); const SortableDragger = SortableHandle(() => ( @@ -78,137 +67,158 @@ const SortableDragger = SortableHandle(() => ( /> )); -class CollectionControl extends Component { - static defaultProps = defaultProps; +const defaultItemGenerator = () => ({ key: nanoid(11) }); +const defaultKeyAccessor = (o: CollectionItem) => o.key ?? ''; - constructor(props: CollectionControlProps) { - super(props); - this.onAdd = this.onAdd.bind(this); - } +export default function CollectionControl({ + name, + label = null, + description = null, + placeholder = t('Empty collection'), + addTooltip = t('Add an item'), + itemGenerator = defaultItemGenerator, + keyAccessor = defaultKeyAccessor, + onChange = () => {}, + value = [], + isFloat, + isInt, + controlName, +}: CollectionControlProps) { + const theme = useTheme(); - onChange(i: number, value: CollectionItem) { - const currentValue = this.props.value ?? []; - const newValue = [...currentValue]; - newValue[i] = { ...currentValue[i], ...value }; - this.props.onChange?.(newValue); - } + const handleChange = useCallback( + (i: number, itemValue: CollectionItem) => { + const newValue = [...value]; + newValue[i] = { ...value[i], ...itemValue }; + onChange(newValue); + }, + [value, onChange], + ); - onAdd() { - const currentValue = this.props.value ?? []; - const newItem = this.props.itemGenerator?.(); + const handleAdd = useCallback(() => { + const newItem = itemGenerator(); // Cast needed: original JS allowed undefined items from itemGenerator - this.props.onChange?.( - currentValue.concat([newItem] as unknown as CollectionItem[]), - ); - } + onChange(value.concat([newItem] as unknown as CollectionItem[])); + }, [value, onChange, itemGenerator]); - onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) { - const currentValue = this.props.value ?? []; - this.props.onChange?.(arrayMove(currentValue, oldIndex, newIndex)); - } + const handleSortEnd = useCallback( + ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => { + onChange(arrayMove(value, oldIndex, newIndex)); + }, + [value, onChange], + ); - removeItem(i: number) { - const currentValue = this.props.value ?? []; - this.props.onChange?.(currentValue.filter((o, ix) => i !== ix)); - } + const removeItem = useCallback( + (i: number) => { + onChange(value.filter((o, ix) => i !== ix)); + }, + [value, onChange], + ); - renderList() { - const currentValue = this.props.value ?? []; - if (currentValue.length === 0) { - return
{this.props.placeholder}
; + const renderList = () => { + if (value.length === 0) { + return
{placeholder}
; } const Control = (controlMap as Record>)[ - this.props.controlName + controlName ]; - const keyAccessor = - this.props.keyAccessor ?? ((o: CollectionItem) => o.key ?? ''); return ( ({ - borderRadius: theme.borderRadius, + css={(themeArg: SupersetTheme) => ({ + borderRadius: themeArg.borderRadius, })} > - {currentValue.map((o: CollectionItem, i: number) => { - // label relevant only for header, not here - const { label, theme, ...commonProps } = this.props; - return ( - ({ - alignItems: 'center', - justifyContent: 'flex-start', - display: 'flex', - paddingInline: theme.sizeUnit * 6, + {value.map((o: CollectionItem, i: number) => ( + ({ + alignItems: 'center', + justifyContent: 'flex-start', + display: 'flex', + paddingInline: themeArg.sizeUnit * 6, + })} + key={keyAccessor(o)} + index={i} + > + +
({ + flex: 1, + marginLeft: themeArg.sizeUnit * 2, + marginRight: themeArg.sizeUnit * 2, })} - key={keyAccessor(o)} - index={i} > - -
({ - flex: 1, - marginLeft: theme.sizeUnit * 2, - marginRight: theme.sizeUnit * 2, - })} - > - -
- ({ - padding: 0, - minWidth: 'auto', - height: 'auto', - lineHeight: 1, - cursor: 'pointer', - '& svg path': { - fill: theme.colorIcon, - transition: `fill ${theme.motionDurationMid} ease-out`, - }, - '&:hover svg path': { - fill: theme.colorError, - }, - })} - > - - - - ); - })} + + handleChange(i, itemValue) + } + /> +
+ removeItem(i)} + tooltip={t('Remove item')} + mouseEnterDelay={0} + mouseLeaveDelay={0} + css={(themeArg: SupersetTheme) => ({ + padding: 0, + minWidth: 'auto', + height: 'auto', + lineHeight: 1, + cursor: 'pointer', + '& svg path': { + fill: themeArg.colorIcon, + transition: `fill ${themeArg.motionDurationMid} ease-out`, + }, + '&:hover svg path': { + fill: themeArg.colorError, + }, + })} + > + + +
+ ))}
); - } + }; - render() { - return ( -
- - - - - - - {this.renderList()} -
- ); - } + // Props for ControlHeader (excluding label and theme which are handled separately) + const controlHeaderProps = { + name, + label, + description, + }; + + return ( +
+ + + + + + + {renderList()} +
+ ); } - -export default withTheme(CollectionControl); diff --git a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx index 968c00d20a4..b6580bc97c4 100644 --- a/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/DatasourceControl/index.tsx @@ -18,15 +18,10 @@ * under the License. */ -import React, { PureComponent } from 'react'; +import React, { useState, useCallback } from 'react'; import { DatasourceType, SupersetClient, Datasource } from '@superset-ui/core'; import { t } from '@apache-superset/core'; -import { - css, - styled, - withTheme, - type SupersetTheme, -} from '@apache-superset/core/ui'; +import { css, styled, useTheme } from '@apache-superset/core/ui'; import { getTemporalColumns } from '@superset-ui/chart-controls'; import { getUrlParam } from 'src/utils/urlUtils'; import { @@ -99,7 +94,6 @@ interface DatasourceControlProps { form_data?: FormData; isEditable?: boolean; onDatasourceSave?: ((datasource: ExtendedDatasource) => void) | null; - theme: SupersetTheme; user: User; // ControlHeader-related props hovered?: boolean; @@ -111,20 +105,6 @@ interface DatasourceControlProps { name?: string; } -interface DatasourceControlState { - showEditDatasourceModal: boolean; - showChangeDatasourceModal: boolean; - showSaveDatasetModal: boolean; - showDatasource?: boolean; -} - -const defaultProps = { - onChange: () => {}, - onDatasourceSave: null, - value: null, - isEditable: true, -}; - const getDatasetType = (datasource: ExtendedDatasource): string => { if (datasource.type === 'query') { return 'query'; @@ -234,397 +214,372 @@ const preventRouterLinkWhileMetaClicked = (evt: React.MouseEvent) => { } }; -class DatasourceControl extends PureComponent< - DatasourceControlProps, - DatasourceControlState -> { - static defaultProps = defaultProps; +export default function DatasourceControl({ + actions, + onChange = () => {}, + value = null, + datasource, + form_data, + isEditable = true, + onDatasourceSave = null, + user, +}: DatasourceControlProps) { + const theme = useTheme(); - constructor(props: DatasourceControlProps) { - super(props); - this.state = { - showEditDatasourceModal: false, - showChangeDatasourceModal: false, - showSaveDatasetModal: false, - }; + const [showEditDatasourceModal, setShowEditDatasourceModal] = useState(false); + const [showChangeDatasourceModal, setShowChangeDatasourceModal] = + useState(false); + const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false); + + const handleDatasourceSave = useCallback( + (savedDatasource: Datasource) => { + // Cast to ExtendedDatasource for the component's internal use + actions.changeDatasource(savedDatasource as ExtendedDatasource); + // Cast datasource for getTemporalColumns which expects Dataset | QueryResponse + const { temporalColumns, defaultTemporalColumn } = getTemporalColumns( + savedDatasource as Parameters[0], + ); + const { columns } = savedDatasource; + // the granularity_sqla might not be a temporal column anymore + const timeCol = form_data?.granularity_sqla; + const isGranularitySqlaTemporal = columns.find( + ({ column_name }) => column_name === timeCol, + )?.is_dttm; + // the main_dttm_col might not be a temporal column anymore + const isDefaultTemporal = columns.find( + ({ column_name }) => column_name === defaultTemporalColumn, + )?.is_dttm; + + // if granularity_sqla is empty or it is not a temporal column anymore + // let's update the control value + if (savedDatasource.type === 'table' && !isGranularitySqlaTemporal) { + const temporalColumn = isDefaultTemporal + ? defaultTemporalColumn + : temporalColumns?.[0]; + actions.setControlValue('granularity_sqla', temporalColumn || null); + } + + if (onDatasourceSave) { + onDatasourceSave(savedDatasource); + } + }, + [actions, form_data?.granularity_sqla, onDatasourceSave], + ); + + const toggleChangeDatasourceModal = useCallback(() => { + setShowChangeDatasourceModal(prev => !prev); + }, []); + + const toggleEditDatasourceModal = useCallback(() => { + setShowEditDatasourceModal(prev => !prev); + }, []); + + const toggleSaveDatasetModal = useCallback(() => { + setShowSaveDatasetModal(prev => !prev); + }, []); + + const handleMenuItemClick = useCallback( + ({ key }: { key: string }) => { + switch (key) { + case CHANGE_DATASET: + toggleChangeDatasourceModal(); + break; + + case EDIT_DATASET: + toggleEditDatasourceModal(); + break; + + case VIEW_IN_SQL_LAB: + { + const payload = { + datasourceKey: `${datasource.id}__${datasource.type}`, + sql: datasource.sql, + }; + SupersetClient.postForm('/sqllab/', { + form_data: safeStringify(payload), + }); + } + break; + + case SAVE_AS_DATASET: + toggleSaveDatasetModal(); + break; + + default: + break; + } + }, + [ + datasource, + toggleChangeDatasourceModal, + toggleEditDatasourceModal, + toggleSaveDatasetModal, + ], + ); + + let extra; + if (datasource?.extra) { + if (typeof datasource.extra === 'string') { + try { + extra = JSON.parse(datasource.extra); + } catch {} // eslint-disable-line no-empty + } else { + extra = datasource.extra; // eslint-disable-line prefer-destructuring + } + } + const isMissingDatasource = !datasource?.id || Boolean(extra?.error); + let isMissingParams = false; + if (isMissingDatasource) { + const datasourceId = getUrlParam(URL_PARAMS.datasourceId); + const sliceId = getUrlParam(URL_PARAMS.sliceId); + + if (!datasourceId && !sliceId) { + isMissingParams = true; + } } - onDatasourceSave = (datasource: Datasource) => { - // Cast to ExtendedDatasource for the component's internal use - this.props.actions.changeDatasource(datasource as ExtendedDatasource); - // Cast datasource for getTemporalColumns which expects Dataset | QueryResponse - const { temporalColumns, defaultTemporalColumn } = getTemporalColumns( - datasource as Parameters[0], - ); - const { columns } = datasource; - // the current granularity_sqla might not be a temporal column anymore - const timeCol = this.props.form_data?.granularity_sqla; - const isGranularitySqlaTemporal = columns.find( - ({ column_name }) => column_name === timeCol, - )?.is_dttm; - // the current main_dttm_col might not be a temporal column anymore - const isDefaultTemporal = columns.find( - ({ column_name }) => column_name === defaultTemporalColumn, - )?.is_dttm; + const allowEdit = + datasource.owners?.map(o => o.id || o.value).includes(user.userId) || + isUserAdmin(user); - // if the current granularity_sqla is empty or it is not a temporal column anymore - // let's update the control value - if (datasource.type === 'table' && !isGranularitySqlaTemporal) { - const temporalColumn = isDefaultTemporal - ? defaultTemporalColumn - : temporalColumns?.[0]; - this.props.actions.setControlValue( - 'granularity_sqla', - temporalColumn || null, - ); - } + const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access'); - if (this.props.onDatasourceSave) { - this.props.onDatasourceSave(datasource); - } + const editText = t('Edit dataset'); + const requestedQuery = { + datasourceKey: `${datasource.id}__${datasource.type}`, + sql: datasource.sql, }; - - toggleShowDatasource = () => { - this.setState(({ showDatasource }) => ({ - showDatasource: !showDatasource, - })); - }; - - toggleChangeDatasourceModal = () => { - this.setState(({ showChangeDatasourceModal }) => ({ - showChangeDatasourceModal: !showChangeDatasourceModal, - })); - }; - - toggleEditDatasourceModal = () => { - this.setState(({ showEditDatasourceModal }) => ({ - showEditDatasourceModal: !showEditDatasourceModal, - })); - }; - - toggleSaveDatasetModal = () => { - this.setState(({ showSaveDatasetModal }) => ({ - showSaveDatasetModal: !showSaveDatasetModal, - })); - }; - - handleMenuItemClick = ({ key }: { key: string }) => { - switch (key) { - case CHANGE_DATASET: - this.toggleChangeDatasourceModal(); - break; - - case EDIT_DATASET: - this.toggleEditDatasourceModal(); - break; - - case VIEW_IN_SQL_LAB: - { - const { datasource } = this.props; - const payload = { - datasourceKey: `${datasource.id}__${datasource.type}`, - sql: datasource.sql, - }; - SupersetClient.postForm('/sqllab/', { - form_data: safeStringify(payload), - }); - } - break; - - case SAVE_AS_DATASET: - this.toggleSaveDatasetModal(); - break; - - default: - break; - } - }; - - render() { - const { - showChangeDatasourceModal, - showEditDatasourceModal, - showSaveDatasetModal, - } = this.state; - const { datasource, onChange, theme } = this.props; - let extra; - if (datasource?.extra) { - if (typeof datasource.extra === 'string') { - try { - extra = JSON.parse(datasource.extra); - } catch {} // eslint-disable-line no-empty - } else { - extra = datasource.extra; // eslint-disable-line prefer-destructuring - } - } - const isMissingDatasource = !datasource?.id || Boolean(extra?.error); - let isMissingParams = false; - if (isMissingDatasource) { - const datasourceId = getUrlParam(URL_PARAMS.datasourceId); - const sliceId = getUrlParam(URL_PARAMS.sliceId); - - if (!datasourceId && !sliceId) { - isMissingParams = true; - } - } - - const { user } = this.props; - const allowEdit = - datasource.owners?.map(o => o.id || o.value).includes(user.userId) || - isUserAdmin(user); - - const canAccessSqlLab = userHasPermission(user, 'SQL Lab', 'menu_access'); - - const editText = t('Edit dataset'); - const requestedQuery = { - datasourceKey: `${datasource.id}__${datasource.type}`, - sql: datasource.sql, - }; - const defaultDatasourceMenuItems = []; - if (this.props.isEditable && !isMissingDatasource) { - defaultDatasourceMenuItems.push({ - key: EDIT_DATASET, - label: !allowEdit ? ( - - {editText} - - ) : ( - editText - ), - disabled: !allowEdit, - 'data-test': 'edit-dataset', - }); - } - + const defaultDatasourceMenuItems = []; + if (isEditable && !isMissingDatasource) { defaultDatasourceMenuItems.push({ - key: CHANGE_DATASET, - label: t('Swap dataset'), - }); - - if (!isMissingDatasource && canAccessSqlLab) { - defaultDatasourceMenuItems.push({ - key: VIEW_IN_SQL_LAB, - label: ( - - {t('View in SQL Lab')} - - ), - }); - } - - const defaultDatasourceMenu = ( - - ); - - const queryDatasourceMenuItems = [ - { - key: QUERY_PREVIEW, - label: ( - {t('Query preview')}
- } - modalTitle={t('Query preview')} - modalBody={ - - } - modalFooter={ - - } - draggable={false} - resizable={false} - responsive - /> - ), - }, - ]; - - if (canAccessSqlLab) { - queryDatasourceMenuItems.push({ - key: VIEW_IN_SQL_LAB, - label: ( - - {t('View in SQL Lab')} - - ), - }); - } - - queryDatasourceMenuItems.push({ - key: SAVE_AS_DATASET, - label: {t('Save as dataset')}, - }); - - const queryDatasourceMenu = ( - - ); - - const { health_check_message: healthCheckMessage } = datasource; - - const titleText = - isMissingDatasource && !datasource.name - ? t('Missing dataset') - : getDatasourceTitle(datasource); - - const tooltip = titleText; - - return ( - -
- {datasourceIconLookup[getDatasetType(datasource)]} - {renderDatasourceTitle(titleText, tooltip)} - {healthCheckMessage && ( - - - + key: EDIT_DATASET, + label: !allowEdit ? ( + - )} - - datasource.type === DatasourceType.Query - ? queryDatasourceMenu - : defaultDatasourceMenu - } - trigger={['click']} - data-test="datasource-menu" - > - + {editText} + + ) : ( + editText + ), + disabled: !allowEdit, + 'data-test': 'edit-dataset', + }); + } + + defaultDatasourceMenuItems.push({ + key: CHANGE_DATASET, + label: t('Swap dataset'), + }); + + if (!isMissingDatasource && canAccessSqlLab) { + defaultDatasourceMenuItems.push({ + key: VIEW_IN_SQL_LAB, + label: ( + + {t('View in SQL Lab')} + + ), + }); + } + + const defaultDatasourceMenu = ( + + ); + + const queryDatasourceMenuItems = [ + { + key: QUERY_PREVIEW, + label: ( + {t('Query preview')}
+ } + modalTitle={t('Query preview')} + modalBody={ + - + } + modalFooter={ + + } + draggable={false} + resizable={false} + responsive + /> + ), + }, + ]; + + if (canAccessSqlLab) { + queryDatasourceMenuItems.push({ + key: VIEW_IN_SQL_LAB, + label: ( + + {t('View in SQL Lab')} + + ), + }); + } + + queryDatasourceMenuItems.push({ + key: SAVE_AS_DATASET, + label: {t('Save as dataset')}, + }); + + const queryDatasourceMenu = ( + + ); + + const { health_check_message: healthCheckMessage } = datasource; + + const titleText = + isMissingDatasource && !datasource.name + ? t('Missing dataset') + : getDatasourceTitle(datasource); + + const tooltip = titleText; + + return ( + +
+ {datasourceIconLookup[getDatasetType(datasource)]} + {renderDatasourceTitle(titleText, tooltip)} + {healthCheckMessage && ( + + + + )} + {extra?.warning_markdown && ( + + )} + + datasource.type === DatasourceType.Query + ? queryDatasourceMenu + : defaultDatasourceMenu + } + trigger={['click']} + data-test="datasource-menu" + > + + +
+ {/* missing dataset */} + {isMissingDatasource && isMissingParams && ( +
+
- {/* missing dataset */} - {isMissingDatasource && isMissingParams && ( -
+ )} + {isMissingDatasource && !isMissingParams && ( +
+ {extra?.error ? ( + + ) : ( +

+ {t( + 'The dataset linked to this chart may have been deleted.', + )} +

+

+ +

+ + } /> -
- )} - {isMissingDatasource && !isMissingParams && ( -
- {extra?.error ? ( - - ) : ( - -

- {t( - 'The dataset linked to this chart may have been deleted.', - )} -

-

- -

- - } - /> - )} -
- )} - {showEditDatasourceModal && ( - - )} - {showChangeDatasourceModal && ( - - )} - {showSaveDatasetModal && ( - - )} - - ); - } + )} +
+ )} + {showEditDatasourceModal && ( + + )} + {showChangeDatasourceModal && ( + + )} + {showSaveDatasetModal && ( + + )} +
+ ); } - -// withTheme injects the theme prop, so we need to cast the component type -export default withTheme( - DatasourceControl as React.ComponentType< - Omit - >, -); diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx index 97d3295bd1b..64cf4963c96 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterControl/index.tsx @@ -16,11 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { Component, ReactNode } from 'react'; +import { + useState, + useCallback, + useEffect, + useMemo, + type ReactNode, +} from 'react'; import { SupersetClient, ensureIsArray } from '@superset-ui/core'; import { logging } from '@apache-superset/core'; import { t } from '@apache-superset/core'; -import { withTheme, type SupersetTheme } from '@apache-superset/core/ui'; import ControlHeader from 'src/explore/components/ControlHeader'; import AdhocMetric, { @@ -30,7 +35,6 @@ import { Operators, OPERATOR_ENUM_TO_OPERATOR_TYPE, } from 'src/explore/constants'; -import FilterDefinitionOption from 'src/explore/components/controls/MetricControl/FilterDefinitionOption'; import { AddControlLabel, HeaderContainer, @@ -85,7 +89,6 @@ interface AdhocFilterControlProps { filter: AdhocFilter, allFilters: AdhocFilter[], ) => string | boolean | undefined; - theme?: SupersetTheme; } interface FilterOption { @@ -96,22 +99,8 @@ interface FilterOption { [key: string]: unknown; } -interface AdhocFilterControlState { - values: AdhocFilter[]; - options: FilterOption[]; - partitionColumn: string | null; -} - const { warning } = Modal; -const defaultProps = { - name: '', - onChange: () => {}, - columns: [], - savedMetrics: [], - selectedMetrics: [], -}; - function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] { const options = [ ...(props.columns || []), @@ -154,71 +143,51 @@ function optionsForSelect(props: AdhocFilterControlProps): FilterOption[] { ); } -class AdhocFilterControl extends Component< - AdhocFilterControlProps, - AdhocFilterControlState -> { - optionRenderer: (option: FilterOption) => JSX.Element; - valueRenderer: (adhocFilter: AdhocFilter, index: number) => JSX.Element; - - constructor(props: AdhocFilterControlProps) { - super(props); - this.onRemoveFilter = this.onRemoveFilter.bind(this); - this.onNewFilter = this.onNewFilter.bind(this); - this.onFilterEdit = this.onFilterEdit.bind(this); - this.moveLabel = this.moveLabel.bind(this); - this.onChange = this.onChange.bind(this); - this.mapOption = this.mapOption.bind(this); - this.getMetricExpression = this.getMetricExpression.bind(this); - this.removeFilter = this.removeFilter.bind(this); - - const filters = (this.props.value || []).map(filter => +function AdhocFilterControl({ + label, + name = '', + sections, + operators, + onChange = () => {}, + value, + datasource, + columns = [], + savedMetrics = [], + selectedMetrics = [], + canDelete, +}: AdhocFilterControlProps) { + const [values, setValues] = useState(() => + (value || []).map(filter => isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, - ); + ), + ); + const [partitionColumn, setPartitionColumn] = useState(null); - this.optionRenderer = option => ; - this.valueRenderer = (adhocFilter, index) => ( - { - e.stopPropagation(); - this.onRemoveFilter(index); - }} - onMoveLabel={this.moveLabel} - onDropLabel={() => this.props.onChange?.(this.state.values)} - partitionColumn={this.state.partitionColumn} - /> - ); - this.state = { - values: filters, - options: optionsForSelect(this.props), - partitionColumn: null, - }; - } + const options = useMemo( + () => + optionsForSelect({ + columns, + selectedMetrics, + savedMetrics, + }), + [columns, selectedMetrics, savedMetrics], + ); - componentDidMount() { - const { datasource } = this.props; + useEffect(() => { if (datasource && datasource.type === 'table') { const dbId = datasource.database?.id; const { - datasource_name: name, + datasource_name: dsName, catalog, schema, is_sqllab_view: isSqllabView, } = datasource; - if (!isSqllabView && dbId && name && schema) { + if (!isSqllabView && dbId && dsName && schema) { SupersetClient.get({ endpoint: `/api/v1/database/${dbId}/table_metadata/extra/${toQueryString( { - name, + name: dsName, catalog, schema, }, @@ -234,7 +203,7 @@ class AdhocFilterControl extends Component< partitions.cols && Object.keys(partitions.cols).length === 1 ) { - this.setState({ partitionColumn: partitions.cols[0] }); + setPartitionColumn(partitions.cols[0]); } } }) @@ -243,177 +212,205 @@ class AdhocFilterControl extends Component< }); } } - } + }, [datasource]); - componentDidUpdate(prevProps: AdhocFilterControlProps): void { - if (this.props.columns !== prevProps.columns) { - this.setState({ options: optionsForSelect(this.props) }); - } - if (this.props.value !== prevProps.value) { - this.setState({ - values: (this.props.value || []).map(filter => + useEffect(() => { + if (value !== undefined) { + setValues( + (value || []).map(filter => isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter, ), - }); - } - } - - removeFilter(index: number): void { - const valuesCopy = [...this.state.values]; - valuesCopy.splice(index, 1); - this.setState(prevState => ({ - ...prevState, - values: valuesCopy, - })); - this.props.onChange?.(valuesCopy); - } - - onRemoveFilter(index: number): void { - const { canDelete } = this.props; - const { values } = this.state; - const result = canDelete?.(values[index], values); - if (typeof result === 'string') { - warning({ title: t('Warning'), content: result }); - return; - } - this.removeFilter(index); - } - - onNewFilter(newFilter: FilterOption | AdhocFilter): void { - const mappedOption = this.mapOption(newFilter); - if (mappedOption) { - this.setState( - prevState => ({ - ...prevState, - values: [...prevState.values, mappedOption], - }), - () => { - this.props.onChange?.(this.state.values); - }, ); } - } + }, [value]); - onFilterEdit(changedFilter: AdhocFilter): void { - this.props.onChange?.( - this.state.values.map(value => { - if (value.filterOptionName === changedFilter.filterOptionName) { - return changedFilter; - } - return value; - }), - ); - } + const getMetricExpression = useCallback( + (savedMetricName: string): string => { + const metric = savedMetrics?.find( + savedMetric => savedMetric.metric_name === savedMetricName, + ); + return metric?.expression ?? ''; + }, + [savedMetrics], + ); - onChange(opts: FilterOption[] | null): void { - const options = (opts || []) - .map(option => this.mapOption(option)) - .filter((option): option is AdhocFilter => option !== null); - this.props.onChange?.(options); - } + const mapOption = useCallback( + (option: FilterOption | AdhocFilter): AdhocFilter | null => { + // already a AdhocFilter, skip + if (option instanceof AdhocFilter) { + return option; + } + // via datasource saved metric + if (option.saved_metric_name) { + return new AdhocFilter({ + expressionType: ExpressionTypes.Sql, + subject: getMetricExpression(option.saved_metric_name), + operator: + OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation, + comparator: 0, + clause: Clauses.Having, + }); + } + // has a custom label, meaning it's custom column + if (option.label) { + return new AdhocFilter({ + expressionType: ExpressionTypes.Sql, + subject: new AdhocMetric(option).translateToSql(), + operator: + OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation, + comparator: 0, + clause: Clauses.Having, + }); + } + // add a new filter item + if (option.column_name) { + return new AdhocFilter({ + expressionType: ExpressionTypes.Simple, + subject: option.column_name, + operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation, + comparator: '', + clause: Clauses.Where, + isNew: true, + }); + } + return null; + }, + [getMetricExpression], + ); - getMetricExpression(savedMetricName: string): string { - const metric = this.props.savedMetrics?.find( - savedMetric => savedMetric.metric_name === savedMetricName, - ); - return metric?.expression ?? ''; - } + const removeFilter = useCallback( + (index: number) => { + const valuesCopy = [...values]; + valuesCopy.splice(index, 1); + setValues(valuesCopy); + onChange?.(valuesCopy); + }, + [values, onChange], + ); - moveLabel(dragIndex: number, hoverIndex: number): void { - const { values } = this.state; + const onRemoveFilter = useCallback( + (index: number) => { + const result = canDelete?.(values[index], values); + if (typeof result === 'string') { + warning({ title: t('Warning'), content: result }); + return; + } + removeFilter(index); + }, + [canDelete, values, removeFilter], + ); - const newValues = [...values]; - [newValues[hoverIndex], newValues[dragIndex]] = [ - newValues[dragIndex], - newValues[hoverIndex], - ]; - this.setState({ values: newValues }); - } + const onFilterEdit = useCallback( + (changedFilter: AdhocFilter) => { + onChange?.( + values.map(val => { + if (val.filterOptionName === changedFilter.filterOptionName) { + return changedFilter; + } + return val; + }), + ); + }, + [values, onChange], + ); - mapOption(option: FilterOption | AdhocFilter): AdhocFilter | null { - // already a AdhocFilter, skip - if (option instanceof AdhocFilter) { - return option; - } - // via datasource saved metric - if (option.saved_metric_name) { - return new AdhocFilter({ - expressionType: ExpressionTypes.Sql, - subject: this.getMetricExpression(option.saved_metric_name), - operator: - OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation, - comparator: 0, - clause: Clauses.Having, - }); - } - // has a custom label, meaning it's custom column - if (option.label) { - return new AdhocFilter({ - expressionType: ExpressionTypes.Sql, - subject: new AdhocMetric(option).translateToSql(), - operator: - OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.GreaterThan].operation, - comparator: 0, - clause: Clauses.Having, - }); - } - // add a new filter item - if (option.column_name) { - return new AdhocFilter({ - expressionType: ExpressionTypes.Simple, - subject: option.column_name, - operator: OPERATOR_ENUM_TO_OPERATOR_TYPE[Operators.Equals].operation, - comparator: '', - clause: Clauses.Where, - isNew: true, - }); - } - return null; - } + const moveLabel = useCallback((dragIndex: number, hoverIndex: number) => { + setValues(prevValues => { + const newValues = [...prevValues]; + [newValues[hoverIndex], newValues[dragIndex]] = [ + newValues[dragIndex], + newValues[hoverIndex], + ]; + return newValues; + }); + }, []); - addNewFilterPopoverTrigger(trigger: ReactNode): JSX.Element { - return ( + const onDropLabel = useCallback(() => { + onChange?.(values); + }, [onChange, values]); + + const onNewFilter = useCallback( + (newFilter: FilterOption | AdhocFilter) => { + const mappedOption = mapOption(newFilter); + if (mappedOption) { + const newValues = [...values, mappedOption]; + setValues(newValues); + onChange?.(newValues); + } + }, + [mapOption, values, onChange], + ); + + const valueRenderer = useCallback( + (adhocFilter: AdhocFilter, index: number) => ( + { + e.stopPropagation(); + onRemoveFilter(index); + }} + onMoveLabel={moveLabel} + onDropLabel={onDropLabel} + partitionColumn={partitionColumn} + /> + ), + [ + onFilterEdit, + options, + sections, + operators, + datasource, + onRemoveFilter, + moveLabel, + onDropLabel, + partitionColumn, + ], + ); + + const addNewFilterPopoverTrigger = useCallback( + (trigger: ReactNode) => ( ) || {}} - options={this.state.options} - onFilterEdit={this.onNewFilter} - partitionColumn={this.state.partitionColumn ?? undefined} + datasource={(datasource as Record) || {}} + options={options} + onFilterEdit={onNewFilter} + partitionColumn={partitionColumn ?? undefined} > {trigger} - ); - } + ), + [operators, sections, datasource, options, onNewFilter, partitionColumn], + ); - render() { - return ( -
- - - - - {[ - ...(this.state.values.length > 0 - ? this.state.values.map((value, index) => - this.valueRenderer(value, index), - ) - : []), - this.addNewFilterPopoverTrigger( - - - {t('Add filter')} - , - ), - ]} - -
- ); - } + return ( +
+ + + + + {[ + ...(values.length > 0 + ? values.map((val, index) => valueRenderer(val, index)) + : []), + addNewFilterPopoverTrigger( + + + {t('Add filter')} + , + ), + ]} + +
+ ); } -// @ts-expect-error - defaultProps for backward compatibility -AdhocFilterControl.defaultProps = defaultProps; - -export default withTheme(AdhocFilterControl); +export default AdhocFilterControl; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx index 12d106a7319..3536db3f5ec 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterEditPopover/index.tsx @@ -17,7 +17,7 @@ * under the License. */ import type React from 'react'; -import { createRef, Component, type RefObject } from 'react'; +import { useRef, useState, useCallback, useEffect } from 'react'; import type { SupersetTheme } from '@apache-superset/core/ui'; import { Button, Icons, Select } from '@superset-ui/core/components'; import { ErrorBoundary } from 'src/components'; @@ -66,17 +66,6 @@ interface AdhocFilterEditPopoverProps { requireSave?: boolean; } -interface AdhocFilterEditPopoverState { - adhocFilter: AdhocFilter; - width: number; - height: number; - activeKey: string; - isSimpleTabValid: boolean; - selectedLayers: LayerOption[]; - layerOptions: LayerOption[]; - hasLayerFilterScopeChanged: boolean; -} - const FilterPopoverContentContainer = styled.div` .adhoc-filter-edit-tabs > .nav-tabs { margin-bottom: ${({ theme }) => theme.sizeUnit * 2}px; @@ -114,369 +103,337 @@ const LayerSelectContainer = styled.div` margin-bottom: ${({ theme }) => theme.sizeUnit * 12}px; `; -export default class AdhocFilterEditPopover extends Component< - AdhocFilterEditPopoverProps, - AdhocFilterEditPopoverState -> { - popoverContentRef: RefObject; +function AdhocFilterEditPopover({ + adhocFilter: propsAdhocFilter, + onChange, + onClose, + onResize, + options, + datasource, + partitionColumn, + operators, + requireSave, + ...popoverProps +}: AdhocFilterEditPopoverProps) { + const popoverContentRef = useRef(null); - dragStartX = 0; + const dragStartRef = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + }); - dragStartY = 0; + const [adhocFilter, setAdhocFilter] = useState(propsAdhocFilter); + const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH); + const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT); + const [isSimpleTabValid, setIsSimpleTabValid] = useState(true); + const [selectedLayers, setSelectedLayers] = useState([ + { id: null, value: -1, label: 'All' }, + ]); + const [layerOptions, setLayerOptions] = useState([]); + const [hasLayerFilterScopeChanged, setHasLayerFilterScopeChanged] = + useState(false); - dragStartWidth = 0; + const loadLayerOptions = useCallback( + (page: number, pageSize: number) => { + const query = rison.encode({ + columns: ['id', 'slice_name', 'viz_type'], + filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }], + page, + page_size: pageSize, + order_column: 'slice_name', + order_direction: 'asc', + }); - dragStartHeight = 0; + return SupersetClient.get({ + endpoint: `/api/v1/chart/?q=${query}`, + }).then(response => { + if (!response?.json?.result) { + return { + data: [ + { + id: null, + value: -1, + label: 'All', + }, + ], + totalCount: 1, + }; + } - constructor(props: AdhocFilterEditPopoverProps) { - super(props); - this.onSave = this.onSave.bind(this); - this.onDragDown = this.onDragDown.bind(this); - this.onMouseMove = this.onMouseMove.bind(this); - this.onMouseUp = this.onMouseUp.bind(this); - this.onAdhocFilterChange = this.onAdhocFilterChange.bind(this); - this.setSimpleTabIsValid = this.setSimpleTabIsValid.bind(this); - this.adjustHeight = this.adjustHeight.bind(this); - this.onTabChange = this.onTabChange.bind(this); - this.loadLayerOptions = this.loadLayerOptions.bind(this); - this.onLayerChange = this.onLayerChange.bind(this); + const deckSlices = (propsAdhocFilter?.deck_slices || []) as number[]; - this.state = { - adhocFilter: this.props.adhocFilter, - width: POPOVER_INITIAL_WIDTH, - height: POPOVER_INITIAL_HEIGHT, - activeKey: this.props?.adhocFilter?.expressionType || 'SIMPLE', - isSimpleTabValid: true, - selectedLayers: [{ id: null, value: -1, label: 'All' }], - layerOptions: [], - hasLayerFilterScopeChanged: false, - }; + const list = [ + { + id: null, + value: -1, + label: 'All', + }, + ...response.json.result + .map((item: { id: number; slice_name: string }) => { + const sliceIndex = deckSlices.indexOf(item.id); + return { + id: item.id, + value: sliceIndex >= 0 ? sliceIndex : item.id, + label: item.slice_name, + sliceIndex, + }; + }) + .filter((item: { sliceIndex: number }) => item.sliceIndex !== -1) + .map( + ({ + sliceIndex, + ...item + }: { + sliceIndex: number; + id: number; + value: number; + label: string; + }) => item, + ), + ]; - this.popoverContentRef = createRef(); - } + return { + data: list, + totalCount: list.length, + }; + }); + }, + [propsAdhocFilter?.deck_slices], + ); - componentDidMount() { - document.addEventListener('mouseup', this.onMouseUp); + const onMouseMove = useCallback( + (e: MouseEvent) => { + onResize(); + setWidth( + Math.max( + dragStartRef.current.width + (e.clientX - dragStartRef.current.x), + POPOVER_INITIAL_WIDTH, + ), + ); + setHeight( + Math.max( + dragStartRef.current.height + (e.clientY - dragStartRef.current.y), + POPOVER_INITIAL_HEIGHT, + ), + ); + }, + [onResize], + ); + + const onMouseUp = useCallback(() => { + document.removeEventListener('mousemove', onMouseMove); + }, [onMouseMove]); + + useEffect(() => { + document.addEventListener('mouseup', onMouseUp); // Load layer options if deck_slices exist - const deckSlices = this.props.adhocFilter?.deck_slices as - | number[] - | undefined; + const deckSlices = propsAdhocFilter?.deck_slices as number[] | undefined; if (deckSlices && deckSlices.length > 0) { - this.loadLayerOptions(0, 100).then(result => { - this.setState({ layerOptions: result.data }); - const layerFilterScope = this.props.adhocFilter?.layerFilterScope as + loadLayerOptions(0, 100).then(result => { + setLayerOptions(result.data); + const layerFilterScope = propsAdhocFilter?.layerFilterScope as | number[] | undefined; if (layerFilterScope) { - const selectedLayers = layerFilterScope.map(item => { - const layerOption = result.data.find( - option => option.value === item, - ); - return layerOption; - }); - this.setState({ - selectedLayers: selectedLayers.filter(Boolean) as LayerOption[], - }); + const layers = layerFilterScope + .map(item => result.data.find(option => option.value === item)) + .filter(Boolean) as LayerOption[]; + setSelectedLayers(layers); } }); } - } - componentWillUnmount() { - document.removeEventListener('mouseup', this.onMouseUp); - document.removeEventListener('mousemove', this.onMouseMove); - } + return () => { + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + }; + }, [loadLayerOptions, onMouseMove, onMouseUp, propsAdhocFilter]); - onAdhocFilterChange(adhocFilter: AdhocFilter): void { - this.setState({ adhocFilter }); - } + const onAdhocFilterChange = useCallback((filter: AdhocFilter) => { + setAdhocFilter(filter); + }, []); - setSimpleTabIsValid(isValid: boolean): void { - this.setState({ isSimpleTabValid: isValid }); - } + const setSimpleTabIsValid = useCallback((isValid: boolean) => { + setIsSimpleTabValid(isValid); + }, []); - onSave() { - const deckSlices = this.state.adhocFilter.deck_slices as - | number[] - | undefined; + const onSave = useCallback(() => { + const deckSlices = adhocFilter.deck_slices as number[] | undefined; const hasDeckSlices = deckSlices && deckSlices.length > 0; if (!hasDeckSlices) { - this.props.onChange(this.state.adhocFilter); - this.props.onClose(); + onChange(adhocFilter); + onClose(); return; } // Update layer filter scope for deck multi - const selectedLayers = this.state.selectedLayers.map(item => { + const layers = selectedLayers.map(item => { if (isObject(item)) { return item.value; } return item; }); - const correctedAdhocFilter = this.state.adhocFilter.duplicateWith({ - layerFilterScope: selectedLayers, + const correctedAdhocFilter = adhocFilter.duplicateWith({ + layerFilterScope: layers, }); - this.setState({ hasLayerFilterScopeChanged: false }); - this.props.onChange(correctedAdhocFilter); - this.props.onClose(); - } + setHasLayerFilterScopeChanged(false); + onChange(correctedAdhocFilter); + onClose(); + }, [adhocFilter, onChange, onClose, selectedLayers]); - onDragDown(e: React.MouseEvent): void { - this.dragStartX = e.clientX; - this.dragStartY = e.clientY; - this.dragStartWidth = this.state.width; - this.dragStartHeight = this.state.height; - document.addEventListener('mousemove', this.onMouseMove); - } - - onMouseMove(e: MouseEvent): void { - this.props.onResize(); - this.setState({ - width: Math.max( - this.dragStartWidth + (e.clientX - this.dragStartX), - POPOVER_INITIAL_WIDTH, - ), - height: Math.max( - this.dragStartHeight + (e.clientY - this.dragStartY), - POPOVER_INITIAL_HEIGHT, - ), - }); - } - - onMouseUp() { - document.removeEventListener('mousemove', this.onMouseMove); - } - - onTabChange(activeKey: string) { - this.setState({ - activeKey, - }); - } - - adjustHeight(heightDifference: number) { - this.setState(state => ({ height: state.height + heightDifference })); - } - - loadLayerOptions(page: number, pageSize: number) { - const query = rison.encode({ - columns: ['id', 'slice_name', 'viz_type'], - filters: [{ col: 'viz_type', opr: 'sw', value: 'deck' }], - page, - page_size: pageSize, - order_column: 'slice_name', - order_direction: 'asc', - }); - - return SupersetClient.get({ - endpoint: `/api/v1/chart/?q=${query}`, - }).then(response => { - if (!response?.json?.result) { - return { - data: [ - { - id: null, - value: -1, - label: 'All', - }, - ], - totalCount: 1, - }; - } - - const deckSlices = (this.props.adhocFilter?.deck_slices || - []) as number[]; - - const list = [ - { - id: null, - value: -1, - label: 'All', - }, - ...response.json.result - .map((item: { id: number; slice_name: string }) => { - const sliceIndex = deckSlices.indexOf(item.id); - return { - id: item.id, - value: sliceIndex >= 0 ? sliceIndex : item.id, - label: item.slice_name, - sliceIndex, - }; - }) - .filter((item: { sliceIndex: number }) => item.sliceIndex !== -1) - .map( - ({ - sliceIndex, - ...item - }: { - sliceIndex: number; - id: number; - value: number; - label: string; - }) => item, - ), - ]; - - return { - data: list, - totalCount: list.length, + const onDragDown = useCallback( + (e: React.MouseEvent) => { + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + width, + height, }; - }); - } + document.addEventListener('mousemove', onMouseMove); + }, + [width, height, onMouseMove], + ); - onLayerChange(selectedValue: LayerOption[] | number[] | null) { - let updatedSelectedLayers: LayerOption[] = - (selectedValue as LayerOption[]) || []; + const adjustHeight = useCallback((heightDifference: number) => { + setHeight(prevHeight => prevHeight + heightDifference); + }, []); - if (!selectedValue || selectedValue.length === 0) { - updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }]; - } else if ( - selectedValue.length > 1 && - selectedValue.some( - (item: LayerOption | number) => - (typeof item === 'object' && item.value === -1) || item === -1, - ) - ) { - const lastItem = selectedValue[selectedValue.length - 1]; - if ( - (typeof lastItem === 'object' && lastItem.value === -1) || - lastItem === -1 - ) { + const onLayerChange = useCallback( + (selectedValue: LayerOption[] | number[] | null) => { + let updatedSelectedLayers: LayerOption[] = + (selectedValue as LayerOption[]) || []; + + if (!selectedValue || selectedValue.length === 0) { updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }]; - } else { - updatedSelectedLayers = (selectedValue as LayerOption[]).filter( - (item: LayerOption) => item.value !== -1, - ); + } else if ( + selectedValue.length > 1 && + selectedValue.some( + (item: LayerOption | number) => + (typeof item === 'object' && item.value === -1) || item === -1, + ) + ) { + const lastItem = selectedValue[selectedValue.length - 1]; + if ( + (typeof lastItem === 'object' && lastItem.value === -1) || + lastItem === -1 + ) { + updatedSelectedLayers = [{ id: null, value: -1, label: 'All' }]; + } else { + updatedSelectedLayers = (selectedValue as LayerOption[]).filter( + (item: LayerOption) => item.value !== -1, + ); + } } - } - this.setState({ selectedLayers: updatedSelectedLayers }); - this.setState({ hasLayerFilterScopeChanged: true }); - } + setSelectedLayers(updatedSelectedLayers); + setHasLayerFilterScopeChanged(true); + }, + [], + ); - render() { - const { - adhocFilter: propsAdhocFilter, - options, - onChange, - onClose, - onResize, - datasource, - partitionColumn, - theme, - operators, - requireSave, - ...popoverProps - } = this.props; + const stateIsValid = adhocFilter.isValid(); + const hasUnsavedChanges = + requireSave || + !adhocFilter.equals(propsAdhocFilter) || + hasLayerFilterScopeChanged; - const { adhocFilter, selectedLayers, hasLayerFilterScopeChanged } = - this.state; - const stateIsValid = adhocFilter.isValid(); - const hasUnsavedChanges = - requireSave || - !adhocFilter.equals(propsAdhocFilter) || - hasLayerFilterScopeChanged; + const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined; + const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0; - const renderDeckSlices = adhocFilter.deck_slices as number[] | undefined; - const hasDeckSlices = renderDeckSlices && renderDeckSlices.length > 0; - - return ( - - - - - ), - }, - { - key: ExpressionTypes.Sql, - label: t('Custom SQL'), - children: ( - - - - ), - }, - ]} - /> - {hasDeckSlices && ( - - void} + value={selectedLayers} + mode="multiple" /> - - - ); - } + + )} + + + + + + + + ); } + +export default AdhocFilterEditPopover; diff --git a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx index 115fb9449a7..f2f4fe2f120 100644 --- a/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx +++ b/superset-frontend/src/explore/components/controls/FilterControl/AdhocFilterPopoverTrigger/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { useState, useCallback, type ReactNode } from 'react'; import { OptionSortType } from 'src/explore/types'; import AdhocFilterEditPopover from 'src/explore/components/controls/FilterControl/AdhocFilterEditPopover'; import AdhocFilter from 'src/explore/components/controls/FilterControl/AdhocFilter'; @@ -37,84 +37,80 @@ interface AdhocFilterPopoverTriggerProps { togglePopover?: (visible: boolean) => void; closePopover?: () => void; requireSave?: boolean; + children?: ReactNode; } -interface AdhocFilterPopoverTriggerState { - popoverVisible: boolean; -} +function AdhocFilterPopoverTrigger({ + sections, + operators, + adhocFilter, + options, + datasource, + onFilterEdit, + partitionColumn, + isControlledComponent, + visible: propsVisible, + togglePopover: propsTogglePopover, + closePopover: propsClosePopover, + requireSave, + children, +}: AdhocFilterPopoverTriggerProps) { + const [popoverVisible, setPopoverVisible] = useState(false); + const [, forceUpdate] = useState({}); -class AdhocFilterPopoverTrigger extends PureComponent< - AdhocFilterPopoverTriggerProps, - AdhocFilterPopoverTriggerState -> { - constructor(props: AdhocFilterPopoverTriggerProps) { - super(props); - this.onPopoverResize = this.onPopoverResize.bind(this); - this.closePopover = this.closePopover.bind(this); - this.togglePopover = this.togglePopover.bind(this); - this.state = { - popoverVisible: false, - }; - } + const onPopoverResize = useCallback(() => { + forceUpdate({}); + }, []); - onPopoverResize() { - this.forceUpdate(); - } + const internalClosePopover = useCallback(() => { + setPopoverVisible(false); + }, []); - closePopover() { - this.togglePopover(false); - } + const internalTogglePopover = useCallback((visible: boolean) => { + setPopoverVisible(visible); + }, []); - togglePopover(visible: boolean) { - this.setState({ - popoverVisible: visible, - }); - } + const { visible, togglePopover, closePopover } = isControlledComponent + ? { + visible: propsVisible, + togglePopover: propsTogglePopover, + closePopover: propsClosePopover, + } + : { + visible: popoverVisible, + togglePopover: internalTogglePopover, + closePopover: internalClosePopover, + }; - render() { - const { adhocFilter, isControlledComponent } = this.props; + const overlayContent = ( + + {})} + sections={sections} + operators={operators} + onChange={onFilterEdit} + requireSave={requireSave} + /> + + ); - const { visible, togglePopover, closePopover } = isControlledComponent - ? { - visible: this.props.visible, - togglePopover: this.props.togglePopover, - closePopover: this.props.closePopover, - } - : { - visible: this.state.popoverVisible, - togglePopover: this.togglePopover, - closePopover: this.closePopover, - }; - const overlayContent = ( - - {})} - sections={this.props.sections} - operators={this.props.operators} - onChange={this.props.onFilterEdit} - requireSave={this.props.requireSave} - /> - - ); - - return ( - - {this.props.children} - - ); - } + return ( + + {children} + + ); } export default AdhocFilterPopoverTrigger; diff --git a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx index 74189cf91d9..c8a4bd6b3c2 100644 --- a/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/FixedOrMetricControl/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component } from 'react'; +import { useState, useCallback } from 'react'; import { t } from '@apache-superset/core'; import { Collapse, Label } from '@superset-ui/core/components'; import TextControl from 'src/explore/components/controls/TextControl'; @@ -56,153 +56,149 @@ interface FixedOrMetricControlProps { isFloat?: boolean; datasource: DatasourceType; default?: ControlValue; + // ControlHeader props that may be passed through + name?: string; + label?: React.ReactNode; + description?: React.ReactNode; } -interface FixedOrMetricControlState { - type: 'fix' | 'metric'; - fixedValue: string | number; - metricValue: MetricValue | null; -} +const DEFAULT_VALUE: ControlValue = { type: controlTypes.fixed, value: 5 }; -const defaultProps = { - onChange: () => {}, - default: { type: controlTypes.fixed, value: 5 }, -}; - -export default class FixedOrMetricControl extends Component< - FixedOrMetricControlProps, - FixedOrMetricControlState -> { - constructor(props: FixedOrMetricControlProps) { - super(props); - this.onChange = this.onChange.bind(this); - this.setType = this.setType.bind(this); - this.setFixedValue = this.setFixedValue.bind(this); - this.setMetric = this.setMetric.bind(this); - const type = (props.value?.type ?? - props.default?.type ?? - controlTypes.fixed) as 'fix' | 'metric'; - const rawValue = props.value?.value ?? props.default?.value ?? '100'; - const fixedValue = - type === controlTypes.fixed && typeof rawValue !== 'object' - ? rawValue - : ''; - const metricValue = - type === controlTypes.metric && typeof rawValue === 'object' - ? (rawValue as MetricValue) - : null; - this.state = { - type, - fixedValue, - metricValue, - }; - } - - onChange(): void { - this.props.onChange?.({ - type: this.state.type, - value: - this.state.type === controlTypes.fixed - ? this.state.fixedValue - : (this.state.metricValue ?? undefined), - }); - } - - setType(type: 'fix' | 'metric'): void { - this.setState({ type }, this.onChange); - } - - setFixedValue(fixedValue: string | number): void { - this.setState({ fixedValue }, this.onChange); - } - - setMetric(metricValue: MetricValue | null): void { - this.setState({ metricValue }, this.onChange); - } - - render() { - const value = this.props.value ?? this.props.default; - const type = value?.type ?? controlTypes.fixed; - const columns = this.props.datasource - ? this.props.datasource.columns +export default function FixedOrMetricControl({ + onChange = () => {}, + value, + datasource, + default: defaultValue = DEFAULT_VALUE, + name, + label, + description, +}: FixedOrMetricControlProps) { + const initialType = (value?.type ?? + defaultValue?.type ?? + controlTypes.fixed) as 'fix' | 'metric'; + const initialRawValue = value?.value ?? defaultValue?.value ?? '100'; + const initialFixedValue = + initialType === controlTypes.fixed && typeof initialRawValue !== 'object' + ? initialRawValue + : ''; + const initialMetricValue = + initialType === controlTypes.metric && typeof initialRawValue === 'object' + ? (initialRawValue as MetricValue) : null; - const metrics = this.props.datasource - ? this.props.datasource.metrics - : null; - return ( -
- - - {this.state.type === controlTypes.fixed && ( - {this.state.fixedValue} - )} - {this.state.type === controlTypes.metric && ( - - {t('metric')}: - - {this.state.metricValue - ? this.state.metricValue.label - : null} - - - )} - - ), - children: ( -
- { - this.setType(controlTypes.fixed); - }} - > - { - this.setType(controlTypes.fixed); - return {}; - }} - value={this.state.fixedValue} - /> - - { - this.setType(controlTypes.metric); - }} - > - { - this.setType(controlTypes.metric); - }} - onChange={this.setMetric} - value={this.state.metricValue} - datasource={this.props.datasource} - /> - -
- ), - }, - ]} - /> -
- ); - } -} -// @ts-expect-error - defaultProps for backward compatibility -FixedOrMetricControl.defaultProps = defaultProps; + const [type, setTypeState] = useState<'fix' | 'metric'>(initialType); + const [fixedValue, setFixedValueState] = useState( + initialFixedValue, + ); + const [metricValue, setMetricValueState] = useState( + initialMetricValue, + ); + + const setType = useCallback( + (newType: 'fix' | 'metric') => { + setTypeState(newType); + onChange({ + type: newType, + value: + newType === controlTypes.fixed + ? fixedValue + : (metricValue ?? undefined), + }); + }, + [fixedValue, metricValue, onChange], + ); + + const setFixedValue = useCallback( + (newFixedValue: string | number) => { + setFixedValueState(newFixedValue); + onChange({ + type, + value: newFixedValue, + }); + }, + [type, onChange], + ); + + const setMetric = useCallback( + (newMetricValue: MetricValue | null) => { + setMetricValueState(newMetricValue); + onChange({ + type, + value: newMetricValue ?? undefined, + }); + }, + [type, onChange], + ); + + const displayValue = value ?? defaultValue; + const displayType = displayValue?.type ?? controlTypes.fixed; + const columns = datasource ? datasource.columns : null; + const metrics = datasource ? datasource.metrics : null; + + return ( +
+ + + {type === controlTypes.fixed && {fixedValue}} + {type === controlTypes.metric && ( + + {t('metric')}: + {metricValue ? metricValue.label : null} + + )} + + ), + children: ( +
+ { + setType(controlTypes.fixed); + }} + > + { + setType(controlTypes.fixed); + return {}; + }} + value={fixedValue} + /> + + { + setType(controlTypes.metric); + }} + > + { + setType(controlTypes.metric); + }} + onChange={setMetric} + value={metricValue} + datasource={datasource} + /> + +
+ ), + }, + ]} + /> +
+ ); +} diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx index 0f435dfa5b7..79b0aed3971 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricEditPopover/index.tsx @@ -17,7 +17,7 @@ * under the License. */ /* eslint-disable camelcase */ -import { PureComponent, createRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isDefined, ensureIsArray, DatasourceType } from '@superset-ui/core'; import { t } from '@apache-superset/core'; import type { editors } from '@apache-superset/core'; @@ -96,19 +96,6 @@ interface AdhocMetricEditPopoverProps { isLabelModified?: boolean; } -interface AdhocMetricEditPopoverState { - adhocMetric: AdhocMetric; - savedMetric?: SavedMetricType; - width: number; - height: number; -} - -const defaultProps = { - columns: [], - getCurrentTab: noOp, - isNewMetric: false, -}; - const StyledSelect = styled(Select)` .metric-option { & > svg { @@ -123,483 +110,502 @@ const StyledSelect = styled(Select)` export const SAVED_TAB_KEY = 'SAVED'; -export default class AdhocMetricEditPopover extends PureComponent< - AdhocMetricEditPopoverProps, - AdhocMetricEditPopoverState -> { - // "Saved" is a default tab unless there are no saved metrics for dataset - defaultActiveTabKey = this.getDefaultTab(); +function AdhocMetricEditPopover({ + onChange, + onClose, + onResize, + getCurrentTab = noOp, + getCurrentLabel, + handleDatasetModal, + adhocMetric: propsAdhocMetric, + columns = [], + savedMetricsOptions, + savedMetric: propsSavedMetric, + datasource, + isNewMetric = false, + isLabelModified, + ...popoverProps +}: AdhocMetricEditPopoverProps) { + const [adhocMetric, setAdhocMetric] = useState(propsAdhocMetric); + const [savedMetric, setSavedMetric] = useState( + propsSavedMetric, + ); + const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH); + const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT); - aceEditorRef: RefObject; + const aceEditorRef = useRef(null); - dragStartX = 0; + const dragStartRef = useRef({ + x: 0, + y: 0, + width: 0, + height: 0, + }); - dragStartY = 0; - - dragStartWidth = 0; - - dragStartHeight = 0; - - constructor(props: AdhocMetricEditPopoverProps) { - super(props); - this.onSave = this.onSave.bind(this); - this.onResetStateAndClose = this.onResetStateAndClose.bind(this); - this.onColumnChange = this.onColumnChange.bind(this); - this.onAggregateChange = this.onAggregateChange.bind(this); - this.onSavedMetricChange = this.onSavedMetricChange.bind(this); - this.onSqlExpressionChange = this.onSqlExpressionChange.bind(this); - this.onDragDown = this.onDragDown.bind(this); - this.onMouseMove = this.onMouseMove.bind(this); - this.onMouseUp = this.onMouseUp.bind(this); - this.onTabChange = this.onTabChange.bind(this); - this.aceEditorRef = createRef(); - this.refreshAceEditor = this.refreshAceEditor.bind(this); - this.getDefaultTab = this.getDefaultTab.bind(this); - - this.state = { - adhocMetric: this.props.adhocMetric, - savedMetric: this.props.savedMetric, - width: POPOVER_INITIAL_WIDTH, - height: POPOVER_INITIAL_HEIGHT, - }; - document.addEventListener('mouseup', this.onMouseUp); - } - - componentDidMount() { - this.props.getCurrentTab?.(this.defaultActiveTabKey); - } - - componentDidUpdate( - _prevProps: AdhocMetricEditPopoverProps, - prevState: AdhocMetricEditPopoverState, - ) { + const getDefaultTab = useCallback(() => { if ( - prevState.adhocMetric?.sqlExpression !== - this.state.adhocMetric?.sqlExpression || - prevState.adhocMetric?.aggregate !== this.state.adhocMetric?.aggregate || - prevState.adhocMetric?.column?.column_name !== - this.state.adhocMetric?.column?.column_name || - prevState.savedMetric?.metric_name !== this.state.savedMetric?.metric_name + isDefined(propsAdhocMetric.column) || + isDefined(propsAdhocMetric.sqlExpression) ) { - this.props.getCurrentLabel?.({ - savedMetricLabel: - this.state.savedMetric?.verbose_name || - this.state.savedMetric?.metric_name, - adhocMetricLabel: this.state.adhocMetric?.getDefaultLabel(), - }); - } - } - - componentWillUnmount() { - document.removeEventListener('mouseup', this.onMouseUp); - document.removeEventListener('mousemove', this.onMouseMove); - } - - getDefaultTab() { - const { adhocMetric, savedMetric, savedMetricsOptions, isNewMetric } = - this.props; - if (isDefined(adhocMetric.column) || isDefined(adhocMetric.sqlExpression)) { - return adhocMetric.expressionType; + return propsAdhocMetric.expressionType; } if ( - (isNewMetric || savedMetric?.metric_name) && + (isNewMetric || propsSavedMetric?.metric_name) && Array.isArray(savedMetricsOptions) && savedMetricsOptions.length > 0 ) { return SAVED_TAB_KEY; } - return adhocMetric.expressionType; - } + return propsAdhocMetric.expressionType; + }, [propsAdhocMetric, propsSavedMetric, savedMetricsOptions, isNewMetric]); - onSave() { - const { adhocMetric, savedMetric } = this.state; + const defaultActiveTabKey = useMemo(() => getDefaultTab(), [getDefaultTab]); + const onMouseMove = useCallback( + (e: MouseEvent): void => { + onResize(); + setWidth( + Math.max( + dragStartRef.current.width + (e.clientX - dragStartRef.current.x), + POPOVER_INITIAL_WIDTH, + ), + ); + setHeight( + Math.max( + dragStartRef.current.height + (e.clientY - dragStartRef.current.y), + POPOVER_INITIAL_HEIGHT, + ), + ); + }, + [onResize], + ); + + const onMouseUp = useCallback((): void => { + document.removeEventListener('mousemove', onMouseMove); + }, [onMouseMove]); + + useEffect(() => { + getCurrentTab(defaultActiveTabKey); + }, []); + + useEffect(() => { + document.addEventListener('mouseup', onMouseUp); + return () => { + document.removeEventListener('mouseup', onMouseUp); + document.removeEventListener('mousemove', onMouseMove); + }; + }, [onMouseUp, onMouseMove]); + + const prevAdhocMetricRef = useRef(adhocMetric); + const prevSavedMetricRef = useRef(savedMetric); + + useEffect(() => { + const prevAdhocMetric = prevAdhocMetricRef.current; + const prevSavedMetric = prevSavedMetricRef.current; + + if ( + prevAdhocMetric?.sqlExpression !== adhocMetric?.sqlExpression || + prevAdhocMetric?.aggregate !== adhocMetric?.aggregate || + prevAdhocMetric?.column?.column_name !== + adhocMetric?.column?.column_name || + prevSavedMetric?.metric_name !== savedMetric?.metric_name + ) { + getCurrentLabel?.({ + savedMetricLabel: savedMetric?.verbose_name || savedMetric?.metric_name, + adhocMetricLabel: adhocMetric?.getDefaultLabel(), + }); + } + + prevAdhocMetricRef.current = adhocMetric; + prevSavedMetricRef.current = savedMetric; + }, [adhocMetric, savedMetric, getCurrentLabel]); + + const onSave = useCallback(() => { const metric = savedMetric?.metric_name ? savedMetric : adhocMetric; - const oldMetric = this.props.savedMetric?.metric_name - ? this.props.savedMetric - : this.props.adhocMetric; - this.props.onChange( + const oldMetric = propsSavedMetric?.metric_name + ? propsSavedMetric + : propsAdhocMetric; + onChange( { ...metric, } as Metric, oldMetric as Metric, ); - this.props.onClose(); - } + onClose(); + }, [ + adhocMetric, + savedMetric, + propsSavedMetric, + propsAdhocMetric, + onChange, + onClose, + ]); - onResetStateAndClose() { - this.setState( - { - adhocMetric: this.props.adhocMetric, - savedMetric: this.props.savedMetric, - }, - this.props.onClose, - ); - } + const onResetStateAndClose = useCallback(() => { + setAdhocMetric(propsAdhocMetric); + setSavedMetric(propsSavedMetric); + onClose(); + }, [propsAdhocMetric, propsSavedMetric, onClose]); - onColumnChange(columnName: string): void { - const column = this.props.columns?.find( - column => column.column_name === columnName, - ); - this.setState(prevState => ({ - adhocMetric: prevState.adhocMetric.duplicateWith({ - column, - expressionType: EXPRESSION_TYPES.SIMPLE, - }), - savedMetric: undefined, - })); - } + const onColumnChange = useCallback( + (columnName: string): void => { + const column = columns.find(col => col.column_name === columnName); + setAdhocMetric(prevMetric => + prevMetric.duplicateWith({ + column, + expressionType: EXPRESSION_TYPES.SIMPLE, + }), + ); + setSavedMetric(undefined); + }, + [columns], + ); - onAggregateChange(aggregate: string | null): void { - // we construct this object explicitly to overwrite the value in the case aggregate is null - this.setState(prevState => ({ - adhocMetric: prevState.adhocMetric.duplicateWith({ + const onAggregateChange = useCallback((aggregate: string | null): void => { + setAdhocMetric(prevMetric => + prevMetric.duplicateWith({ aggregate, expressionType: EXPRESSION_TYPES.SIMPLE, }), - savedMetric: undefined, - })); - } - - onSavedMetricChange(savedMetricName: string): void { - const savedMetric = this.props.savedMetricsOptions?.find( - metric => metric.metric_name === savedMetricName, ); - this.setState(prevState => ({ - savedMetric, - adhocMetric: prevState.adhocMetric.duplicateWith({ - column: undefined, - aggregate: undefined, - sqlExpression: undefined, - expressionType: EXPRESSION_TYPES.SIMPLE, - }), - })); - } + setSavedMetric(undefined); + }, []); - onSqlExpressionChange(sqlExpression: string): void { - this.setState(prevState => ({ - adhocMetric: prevState.adhocMetric.duplicateWith({ + const onSavedMetricChange = useCallback( + (savedMetricName: string): void => { + const metric = savedMetricsOptions?.find( + m => m.metric_name === savedMetricName, + ); + setSavedMetric(metric); + setAdhocMetric(prevMetric => + prevMetric.duplicateWith({ + column: undefined, + aggregate: undefined, + sqlExpression: undefined, + expressionType: EXPRESSION_TYPES.SIMPLE, + }), + ); + }, + [savedMetricsOptions], + ); + + const onSqlExpressionChange = useCallback((sqlExpression: string): void => { + setAdhocMetric(prevMetric => + prevMetric.duplicateWith({ sqlExpression, expressionType: EXPRESSION_TYPES.SQL, }), - savedMetric: undefined, - })); - } + ); + setSavedMetric(undefined); + }, []); - onDragDown(e: React.MouseEvent): void { - this.dragStartX = e.clientX; - this.dragStartY = e.clientY; - this.dragStartWidth = this.state.width; - this.dragStartHeight = this.state.height; - document.addEventListener('mousemove', this.onMouseMove); - } + const onDragDown = useCallback( + (e: React.MouseEvent): void => { + dragStartRef.current = { + x: e.clientX, + y: e.clientY, + width, + height, + }; + document.addEventListener('mousemove', onMouseMove); + }, + [width, height, onMouseMove], + ); - onMouseMove(e: MouseEvent): void { - this.props.onResize(); - this.setState({ - width: Math.max( - this.dragStartWidth + (e.clientX - this.dragStartX), - POPOVER_INITIAL_WIDTH, - ), - height: Math.max( - this.dragStartHeight + (e.clientY - this.dragStartY), - POPOVER_INITIAL_HEIGHT, - ), - }); - } - - onMouseUp(): void { - document.removeEventListener('mousemove', this.onMouseMove); - } - - onTabChange(tab: string): void { - this.refreshAceEditor(); - this.props.getCurrentTab?.(tab); - } - - refreshAceEditor(): void { + const refreshAceEditor = useCallback((): void => { setTimeout(() => { - if (this.aceEditorRef.current) { - // Cast to access ace editor API + if (aceEditorRef.current) { ( - this.aceEditorRef.current as unknown as { + aceEditorRef.current as unknown as { editor?: { resize?: () => void }; } ).editor?.resize?.(); } }, 0); - } + }, []); - renderColumnOption(option: ColumnType): React.ReactNode { - const column = { ...option }; - if ( - (column as unknown as { metric_name?: string }).metric_name && - !column.verbose_name - ) { - column.verbose_name = ( - column as unknown as { metric_name: string } - ).metric_name; - } - return ; - } + const onTabChange = useCallback( + (tab: string): void => { + refreshAceEditor(); + getCurrentTab(tab); + }, + [refreshAceEditor, getCurrentTab], + ); - renderMetricOption(savedMetric: SavedMetricType): React.ReactNode { - return ; - } + const renderColumnOption = useCallback( + (option: ColumnType): React.ReactNode => { + const column = { ...option }; + if ( + (column as unknown as { metric_name?: string }).metric_name && + !column.verbose_name + ) { + column.verbose_name = ( + column as unknown as { metric_name: string } + ).metric_name; + } + return ; + }, + [], + ); - render() { - const { - adhocMetric: propsAdhocMetric, - savedMetric: propsSavedMetric, - columns, - savedMetricsOptions, - onChange, - onClose, - onResize, - datasource, - isNewMetric, - isLabelModified, - ...popoverProps - } = this.props; - const { adhocMetric, savedMetric } = this.state; - const columnsArray = columns ?? []; - const keywords = sqlKeywords.concat( - getColumnKeywords( - columnsArray as Parameters[0], + const renderMetricOption = useCallback( + (metric: SavedMetricType): React.ReactNode => ( + + ), + [], + ); + + const columnsArray = columns; + const keywords = useMemo( + () => + sqlKeywords.concat( + getColumnKeywords( + columnsArray as Parameters[0], + ), ), - ); + [columnsArray], + ); - const columnValue = - (adhocMetric.column && adhocMetric.column.column_name) || - adhocMetric.inferSqlExpressionColumn(); + const columnValue = + (adhocMetric.column && adhocMetric.column.column_name) || + adhocMetric.inferSqlExpressionColumn(); - // autofocus on column if there's no value in column; otherwise autofocus on aggregate - const columnSelectProps = { + const columnSelectProps = useMemo( + () => ({ ariaLabel: t('Select column'), placeholder: t('%s column(s)', columnsArray.length), value: columnValue, - onChange: this.onColumnChange, + onChange: onColumnChange, allowClear: true, autoFocus: !columnValue, - }; + }), + [columnsArray.length, columnValue, onColumnChange], + ); - const aggregateSelectProps = { + const aggregateSelectProps = useMemo( + () => ({ ariaLabel: t('Select aggregate options'), placeholder: t('%s aggregates(s)', AGGREGATES_OPTIONS.length), value: adhocMetric.aggregate ?? adhocMetric.inferSqlExpressionAggregate() ?? undefined, - onChange: this.onAggregateChange as (value: unknown) => void, + onChange: onAggregateChange as (value: unknown) => void, allowClear: true, autoFocus: !!columnValue, - }; + }), + [adhocMetric, columnValue, onAggregateChange], + ); - const savedSelectProps = { + const savedSelectProps = useMemo( + () => ({ ariaLabel: t('Select saved metrics'), placeholder: t('%s saved metric(s)', savedMetricsOptions?.length ?? 0), value: savedMetric?.metric_name, - onChange: this.onSavedMetricChange, + onChange: onSavedMetricChange, allowClear: true, autoFocus: true, - }; + }), + [ + savedMetricsOptions?.length, + savedMetric?.metric_name, + onSavedMetricChange, + ], + ); - const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name; - const hasUnsavedChanges = - isLabelModified || - isNewMetric || - !adhocMetric.equals(propsAdhocMetric) || - (!( - typeof savedMetric?.metric_name === 'undefined' && - typeof propsSavedMetric?.metric_name === 'undefined' - ) && - savedMetric?.metric_name !== propsSavedMetric?.metric_name); + const stateIsValid = adhocMetric.isValid() || savedMetric?.metric_name; + const hasUnsavedChanges = + isLabelModified || + isNewMetric || + !adhocMetric.equals(propsAdhocMetric) || + (!( + typeof savedMetric?.metric_name === 'undefined' && + typeof propsSavedMetric?.metric_name === 'undefined' + ) && + savedMetric?.metric_name !== propsSavedMetric?.metric_name); - let extra: ExtraConfig = {}; - if (datasource?.extra && typeof datasource.extra === 'string') { - try { - extra = JSON.parse(datasource.extra) as ExtraConfig; - } catch {} // eslint-disable-line no-empty - } + let extra: ExtraConfig = {}; + if (datasource?.extra && typeof datasource.extra === 'string') { + try { + extra = JSON.parse(datasource.extra) as ExtraConfig; + } catch {} // eslint-disable-line no-empty + } - return ( -
- 0 ? ( - - ({ - value: savedMetric.metric_name, - label: this.renderMetricOption(savedMetric), - key: savedMetric.id, - metric_name: savedMetric.metric_name, - verbose_name: savedMetric.verbose_name ?? '', - }), - )} - optionFilterProps={['metric_name', 'verbose_name']} - {...savedSelectProps} - /> - - ) : datasource?.type === DatasourceType.Table ? ( - + 0 ? ( + + ({ + value: metric.metric_name, + label: renderMetricOption(metric), + key: metric.id, + metric_name: metric.metric_name, + verbose_name: metric.verbose_name ?? '', + }))} + optionFilterProps={['metric_name', 'verbose_name']} + {...savedSelectProps} /> - ) : ( - - { - this.props.handleDatasetModal?.(true); - this.props.onClose(); - }} - > - {t('Create a dataset')} - - {t(' to add metrics')} - - } - /> - ), - }, - { - key: EXPRESSION_TYPES.SIMPLE, - label: extra.disallow_adhoc_metrics ? ( - + ) : datasource?.type === DatasourceType.Table ? ( + - {t('Simple')} - + /> ) : ( - t('Simple') - ), - disabled: extra.disallow_adhoc_metrics, - children: ( - <> - - ({ - value: option, - label: option, - key: option, - }))} - {...aggregateSelectProps} - /> - - - ), - }, - { - key: EXPRESSION_TYPES.SQL, - label: extra.disallow_adhoc_metrics ? ( - - {t('Custom SQL')} - - ) : ( - t('Custom SQL') - ), - disabled: extra.disallow_adhoc_metrics, - children: ( - + { + handleDatasetModal?.(true); + onClose(); + }} + > + {t('Create a dataset')} + + {t(' to add metrics')} + } - wordWrap - showValidation - expressionType="metric" - datasourceId={datasource?.id} - datasourceType={datasource?.type} /> ), - }, - ]} + }, + { + key: EXPRESSION_TYPES.SIMPLE, + label: extra.disallow_adhoc_metrics ? ( + + {t('Simple')} + + ) : ( + t('Simple') + ), + disabled: extra.disallow_adhoc_metrics, + children: ( + <> + + ({ + value: option, + label: option, + key: option, + }))} + {...aggregateSelectProps} + /> + + + ), + }, + { + key: EXPRESSION_TYPES.SQL, + label: extra.disallow_adhoc_metrics ? ( + + {t('Custom SQL')} + + ) : ( + t('Custom SQL') + ), + disabled: extra.disallow_adhoc_metrics, + children: ( + } + keywords={keywords} + height={`${height - 120}px`} + onChange={onSqlExpressionChange} + width="100%" + lineNumbers={false} + value={ + adhocMetric.sqlExpression || + adhocMetric.translateToSql({ transformCountDistinct: true }) + } + wordWrap + showValidation + expressionType="metric" + datasourceId={datasource?.id} + datasourceType={datasource?.type} + /> + ), + }, + ]} + /> +
+ + + -
- - - -
- - ); - } +
+ + ); } -// @ts-expect-error - defaultProps for backward compatibility -AdhocMetricEditPopover.defaultProps = defaultProps; + +export default memo(AdhocMetricEditPopover); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx index c562dc2556c..b3105e4ee35 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricOption.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent } from 'react'; +import { memo, useCallback } from 'react'; import { Metric } from '@superset-ui/core'; import { OptionControlLabel } from 'src/explore/components/controls/OptionControls'; import { DndItemType } from 'src/explore/components/DndItemType'; @@ -42,61 +42,57 @@ interface AdhocMetricOptionProps { datasourceWarningMessage?: string; } -class AdhocMetricOption extends PureComponent { - constructor(props: AdhocMetricOptionProps) { - super(props); - this.onRemoveMetric = this.onRemoveMetric.bind(this); - } +function AdhocMetricOption({ + adhocMetric, + onMetricEdit, + onRemoveMetric, + columns = [], + savedMetricsOptions = [], + savedMetric = {} as SavedMetricTypeDef, + datasource, + onMoveLabel, + onDropLabel, + index = 0, + type = DndItemType.AdhocMetricOption, + multi, + datasourceWarningMessage, +}: AdhocMetricOptionProps) { + const handleRemoveMetric = useCallback( + (e?: React.MouseEvent): void => { + e?.stopPropagation(); + onRemoveMetric?.(index); + }, + [onRemoveMetric, index], + ); - onRemoveMetric(e?: React.MouseEvent): void { - e?.stopPropagation(); - this.props.onRemoveMetric?.(this.props.index ?? 0); - } + const withCaret = !(savedMetric as SavedMetricTypeDef).error_text; - render() { - const { - adhocMetric, - onMetricEdit, - columns, - savedMetricsOptions, - savedMetric = {} as SavedMetricTypeDef, - datasource, - onMoveLabel, - onDropLabel, - index, - type, - multi, - datasourceWarningMessage, - } = this.props; - const withCaret = !(savedMetric as SavedMetricTypeDef).error_text; - - return ( - + - this.onRemoveMetric()} - onMoveLabel={onMoveLabel} - onDropLabel={onDropLabel} - index={index ?? 0} - type={type ?? DndItemType.AdhocMetricOption} - withCaret={withCaret} - isFunction - multi={multi} - datasourceWarningMessage={datasourceWarningMessage} - /> - - ); - } + label={adhocMetric.label} + onRemove={() => handleRemoveMetric()} + onMoveLabel={onMoveLabel} + onDropLabel={onDropLabel} + index={index} + type={type} + withCaret={withCaret} + isFunction + multi={multi} + datasourceWarningMessage={datasourceWarningMessage} + /> + + ); } -export default AdhocMetricOption; +export default memo(AdhocMetricOption); diff --git a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx index b4f8e580974..98799f88ad1 100644 --- a/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx +++ b/superset-frontend/src/explore/components/controls/MetricControl/AdhocMetricPopoverTrigger.tsx @@ -16,7 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, ReactNode } from 'react'; +import { + memo, + ReactNode, + useCallback, + useEffect, + useReducer, + useRef, + useState, +} from 'react'; import { t } from '@apache-superset/core'; import { Metric } from '@superset-ui/core'; import AdhocMetricEditPopoverTitle from 'src/explore/components/controls/MetricControl/AdhocMetricEditPopoverTitle'; @@ -48,237 +56,315 @@ export type AdhocMetricPopoverTriggerProps = { isNew?: boolean; }; -export type AdhocMetricPopoverTriggerState = { +interface TitleState { + label: string; + hasCustomLabel: boolean; +} + +interface ComponentState { adhocMetric: AdhocMetric; popoverVisible: boolean; - title: { label: string; hasCustomLabel: boolean }; + title: TitleState; currentLabel: string; labelModified: boolean; isTitleEditDisabled: boolean; showSaveDatasetModal: boolean; -}; +} -class AdhocMetricPopoverTrigger extends PureComponent< - AdhocMetricPopoverTriggerProps, - AdhocMetricPopoverTriggerState -> { - constructor(props: AdhocMetricPopoverTriggerProps) { - super(props); - this.onPopoverResize = this.onPopoverResize.bind(this); - this.onLabelChange = this.onLabelChange.bind(this); - this.closePopover = this.closePopover.bind(this); - this.togglePopover = this.togglePopover.bind(this); - this.getCurrentTab = this.getCurrentTab.bind(this); - this.getCurrentLabel = this.getCurrentLabel.bind(this); - this.onChange = this.onChange.bind(this); - this.handleDatasetModal = this.handleDatasetModal.bind(this); - - this.state = { - adhocMetric: props.adhocMetric, - popoverVisible: false, - title: { - label: props.adhocMetric.label, - hasCustomLabel: props.adhocMetric.hasCustomLabel, - }, - currentLabel: '', - labelModified: false, - isTitleEditDisabled: false, - showSaveDatasetModal: false, +type Action = + | { type: 'SET_ADHOC_METRIC'; payload: AdhocMetric } + | { type: 'SET_POPOVER_VISIBLE'; payload: boolean } + | { type: 'SET_TITLE'; payload: TitleState } + | { type: 'SET_CURRENT_LABEL'; payload: string } + | { type: 'SET_LABEL_MODIFIED'; payload: boolean } + | { type: 'SET_TITLE_EDIT_DISABLED'; payload: boolean } + | { type: 'SET_SHOW_SAVE_DATASET_MODAL'; payload: boolean } + | { + type: 'RESET_ON_OPTION_CHANGE'; + payload: { adhocMetric: AdhocMetric; title: TitleState }; + } + | { type: 'UPDATE_ADHOC_METRIC'; payload: AdhocMetric } + | { type: 'CLOSE_POPOVER' } + | { + type: 'ON_LABEL_CHANGE'; + payload: { label: string; currentLabel: string; fallbackLabel: string }; + } + | { + type: 'GET_CURRENT_LABEL'; + payload: { currentLabel: string; hasCustomLabel: boolean }; }; - } - static getDerivedStateFromProps( - nextProps: AdhocMetricPopoverTriggerProps, - prevState: AdhocMetricPopoverTriggerState, - ) { - if (prevState.adhocMetric.optionName !== nextProps.adhocMetric.optionName) { +function reducer(state: ComponentState, action: Action): ComponentState { + switch (action.type) { + case 'SET_ADHOC_METRIC': + return { ...state, adhocMetric: action.payload }; + case 'SET_POPOVER_VISIBLE': + return { ...state, popoverVisible: action.payload }; + case 'SET_TITLE': + return { ...state, title: action.payload }; + case 'SET_CURRENT_LABEL': + return { ...state, currentLabel: action.payload }; + case 'SET_LABEL_MODIFIED': + return { ...state, labelModified: action.payload }; + case 'SET_TITLE_EDIT_DISABLED': + return { ...state, isTitleEditDisabled: action.payload }; + case 'SET_SHOW_SAVE_DATASET_MODAL': + return { ...state, showSaveDatasetModal: action.payload }; + case 'RESET_ON_OPTION_CHANGE': return { - adhocMetric: nextProps.adhocMetric, - title: { - label: nextProps.adhocMetric.label, - hasCustomLabel: nextProps.adhocMetric.hasCustomLabel, - }, + ...state, + adhocMetric: action.payload.adhocMetric, + title: action.payload.title, currentLabel: '', labelModified: false, }; - } - return { - adhocMetric: nextProps.adhocMetric, - }; - } - - onLabelChange(e: any) { - const { verbose_name, metric_name } = this.props.savedMetric; - const defaultMetricLabel = this.props.adhocMetric?.getDefaultLabel(); - const label = e.target.value; - this.setState(state => ({ - title: { - label: - label || - state.currentLabel || - verbose_name || - metric_name || - defaultMetricLabel, - hasCustomLabel: !!label, - }, - labelModified: true, - })); - } - - onPopoverResize() { - this.forceUpdate(); - } - - handleDatasetModal(showModal: boolean) { - this.setState({ showSaveDatasetModal: showModal }); - } - - closePopover() { - this.togglePopover(false); - this.setState({ - labelModified: false, - }); - } - - togglePopover(visible: boolean) { - this.setState({ - popoverVisible: visible, - }); - } - - getCurrentTab(tab: string) { - this.setState({ - isTitleEditDisabled: tab === SAVED_TAB_KEY, - }); - } - - getCurrentLabel({ - savedMetricLabel, - adhocMetricLabel, - }: { - savedMetricLabel: string; - adhocMetricLabel: string; - }) { - const currentLabel = savedMetricLabel || adhocMetricLabel; - this.setState({ - currentLabel, - labelModified: true, - }); - if (savedMetricLabel || !this.state.title.hasCustomLabel) { - this.setState({ + case 'UPDATE_ADHOC_METRIC': + return { ...state, adhocMetric: action.payload }; + case 'CLOSE_POPOVER': + return { ...state, popoverVisible: false, labelModified: false }; + case 'ON_LABEL_CHANGE': { + const { label, currentLabel, fallbackLabel } = action.payload; + return { + ...state, title: { + label: label || currentLabel || fallbackLabel, + hasCustomLabel: !!label, + }, + labelModified: true, + }; + } + case 'GET_CURRENT_LABEL': { + const { currentLabel, hasCustomLabel } = action.payload; + const newState: ComponentState = { + ...state, + currentLabel, + labelModified: true, + }; + if (currentLabel || !hasCustomLabel) { + newState.title = { label: currentLabel, hasCustomLabel: false, - }, - }); + }; + } + return newState; } - } - - onChange(newMetric: Metric, oldMetric: Metric) { - this.props.onMetricEdit({ ...newMetric, ...this.state.title }, oldMetric); - } - - render() { - const { - adhocMetric, - savedMetric, - columns, - savedMetricsOptions, - datasource, - isControlledComponent, - } = this.props; - const { verbose_name, metric_name } = savedMetric; - const { hasCustomLabel, label } = adhocMetric; - const adhocMetricLabel = hasCustomLabel - ? label - : adhocMetric.getDefaultLabel(); - const title = this.state.labelModified - ? this.state.title - : { - label: verbose_name || metric_name || adhocMetricLabel, - hasCustomLabel, - }; - - const { visible, togglePopover, closePopover } = isControlledComponent - ? { - visible: this.props.visible, - togglePopover: this.props.togglePopover ?? this.togglePopover, - closePopover: this.props.closePopover ?? this.closePopover, - } - : { - visible: this.state.popoverVisible, - togglePopover: this.togglePopover, - closePopover: this.closePopover, - }; - - const overlayContent = ( - - void - } - getCurrentTab={this.getCurrentTab} - getCurrentLabel={this.getCurrentLabel} - isNewMetric={this.props.isNew} - isLabelModified={ - this.state.labelModified && - adhocMetricLabel !== this.state.title.label - } - /> - - ); - - const popoverTitle = ( - - ); - - return ( - <> - {this.state.showSaveDatasetModal && ( - this.handleDatasetModal(false)} - buttonTextOnSave={t('Save')} - buttonTextOnOverwrite={t('Overwrite')} - modalDescription={t( - 'Save this query as a virtual dataset to continue exploring', - )} - datasource={datasource} - /> - )} - - {this.props.children} - - - ); + default: + return state; } } -export default AdhocMetricPopoverTrigger; +function AdhocMetricPopoverTrigger({ + adhocMetric: propsAdhocMetric, + onMetricEdit, + columns, + savedMetricsOptions, + savedMetric, + datasource, + children, + isControlledComponent, + visible: propsVisible, + togglePopover: propsTogglePopover, + closePopover: propsClosePopover, + isNew, +}: AdhocMetricPopoverTriggerProps) { + const initialState: ComponentState = { + adhocMetric: propsAdhocMetric, + popoverVisible: false, + title: { + label: propsAdhocMetric.label, + hasCustomLabel: propsAdhocMetric.hasCustomLabel, + }, + currentLabel: '', + labelModified: false, + isTitleEditDisabled: false, + showSaveDatasetModal: false, + }; + + const [state, dispatch] = useReducer(reducer, initialState); + + // Track previous optionName to detect when the metric changes externally + const prevOptionNameRef = useRef(propsAdhocMetric.optionName); + + // Handle getDerivedStateFromProps logic + useEffect(() => { + if (prevOptionNameRef.current !== propsAdhocMetric.optionName) { + dispatch({ + type: 'RESET_ON_OPTION_CHANGE', + payload: { + adhocMetric: propsAdhocMetric, + title: { + label: propsAdhocMetric.label, + hasCustomLabel: propsAdhocMetric.hasCustomLabel, + }, + }, + }); + } else { + dispatch({ type: 'UPDATE_ADHOC_METRIC', payload: propsAdhocMetric }); + } + prevOptionNameRef.current = propsAdhocMetric.optionName; + }, [propsAdhocMetric]); + + const [, forceUpdate] = useState({}); + + const onPopoverResize = useCallback(() => { + forceUpdate({}); + }, []); + + const onLabelChange = useCallback( + (e: { target: { value: string } }) => { + const { verbose_name, metric_name } = savedMetric; + const defaultMetricLabel = propsAdhocMetric?.getDefaultLabel(); + const label = e.target.value; + dispatch({ + type: 'ON_LABEL_CHANGE', + payload: { + label, + currentLabel: state.currentLabel, + fallbackLabel: verbose_name || metric_name || defaultMetricLabel, + }, + }); + }, + [savedMetric, propsAdhocMetric, state.currentLabel], + ); + + const handleDatasetModal = useCallback((showModal: boolean) => { + dispatch({ type: 'SET_SHOW_SAVE_DATASET_MODAL', payload: showModal }); + }, []); + + const closePopover = useCallback(() => { + dispatch({ type: 'CLOSE_POPOVER' }); + }, []); + + const togglePopover = useCallback((visible: boolean) => { + dispatch({ type: 'SET_POPOVER_VISIBLE', payload: visible }); + }, []); + + const getCurrentTab = useCallback((tab: string) => { + dispatch({ + type: 'SET_TITLE_EDIT_DISABLED', + payload: tab === SAVED_TAB_KEY, + }); + }, []); + + const getCurrentLabel = useCallback( + ({ + savedMetricLabel, + adhocMetricLabel, + }: { + savedMetricLabel: string; + adhocMetricLabel: string; + }) => { + const currentLabel = savedMetricLabel || adhocMetricLabel; + dispatch({ + type: 'GET_CURRENT_LABEL', + payload: { + currentLabel, + hasCustomLabel: state.title.hasCustomLabel, + }, + }); + }, + [state.title.hasCustomLabel], + ); + + const onChange = useCallback( + (newMetric: Metric, oldMetric: Metric) => { + onMetricEdit({ ...newMetric, ...state.title }, oldMetric); + }, + [onMetricEdit, state.title], + ); + + const { verbose_name, metric_name } = savedMetric; + const { hasCustomLabel, label } = state.adhocMetric; + const adhocMetricLabel = hasCustomLabel + ? label + : state.adhocMetric.getDefaultLabel(); + const title = state.labelModified + ? state.title + : { + label: verbose_name || metric_name || adhocMetricLabel, + hasCustomLabel, + }; + + const { + visible, + togglePopover: toggle, + closePopover: close, + } = isControlledComponent + ? { + visible: propsVisible, + togglePopover: propsTogglePopover ?? togglePopover, + closePopover: propsClosePopover ?? closePopover, + } + : { + visible: state.popoverVisible, + togglePopover, + closePopover, + }; + + const overlayContent = ( + + void} + getCurrentTab={getCurrentTab} + getCurrentLabel={getCurrentLabel} + isNewMetric={isNew} + isLabelModified={ + state.labelModified && adhocMetricLabel !== state.title.label + } + /> + + ); + + const popoverTitle = ( + + ); + + return ( + <> + {state.showSaveDatasetModal && ( + handleDatasetModal(false)} + buttonTextOnSave={t('Save')} + buttonTextOnOverwrite={t('Overwrite')} + modalDescription={t( + 'Save this query as a virtual dataset to continue exploring', + )} + datasource={datasource} + /> + )} + + {children} + + + ); +} + +export default memo(AdhocMetricPopoverTrigger); diff --git a/superset-frontend/src/explore/components/controls/SelectControl.tsx b/superset-frontend/src/explore/components/controls/SelectControl.tsx index f13164db204..95763505024 100644 --- a/superset-frontend/src/explore/components/controls/SelectControl.tsx +++ b/superset-frontend/src/explore/components/controls/SelectControl.tsx @@ -16,7 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, type ReactNode } from 'react'; +import { + useState, + useCallback, + useEffect, + useMemo, + useRef, + type ReactNode, +} from 'react'; import { isEqualArray } from '@superset-ui/core'; import { t } from '@apache-superset/core'; import { css } from '@apache-superset/core/ui'; @@ -71,26 +78,6 @@ export interface SelectControlProps { sortComparator?: (a: SelectOption, b: SelectOption) => number; } -const defaultProps = { - autoFocus: false, - choices: [], - clearable: true, - description: null, - disabled: false, - freeForm: false, - isLoading: false, - label: null, - multi: false, - onChange: () => {}, - onFocus: () => {}, - showHeader: true, - valueKey: 'value', -}; - -interface SelectControlState { - options: SelectOption[]; -} - const numberComparator = (a: SelectOption, b: SelectOption): number => (a.value as number) - (b.value as number); @@ -139,9 +126,9 @@ export const getSortComparator = ( export const innerGetOptions = (props: SelectControlProps): SelectOption[] => { const { choices, optionRenderer, valueKey = 'value' } = props; - let options: SelectOption[] = []; + let selectOptions: SelectOption[] = []; if (props.options) { - options = props.options.map(o => ({ + selectOptions = props.options.map(o => ({ ...o, value: o[valueKey] as string | number, label: optionRenderer @@ -150,7 +137,7 @@ export const innerGetOptions = (props: SelectControlProps): SelectOption[] => { })); } else if (choices) { // Accepts different formats of input - options = choices.map(c => { + selectOptions = choices.map(c => { if (Array.isArray(c)) { const [value, label] = c.length > 1 ? c : [c[0], c[0]]; return { @@ -162,136 +149,165 @@ export const innerGetOptions = (props: SelectControlProps): SelectOption[] => { return { value: c as unknown as string | number, label: String(c) }; }); } - return options; + return selectOptions; }; -export default class SelectControl extends PureComponent< - SelectControlProps, - SelectControlState -> { - static defaultProps = defaultProps; +function SelectControl({ + ariaLabel, + autoFocus = false, + choices = [], + clearable = true, + description = null, + disabled = false, + freeForm = false, + isLoading = false, + mode, + multi = false, + isMulti, + name, + onChange = () => {}, + onFocus = () => {}, + onSelect, + onDeselect, + value, + default: defaultValue, + showHeader = true, + optionRenderer, + valueKey = 'value', + options: optionsProp, + placeholder, + filterOption, + tokenSeparators, + notFoundContent, + label = undefined, + renderTrigger, + validationErrors, + rightNode, + leftNode, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + sortComparator, +}: SelectControlProps) { + const [options, setOptions] = useState(() => + innerGetOptions({ + choices, + optionRenderer, + valueKey, + options: optionsProp, + name, + }), + ); - constructor(props: SelectControlProps) { - super(props); - this.state = { - options: this.getOptions(props), - }; - this.onChange = this.onChange.bind(this); - this.handleFilterOptions = this.handleFilterOptions.bind(this); - } + // Track previous choices/options for comparison + const prevChoicesRef = useRef(choices); + const prevOptionsRef = useRef(optionsProp); - componentDidUpdate(prevProps: SelectControlProps) { + useEffect(() => { if ( - !isEqualArray(this.props.choices, prevProps.choices) || - !isEqualArray(this.props.options, prevProps.options) + !isEqualArray(choices, prevChoicesRef.current) || + !isEqualArray(optionsProp, prevOptionsRef.current) ) { - const options = this.getOptions(this.props); - this.setState({ options }); + const newOptions = innerGetOptions({ + choices, + optionRenderer, + valueKey, + options: optionsProp, + name, + }); + setOptions(newOptions); + prevChoicesRef.current = choices; + prevOptionsRef.current = optionsProp; } - } + }, [choices, optionsProp, optionRenderer, valueKey, name]); // Beware: This is acting like an on-click instead of an on-change // (firing every time user chooses vs firing only if a new option is chosen). - onChange(val: SelectValue | SelectOption | SelectOption[]) { - // will eventually call `exploreReducer`: SET_FIELD_VALUE - const { valueKey = 'value' } = this.props; - let onChangeVal: SelectValue = val as SelectValue; + const handleChange = useCallback( + (val: SelectValue | SelectOption | SelectOption[]) => { + // will eventually call `exploreReducer`: SET_FIELD_VALUE + let onChangeVal: SelectValue = val as SelectValue; - if (Array.isArray(val)) { - const values = val.map(v => - typeof v === 'object' && - v !== null && - (v as SelectOption)[valueKey] !== undefined - ? (v as SelectOption)[valueKey] - : v, - ); - onChangeVal = values as (string | number)[]; - } - if ( - typeof val === 'object' && - val !== null && - !Array.isArray(val) && - (val as SelectOption)[valueKey] !== undefined - ) { - onChangeVal = (val as SelectOption)[valueKey] as string | number; - } - this.props.onChange?.(onChangeVal, []); - } - - getOptions(props: SelectControlProps) { - return innerGetOptions(props); - } - - handleFilterOptions(text: string, option: SelectOption) { - const { filterOption } = this.props; - return filterOption?.({ data: option }, text) ?? true; - } - - render() { - const { - ariaLabel, - autoFocus, - clearable, - disabled, - filterOption, - freeForm, - isLoading, - isMulti, - label, - multi, - name, - notFoundContent, - onFocus, - onSelect, - onDeselect, - placeholder, - showHeader, - tokenSeparators, - value, - // ControlHeader props - description, - renderTrigger, - rightNode, - leftNode, - validationErrors, - onClick, - hovered, - tooltipOnClick, - warning, - danger, - } = this.props; - - const headerProps = { - name, - label, - description, - renderTrigger, - rightNode, - leftNode, - validationErrors, - onClick, - hovered, - tooltipOnClick, - warning, - danger, - }; - - const getValue = () => { - const currentValue = - value ?? - (this.props.default !== undefined ? this.props.default : undefined); - - // safety check - the value is intended to be undefined but null was used - if ( - currentValue === null && - !this.state.options.some(o => o.value === null) - ) { - return undefined; + if (Array.isArray(val)) { + const values = val.map(v => + typeof v === 'object' && + v !== null && + (v as SelectOption)[valueKey] !== undefined + ? (v as SelectOption)[valueKey] + : v, + ); + onChangeVal = values as (string | number)[]; } - return currentValue; - }; + if ( + typeof val === 'object' && + val !== null && + !Array.isArray(val) && + (val as SelectOption)[valueKey] !== undefined + ) { + onChangeVal = (val as SelectOption)[valueKey] as string | number; + } + onChange?.(onChangeVal, []); + }, + [onChange, valueKey], + ); - const selectProps = { + const handleFilterOptions = useCallback( + (text: string, option: SelectOption) => + filterOption?.({ data: option }, text) ?? true, + [filterOption], + ); + + const headerProps = useMemo( + () => ({ + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + }), + [ + name, + label, + description, + renderTrigger, + rightNode, + leftNode, + validationErrors, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + ], + ); + + const getValue = useCallback(() => { + const currentValue = + value ?? (defaultValue !== undefined ? defaultValue : undefined); + + // safety check - the value is intended to be undefined but null was used + if (currentValue === null && !options.find(o => o.value === null)) { + return undefined; + } + return currentValue; + }, [value, defaultValue, options]); + + const computedSortComparator = useMemo( + () => getSortComparator(choices, optionsProp, valueKey, sortComparator), + [choices, optionsProp, valueKey, sortComparator], + ); + + const selectProps = useMemo( + () => ({ allowNewOptions: freeForm, autoFocus, ariaLabel: @@ -300,46 +316,69 @@ export default class SelectControl extends PureComponent< disabled, filterOption: filterOption && typeof filterOption === 'function' - ? this.handleFilterOptions + ? handleFilterOptions : true, header: showHeader && , loading: isLoading, - mode: this.props.mode || (isMulti || multi ? 'multiple' : 'single'), + mode: mode || (isMulti || multi ? 'multiple' : 'single'), name: `select-${name}`, - onChange: this.onChange, + onChange: handleChange, onFocus, onSelect, onDeselect, - options: this.state.options, + options, placeholder, - sortComparator: getSortComparator( - this.props.choices, - this.props.options, - this.props.valueKey, - this.props.sortComparator, - ), + sortComparator: computedSortComparator, value: getValue(), tokenSeparators, notFoundContent, - }; + }), + [ + freeForm, + autoFocus, + ariaLabel, + label, + clearable, + disabled, + filterOption, + handleFilterOptions, + showHeader, + headerProps, + isLoading, + mode, + isMulti, + multi, + name, + handleChange, + onFocus, + onSelect, + onDeselect, + options, + placeholder, + computedSortComparator, + getValue, + tokenSeparators, + notFoundContent, + ], + ); - return ( -
css` - .type-label { - margin-right: ${theme.sizeUnit * 2}px; - } - .Select__multi-value__label > span, - .Select__option > span, - .Select__single-value > span { - display: flex; - align-items: center; - } - `} - > - {/* eslint-disable-next-line @typescript-eslint/no-explicit-any */} - [0])} /> +
+ ); } + +export default SelectControl; diff --git a/superset-frontend/src/explore/components/controls/SpatialControl.tsx b/superset-frontend/src/explore/components/controls/SpatialControl.tsx index 6fec8797d15..8ed76e60b67 100644 --- a/superset-frontend/src/explore/components/controls/SpatialControl.tsx +++ b/superset-frontend/src/explore/components/controls/SpatialControl.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component, type ReactNode } from 'react'; +import { useState, useCallback, useEffect, type ReactNode } from 'react'; import { Row, Col, @@ -53,212 +53,219 @@ interface SpatialControlProps { value?: SpatialValue; animation?: boolean; choices?: [string, string][]; + // ControlHeader props that may be passed through + name?: string; + label?: React.ReactNode; + description?: React.ReactNode; } -interface SpatialControlState { - type: SpatialType; - delimiter: string; - latCol: string | undefined; - lonCol: string | undefined; - lonlatCol: string | undefined; - reverseCheckbox: boolean; - geohashCol: string | undefined; - value: SpatialValue | null; - errors: string[]; -} +export default function SpatialControl({ + onChange = () => {}, + value: propValue, + choices = [], + name, + label, + description, +}: SpatialControlProps): JSX.Element { + const v = propValue || ({} as SpatialValue); + const defaultCol = choices.length > 0 ? choices[0][0] : undefined; -export default class SpatialControl extends Component< - SpatialControlProps, - SpatialControlState -> { - static defaultProps = { - onChange: () => {}, - animation: true, - choices: [], - }; + const [type, setTypeState] = useState( + v.type || spatialTypes.latlong, + ); + const [delimiter, setDelimiter] = useState(v.delimiter || ','); + const [latCol, setLatCol] = useState( + v.latCol || defaultCol, + ); + const [lonCol, setLonCol] = useState( + v.lonCol || defaultCol, + ); + const [lonlatCol, setLonlatCol] = useState( + v.lonlatCol || defaultCol, + ); + const [reverseCheckbox, setReverseCheckbox] = useState( + v.reverseCheckbox || false, + ); + const [geohashCol, setGeohashCol] = useState( + v.geohashCol || defaultCol, + ); - constructor(props: SpatialControlProps) { - super(props); - const v = props.value || ({} as SpatialValue); - let defaultCol: string | undefined; - if (props.choices && props.choices.length > 0) { - defaultCol = props.choices[0][0]; - } - this.state = { - type: v.type || spatialTypes.latlong, - delimiter: v.delimiter || ',', - latCol: v.latCol || defaultCol, - lonCol: v.lonCol || defaultCol, - lonlatCol: v.lonlatCol || defaultCol, - reverseCheckbox: v.reverseCheckbox || false, - geohashCol: v.geohashCol || defaultCol, - value: null, - errors: [], - }; - } - - componentDidMount(): void { - this.onChange(); - } - - onChange = (): void => { - const { type } = this.state; - const value: SpatialValue = { type }; + const computeValueAndErrors = useCallback((): { + value: SpatialValue; + errors: string[]; + } => { + const computedValue: SpatialValue = { type }; const errors: string[] = []; const errMsg = t('Invalid lat/long configuration.'); + if (type === spatialTypes.latlong) { - value.latCol = this.state.latCol; - value.lonCol = this.state.lonCol; - if (!value.lonCol || !value.latCol) { + computedValue.latCol = latCol; + computedValue.lonCol = lonCol; + if (!lonCol || !latCol) { errors.push(errMsg); } } else if (type === spatialTypes.delimited) { - value.lonlatCol = this.state.lonlatCol; - value.delimiter = this.state.delimiter; - value.reverseCheckbox = this.state.reverseCheckbox; - if (!value.lonlatCol || !value.delimiter) { + computedValue.lonlatCol = lonlatCol; + computedValue.delimiter = delimiter; + computedValue.reverseCheckbox = reverseCheckbox; + if (!lonlatCol || !delimiter) { errors.push(errMsg); } } else if (type === spatialTypes.geohash) { - value.geohashCol = this.state.geohashCol; - value.reverseCheckbox = this.state.reverseCheckbox; - if (!value.geohashCol) { + computedValue.geohashCol = geohashCol; + computedValue.reverseCheckbox = reverseCheckbox; + if (!geohashCol) { errors.push(errMsg); } } - this.setState({ value, errors }); - this.props.onChange?.(value, errors); - }; - setType = (type: SpatialType): void => { - this.setState({ type }, this.onChange); - }; + return { value: computedValue, errors }; + }, [type, latCol, lonCol, lonlatCol, delimiter, reverseCheckbox, geohashCol]); - toggleCheckbox = (): void => { - this.setState( - prevState => ({ reverseCheckbox: !prevState.reverseCheckbox }), - this.onChange, - ); - }; + useEffect(() => { + const { value: computedValue, errors } = computeValueAndErrors(); + onChange(computedValue, errors); + }, [computeValueAndErrors, onChange]); - renderLabelContent(): string | null { - if (this.state.errors.length > 0) { + const setType = useCallback((newType: SpatialType): void => { + setTypeState(newType); + }, []); + + const toggleCheckbox = useCallback((): void => { + setReverseCheckbox(prev => !prev); + }, []); + + const { errors } = computeValueAndErrors(); + + const renderLabelContent = (): string | null => { + if (errors.length > 0) { return 'N/A'; } - if (this.state.type === spatialTypes.latlong) { - return `${this.state.lonCol} | ${this.state.latCol}`; + if (type === spatialTypes.latlong) { + return `${lonCol} | ${latCol}`; } - if (this.state.type === spatialTypes.delimited) { - return `${this.state.lonlatCol}`; + if (type === spatialTypes.delimited) { + return `${lonlatCol}`; } - if (this.state.type === spatialTypes.geohash) { - return `${this.state.geohashCol}`; + if (type === spatialTypes.geohash) { + return `${geohashCol}`; } return null; - } + }; + + const renderSelect = ( + name: 'latCol' | 'lonCol' | 'lonlatCol' | 'geohashCol' | 'delimiter', + selectType: SpatialType, + ): ReactNode => { + const stateMap: Record = { + latCol, + lonCol, + lonlatCol, + geohashCol, + delimiter, + }; + const setterMap: Record< + string, + React.Dispatch> + > = { + latCol: setLatCol, + lonCol: setLonCol, + lonlatCol: setLonlatCol, + geohashCol: setGeohashCol, + delimiter: setDelimiter as React.Dispatch< + React.SetStateAction + >, + }; - renderSelect(name: keyof SpatialControlState, type: SpatialType): ReactNode { return ( { - this.setType(type); + setType(selectType); }} - onChange={(value: string) => { - this.setState( - { [name]: value } as unknown as SpatialControlState, - this.onChange, - ); + onChange={(selectValue: string) => { + setterMap[name](selectValue); }} /> ); - } + }; - renderReverseCheckbox(): ReactNode { - return ( - - {t('Reverse lat/long ')} - - - ); - } + const renderReverseCheckbox = (): ReactNode => ( + + {t('Reverse lat/long ')} + + + ); - renderPopoverContent(): ReactNode { - return ( -
- this.setType(spatialTypes.latlong)} - > - - - {t('Longitude')} - {this.renderSelect('lonCol', spatialTypes.latlong)} - - - {t('Latitude')} - {this.renderSelect('latCol', spatialTypes.latlong)} - - - - this.setType(spatialTypes.delimited)} - > - - - {t('Column')} - {this.renderSelect('lonlatCol', spatialTypes.delimited)} - - - {this.renderReverseCheckbox()} - - - - this.setType(spatialTypes.geohash)} - > - - - {t('Column')} - {this.renderSelect('geohashCol', spatialTypes.geohash)} - - - {this.renderReverseCheckbox()} - - - -
- ); - } + const renderPopoverContent = (): ReactNode => ( +
+ setType(spatialTypes.latlong)} + > + + + {t('Longitude')} + {renderSelect('lonCol', spatialTypes.latlong)} + + + {t('Latitude')} + {renderSelect('latCol', spatialTypes.latlong)} + + + + setType(spatialTypes.delimited)} + > + + + {t('Column')} + {renderSelect('lonlatCol', spatialTypes.delimited)} + + + {renderReverseCheckbox()} + + + + setType(spatialTypes.geohash)} + > + + + {t('Column')} + {renderSelect('geohashCol', spatialTypes.geohash)} + + + {renderReverseCheckbox()} + + + +
+ ); - render(): ReactNode { - return ( -
- - - - -
- ); - } + return ( +
+ + + + +
+ ); } diff --git a/superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx b/superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx index 9f71aa32541..46921087eae 100644 --- a/superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx +++ b/superset-frontend/src/explore/components/controls/TextAreaControl.test.tsx @@ -46,7 +46,7 @@ describe('TextArea', () => { }); test('renders a AceEditor when language is specified', async () => { - const props = { ...defaultProps, language: 'markdown' }; + const props = { ...defaultProps, language: 'markdown' as const }; const { container } = render(); expect(screen.queryByRole('textbox')).not.toBeInTheDocument(); await waitFor(() => { @@ -55,7 +55,7 @@ describe('TextArea', () => { }); test('calls onAreaEditorChange when entering in the AceEditor', () => { - const props = { ...defaultProps, language: 'markdown' }; + const props = { ...defaultProps, language: 'markdown' as const }; render(); const textArea = screen.getByRole('textbox'); fireEvent.change(textArea, { target: { value: 'x' } }); diff --git a/superset-frontend/src/explore/components/controls/TextAreaControl.tsx b/superset-frontend/src/explore/components/controls/TextAreaControl.tsx index 6b5c8a86b19..895c6ce5321 100644 --- a/superset-frontend/src/explore/components/controls/TextAreaControl.tsx +++ b/superset-frontend/src/explore/components/controls/TextAreaControl.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component } from 'react'; +import { useCallback, useEffect, useRef, useMemo } from 'react'; import { debounce } from 'lodash'; import { Input, @@ -26,7 +26,7 @@ import { ModalTrigger, } from '@superset-ui/core/components'; import { t } from '@apache-superset/core'; -import { withTheme } from '@apache-superset/core/ui'; +import { useTheme } from '@apache-superset/core/ui'; import 'ace-builds/src-min-noconflict/mode-handlebars'; @@ -38,12 +38,6 @@ interface HotkeyConfig { func: () => void; } -interface ThemeType { - colorBorder: string; - colorBgMask: string; - sizeUnit: number; -} - interface TextAreaControlProps { name?: string; onChange?: (value: string) => void; @@ -74,206 +68,257 @@ interface TextAreaControlProps { tooltipOptions?: Record; hotkeys?: HotkeyConfig[]; debounceDelay?: number | null; - theme?: ThemeType; 'aria-required'?: boolean; value?: string; [key: string]: unknown; } -const defaultProps = { - onChange: () => {}, - initialValue: '', - height: 250, - minLines: 3, - maxLines: 10, - offerEditInModal: true, - readOnly: false, - resize: null, - textAreaStyles: {}, - tooltipOptions: {}, - hotkeys: [], - debounceDelay: null, -}; +function TextAreaControl({ + name, + onChange = () => {}, + initialValue = '', + height = 250, + minLines = 3, + maxLines = 10, + offerEditInModal = true, + language, + aboveEditorSection, + readOnly = false, + resize = null, + textAreaStyles = {}, + tooltipOptions = {}, + hotkeys = [], + debounceDelay = null, + 'aria-required': ariaRequired, + value, + ...restProps +}: TextAreaControlProps) { + const theme = useTheme(); -class TextAreaControl extends Component { - static defaultProps = defaultProps; + const debouncedOnChangeRef = useRef void> + > | null>(null); - debouncedOnChange: - | ReturnType void>> - | undefined; - - constructor(props: TextAreaControlProps) { - super(props); - if (props.debounceDelay && props.onChange) { - this.debouncedOnChange = debounce(props.onChange, props.debounceDelay); - } - } - - componentDidUpdate(prevProps: TextAreaControlProps) { - if ( - this.props.onChange !== prevProps.onChange && - this.props.debounceDelay && - this.props.onChange - ) { - if (this.debouncedOnChange) { - this.debouncedOnChange.cancel(); + // Create or update debounced onChange when dependencies change + useEffect(() => { + if (debounceDelay && onChange) { + if (debouncedOnChangeRef.current) { + debouncedOnChangeRef.current.cancel(); } - this.debouncedOnChange = debounce( - this.props.onChange, - this.props.debounceDelay, - ); - } - } - - handleChange(value: string | { target: { value: string } }) { - const finalValue = typeof value === 'object' ? value.target.value : value; - if (this.debouncedOnChange) { - this.debouncedOnChange(finalValue); + debouncedOnChangeRef.current = debounce(onChange, debounceDelay); } else { - this.props.onChange?.(finalValue); + if (debouncedOnChangeRef.current) { + debouncedOnChangeRef.current.cancel(); + } + debouncedOnChangeRef.current = null; } - } + }, [onChange, debounceDelay]); - componentWillUnmount() { - if (this.debouncedOnChange) { - this.debouncedOnChange.cancel(); - } - } + // Cleanup on unmount + useEffect( + () => () => { + if (debouncedOnChangeRef.current) { + debouncedOnChangeRef.current.cancel(); + } + }, + [], + ); - renderEditor(inModal = false) { - // Exclude props that shouldn't be passed to TextAreaEditor: - // - theme: TextAreaEditor expects theme as a string, not the theme object from withTheme HOC - // - height: ReactAce expects string, we pass number (height is controlled via minLines/maxLines) - // - other control-specific props and explicitly-set props to avoid duplicate/conflicting assignments - const { - theme, - height, - offerEditInModal, - aboveEditorSection, - resize, - textAreaStyles, - tooltipOptions, - hotkeys, - debounceDelay, - language, - initialValue, - readOnly, - name, - onChange, - minLines: minLinesProp, - maxLines: maxLinesProp, - ...editorProps - } = this.props; - const minLines = inModal ? 40 : minLinesProp || 12; - if (language) { - const style: React.CSSProperties = { - border: theme?.colorBorder - ? `1px solid ${theme.colorBorder}` - : undefined, - minHeight: `${minLines}em`, - width: 'auto', - ...textAreaStyles, + const handleChange = useCallback( + (val: string | { target: { value: string } }) => { + const finalValue = typeof val === 'object' ? val.target.value : val; + if (debouncedOnChangeRef.current) { + debouncedOnChangeRef.current(finalValue); + } else { + onChange?.(finalValue); + } + }, + [onChange], + ); + + const onEditorLoad = useCallback( + (editor: { + commands: { + addCommand: (cmd: { + name: string; + bindKey: { win: string; mac: string }; + exec: () => void; + }) => void; }; - if (resize) { - style.resize = resize; - } - if (readOnly) { - style.backgroundColor = theme?.colorBgMask; - } - const onEditorLoad = (editor: { - commands: { - addCommand: (cmd: { - name: string; - bindKey: { win: string; mac: string }; - exec: () => void; - }) => void; - }; - }) => { - hotkeys?.forEach(keyConfig => { - editor.commands.addCommand({ - name: keyConfig.name, - bindKey: { win: keyConfig.key, mac: keyConfig.key }, - exec: keyConfig.func, - }); + }) => { + hotkeys?.forEach(keyConfig => { + editor.commands.addCommand({ + name: keyConfig.name, + bindKey: { win: keyConfig.key, mac: keyConfig.key }, + exec: keyConfig.func, }); - }; - const codeEditor = ( + }); + }, + [hotkeys], + ); + + const renderEditor = useCallback( + (inModal = false) => { + const effectiveMinLines = inModal ? 40 : minLines || 12; + + if (language) { + const style: React.CSSProperties = { + border: theme?.colorBorder + ? `1px solid ${theme.colorBorder}` + : undefined, + minHeight: `${effectiveMinLines}em`, + width: 'auto', + ...textAreaStyles, + }; + + if (resize) { + style.resize = resize; + } + + if (readOnly) { + style.backgroundColor = theme?.colorBgMask; + } + + const codeEditor = ( +
+ +
+ ); + + if (tooltipOptions && Object.keys(tooltipOptions).length > 0) { + return {codeEditor}; + } + return codeEditor; + } + + const textArea = (
-
); - if (tooltipOptions) { - return {codeEditor}; + if (tooltipOptions && Object.keys(tooltipOptions).length > 0) { + return {textArea}; } - return codeEditor; - } + return textArea; + }, + [ + minLines, + maxLines, + language, + theme, + textAreaStyles, + resize, + readOnly, + onEditorLoad, + initialValue, + name, + restProps, + handleChange, + tooltipOptions, + height, + ariaRequired, + ], + ); - const textArea = ( -
- -
- ); - if (this.props.tooltipOptions) { - return {textArea}; - } - return textArea; - } + // Extract only ControlHeader-compatible props from restProps + const { + label, + description, + validationErrors, + renderTrigger, + rightNode, + leftNode, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + } = restProps as Record; - renderModalBody() { - return ( + const controlHeader = useMemo( + () => ( + void) | undefined} + hovered={hovered as boolean | undefined} + tooltipOnClick={tooltipOnClick as (() => void) | undefined} + warning={warning as string | undefined} + danger={danger as string | undefined} + /> + ), + [ + name, + label, + description, + validationErrors, + renderTrigger, + rightNode, + leftNode, + onClick, + hovered, + tooltipOnClick, + warning, + danger, + ], + ); + + const modalBody = useMemo( + () => ( <> -
{this.props.aboveEditorSection}
- {this.renderEditor(true)} +
{aboveEditorSection}
+ {renderEditor(true)} - ); - } + ), + [aboveEditorSection, renderEditor], + ); - render() { - const controlHeader = ; - return ( -
- {controlHeader} - {this.renderEditor()} - {this.props.offerEditInModal && ( - - {t('Edit %s in modal', this.props.language)} - - } - modalBody={this.renderModalBody()} - responsive - /> - )} -
- ); - } + return ( +
+ {controlHeader} + {renderEditor()} + {offerEditInModal && ( + + {t('Edit %s in modal', language)} + + } + modalBody={modalBody} + responsive + /> + )} +
+ ); } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export default withTheme(TextAreaControl as any); +export default TextAreaControl; diff --git a/superset-frontend/src/explore/components/controls/TextControl/index.tsx b/superset-frontend/src/explore/components/controls/TextControl/index.tsx index 805d3d90eb7..13d853da9d3 100644 --- a/superset-frontend/src/explore/components/controls/TextControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/TextControl/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component, ChangeEvent } from 'react'; +import { useState, useCallback, useRef, useEffect, ChangeEvent } from 'react'; import { legacyValidateNumber, legacyValidateInteger } from '@superset-ui/core'; import { debounce } from 'lodash'; import ControlHeader from 'src/explore/components/ControlHeader'; @@ -31,8 +31,8 @@ export interface TextControlProps { disabled?: boolean; isFloat?: boolean; isInt?: boolean; - onChange?: (value: T, errors: any) => void; - onFocus?: () => {}; + onChange?: (value: T, errors: string[]) => void; + onFocus?: () => void; placeholder?: string; value?: T | null; controlId?: string; @@ -42,82 +42,111 @@ export interface TextControlProps { showHeader?: boolean; } -export interface TextControlState { - value: string; -} - const safeStringify = (value?: InputValueType | null) => value == null ? '' : String(value); -export default class TextControl< - T extends InputValueType = InputValueType, -> extends Component, TextControlState> { - initialValue?: TextControlProps['value']; +function TextControl({ + name, + label, + description, + disabled, + isFloat, + isInt, + onChange, + onFocus, + placeholder, + value, + controlId, + renderTrigger, + validationErrors, + hovered, + showHeader, +}: TextControlProps) { + const [localValue, setLocalValue] = useState(safeStringify(value)); + const prevValueRef = useRef(value); - constructor(props: TextControlProps) { - super(props); - this.initialValue = props.value; - this.state = { - value: safeStringify(this.initialValue), - }; + const handleChange = useCallback( + (inputValue: string) => { + let parsedValue: InputValueType = inputValue; + const errors: string[] = []; + + if (inputValue !== '' && isFloat) { + const error = legacyValidateNumber(inputValue); + if (error) { + errors.push(error); + } else { + parsedValue = inputValue.match(/.*([.0])$/g) + ? inputValue + : parseFloat(inputValue); + } + } + + if (inputValue !== '' && isInt) { + const error = legacyValidateInteger(inputValue); + if (error) { + errors.push(error); + } else { + parsedValue = parseInt(inputValue, 10); + } + } + + onChange?.(parsedValue as T, errors); + }, + [isFloat, isInt, onChange], + ); + + const debouncedOnChangeRef = useRef( + debounce((inputValue: string, changeFn: (val: string) => void) => { + changeFn(inputValue); + }, Constants.FAST_DEBOUNCE), + ); + + useEffect( + () => () => { + debouncedOnChangeRef.current.cancel(); + }, + [], + ); + + const onChangeWrapper = useCallback( + (event: ChangeEvent) => { + const { value: newValue } = event.target; + setLocalValue(newValue); + debouncedOnChangeRef.current(newValue, handleChange); + }, + [handleChange], + ); + + // Sync local value when prop value changes externally + let displayValue = localValue; + if (safeStringify(prevValueRef.current) !== safeStringify(value)) { + prevValueRef.current = value; + displayValue = safeStringify(value); } - onChange = (inputValue: string) => { - let parsedValue: InputValueType = inputValue; - // Validation & casting - const errors = []; - if (inputValue !== '' && this.props.isFloat) { - const error = legacyValidateNumber(inputValue); - if (error) { - errors.push(error); - } else { - parsedValue = inputValue.match(/.*([.0])$/g) - ? inputValue - : parseFloat(inputValue); - } - } - if (inputValue !== '' && this.props.isInt) { - const error = legacyValidateInteger(inputValue); - if (error) { - errors.push(error); - } else { - parsedValue = parseInt(inputValue, 10); - } - } - this.props.onChange?.(parsedValue as T, errors); - }; - - debouncedOnChange = debounce((inputValue: string) => { - this.onChange(inputValue); - }, Constants.FAST_DEBOUNCE); - - onChangeWrapper = (event: ChangeEvent) => { - const { value } = event.target; - this.setState({ value }, () => { - this.debouncedOnChange(value); - }); - }; - - render() { - let { value } = this.state; - if (this.initialValue !== this.props.value) { - this.initialValue = this.props.value; - value = safeStringify(this.props.value); - } - return ( -
- - -
- ); - } + // Note: controlId and showHeader props are not used by ControlHeader + return ( +
+ + +
+ ); } + +export default TextControl; diff --git a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx index 36aa566aba1..76ed41db25f 100644 --- a/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx +++ b/superset-frontend/src/explore/components/controls/TimeSeriesColumnControl/index.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component } from 'react'; +import React, { useCallback, useState } from 'react'; import { Button, Col, @@ -69,23 +69,6 @@ interface TimeSeriesColumnControlState { popoverVisible: boolean; } -const defaultProps = { - label: t('Time series columns'), - tooltip: '', - colType: '', - width: '', - height: '', - timeLag: '', - timeRatio: '', - comparisonType: '', - showYAxis: false, - yAxisBounds: [null, null], - bounds: [null, null], - d3format: '', - dateFormat: '', - sparkType: 'line', -}; - const comparisonTypeOptions = [ { value: 'value', label: t('Actual value'), key: 'value' }, { value: 'diff', label: t('Difference'), key: 'diff' }, @@ -128,97 +111,118 @@ const ButtonBar = styled.div` justify-content: center; `; -export default class TimeSeriesColumnControl extends Component< - TimeSeriesColumnControlProps, - TimeSeriesColumnControlState -> { - static defaultProps = defaultProps; - - constructor(props: TimeSeriesColumnControlProps) { - super(props); - - this.onSave = this.onSave.bind(this); - this.onClose = this.onClose.bind(this); - this.resetState = this.resetState.bind(this); - this.initialState = this.initialState.bind(this); - this.onPopoverVisibleChange = this.onPopoverVisibleChange.bind(this); - - this.state = this.initialState(); - } - - initialState(): TimeSeriesColumnControlState { - return { - label: this.props.label ?? t('Time series columns'), - tooltip: this.props.tooltip ?? '', - colType: this.props.colType ?? '', - width: this.props.width ?? '', - height: this.props.height ?? '', - timeLag: this.props.timeLag ?? 0, - timeRatio: this.props.timeRatio ?? '', - comparisonType: this.props.comparisonType ?? '', - showYAxis: this.props.showYAxis ?? false, - yAxisBounds: this.props.yAxisBounds ?? [null, null], - bounds: this.props.bounds ?? [null, null], - d3format: this.props.d3format ?? '', - dateFormat: this.props.dateFormat ?? '', - sparkType: this.props.sparkType ?? 'line', +function TimeSeriesColumnControl({ + label: propLabel = t('Time series columns'), + tooltip: propTooltip = '', + colType: propColType = '', + width: propWidth = '', + height: propHeight = '', + timeLag: propTimeLag = '', + timeRatio: propTimeRatio = '', + comparisonType: propComparisonType = '', + showYAxis: propShowYAxis = false, + yAxisBounds: propYAxisBounds = [null, null], + bounds: propBounds = [null, null], + d3format: propD3format = '', + dateFormat: propDateFormat = '', + sparkType: propSparkType = 'line', + onChange, +}: TimeSeriesColumnControlProps) { + const getInitialState = useCallback( + (): TimeSeriesColumnControlState => ({ + label: propLabel ?? t('Time series columns'), + tooltip: propTooltip ?? '', + colType: propColType ?? '', + width: propWidth ?? '', + height: propHeight ?? '', + timeLag: propTimeLag ?? 0, + timeRatio: propTimeRatio ?? '', + comparisonType: propComparisonType ?? '', + showYAxis: propShowYAxis ?? false, + yAxisBounds: propYAxisBounds ?? [null, null], + bounds: propBounds ?? [null, null], + d3format: propD3format ?? '', + dateFormat: propDateFormat ?? '', + sparkType: propSparkType ?? 'line', popoverVisible: false, - }; - } + }), + [ + propLabel, + propTooltip, + propColType, + propWidth, + propHeight, + propTimeLag, + propTimeRatio, + propComparisonType, + propShowYAxis, + propYAxisBounds, + propBounds, + propD3format, + propDateFormat, + propSparkType, + ], + ); - resetState() { - const initialState = this.initialState(); - this.setState({ ...initialState }); - } + const [state, setState] = + useState(getInitialState()); - onSave() { - this.props.onChange?.(this.state); - this.setState({ popoverVisible: false }); - } + const resetState = useCallback(() => { + setState(getInitialState()); + }, [getInitialState]); - onClose() { - this.resetState(); - } + const onSave = useCallback(() => { + onChange?.(state); + setState(prev => ({ ...prev, popoverVisible: false })); + }, [onChange, state]); - onSelectChange(attr: string, opt: string) { - this.setState(prevState => ({ ...prevState, [attr]: opt })); - } + const onClose = useCallback(() => { + resetState(); + }, [resetState]); - onTextInputChange(attr: string, event: React.ChangeEvent) { - this.setState(prevState => ({ ...prevState, [attr]: event.target.value })); - } + const onSelectChange = useCallback((attr: string, opt: string) => { + setState(prev => ({ ...prev, [attr]: opt })); + }, []); - onCheckboxChange(attr: string, value: boolean) { - this.setState(prevState => ({ ...prevState, [attr]: value })); - } + const onTextInputChange = useCallback( + (attr: string, event: React.ChangeEvent) => { + setState(prev => ({ ...prev, [attr]: event.target.value })); + }, + [], + ); - onBoundsChange(bounds: (number | null)[]) { - this.setState({ bounds }); - } + const onCheckboxChange = useCallback((attr: string, value: boolean) => { + setState(prev => ({ ...prev, [attr]: value })); + }, []); - onPopoverVisibleChange(popoverVisible: boolean) { - if (popoverVisible) { - this.setState({ popoverVisible }); - } else { - this.resetState(); - } - } + const onBoundsChange = useCallback((bounds: (number | null)[]) => { + setState(prev => ({ ...prev, bounds })); + }, []); - onYAxisBoundsChange(yAxisBounds: (number | null)[]) { - this.setState({ yAxisBounds }); - } + const onPopoverVisibleChange = useCallback( + (popoverVisible: boolean) => { + if (popoverVisible) { + setState(prev => ({ ...prev, popoverVisible })); + } else { + resetState(); + } + }, + [resetState], + ); - textSummary() { - return `${this.props.label ?? ''}`; - } + const onYAxisBoundsChange = useCallback((yAxisBounds: (number | null)[]) => { + setState(prev => ({ ...prev, yAxisBounds })); + }, []); - formRow( - label: string, - tooltip: string, - ttLabel: string, - control: React.ReactNode, - ) { - return ( + const textSummary = useCallback(() => `${propLabel ?? ''}`, [propLabel]); + + const formRow = useCallback( + ( + label: string, + tooltip: string, + ttLabel: string, + control: React.ReactNode, + ) => ( {label} @@ -228,214 +232,241 @@ export default class TimeSeriesColumnControl extends Component< {control} - ); - } + ), + [], + ); + + const renderPopover = useCallback(() => { + const handleLabelChange = (e: React.ChangeEvent) => + onTextInputChange('label', e); + const handleTooltipChange = (e: React.ChangeEvent) => + onTextInputChange('tooltip', e); + const handleColTypeChange = (opt: string) => onSelectChange('colType', opt); + const handleSparkTypeChange = (opt: string) => + onSelectChange('sparkType', opt); + const handleWidthChange = (e: React.ChangeEvent) => + onTextInputChange('width', e); + const handleHeightChange = (e: React.ChangeEvent) => + onTextInputChange('height', e); + const handleTimeLagChange = (e: React.ChangeEvent) => + onTextInputChange('timeLag', e); + const handleTimeRatioChange = (e: React.ChangeEvent) => + onTextInputChange('timeRatio', e); + const handleComparisonTypeChange = (opt: string) => + onSelectChange('comparisonType', opt); + const handleShowYAxisChange = (value: boolean) => + onCheckboxChange('showYAxis', value); + const handleD3formatChange = (e: React.ChangeEvent) => + onTextInputChange('d3format', e); + const handleDateFormatChange = (e: React.ChangeEvent) => + onTextInputChange('dateFormat', e); - renderPopover() { return (
- {this.formRow( + {formRow( t('Label'), t('The column header label'), 'time-lag', , )} - {this.formRow( + {formRow( t('Tooltip'), t('Column header tooltip'), 'col-tooltip', , )} - {this.formRow( + {formRow( t('Type'), t('Type of comparison, value difference or percentage'), 'col-type', , )} - {this.state.colType === 'spark' && - this.formRow( + {state.colType === 'spark' && + formRow( t('Width'), t('Width of the sparkline'), 'spark-width', , )} - {this.state.colType === 'spark' && - this.formRow( + {state.colType === 'spark' && + formRow( t('Height'), t('Height of the sparkline'), 'spark-width', , )} - {['time', 'avg'].indexOf(this.state.colType) >= 0 && - this.formRow( + {['time', 'avg'].indexOf(state.colType) >= 0 && + formRow( t('Time lag'), t( 'Number of periods to compare against. You can use negative numbers to compare from the beginning of the time range.', ), 'time-lag', , )} - {['spark'].indexOf(this.state.colType) >= 0 && - this.formRow( + {['spark'].indexOf(state.colType) >= 0 && + formRow( t('Time ratio'), t('Number of periods to ratio against'), 'time-ratio', , )} - {this.state.colType === 'time' && - this.formRow( + {state.colType === 'time' && + formRow( t('Type'), t('Type of comparison, value difference or percentage'), 'comp-type', , )} - {this.state.colType === 'spark' && - this.formRow( + {state.colType === 'spark' && + formRow( t('Date format'), t('Optional d3 date format string'), 'date-format', , )} - -
); - } + }, [ + state, + formRow, + onTextInputChange, + onSelectChange, + onCheckboxChange, + onBoundsChange, + onYAxisBoundsChange, + onClose, + onSave, + ]); - render() { - return ( - - {this.textSummary()}{' '} - + {textSummary()}{' '} + + ({ + display: 'inline-block', + cursor: 'pointer', + '& svg path': { + fill: theme.colorIcon, + transition: `fill ${theme.motionDurationMid} ease-out`, + }, + '&:hover svg path': { + fill: theme.colorPrimary, + }, + })} > - ({ - display: 'inline-block', - cursor: 'pointer', - '& svg path': { - fill: theme.colorIcon, - transition: `fill ${theme.motionDurationMid} ease-out`, - }, - '&:hover svg path': { - fill: theme.colorPrimary, - }, - })} - > - - - - - ); - } + + + + + ); } + +export default TimeSeriesColumnControl; diff --git a/superset-frontend/src/explore/components/controls/ViewportControl.tsx b/superset-frontend/src/explore/components/controls/ViewportControl.tsx index 9ae1b1169a4..fb8d78b44a0 100644 --- a/superset-frontend/src/explore/components/controls/ViewportControl.tsx +++ b/superset-frontend/src/explore/components/controls/ViewportControl.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { Component, type ReactNode } from 'react'; +import { useCallback, type ReactNode } from 'react'; import { t } from '@apache-superset/core'; import { Popover, FormLabel, Label } from '@superset-ui/core/components'; import { decimalToSexagesimal } from 'geolib'; @@ -55,63 +55,57 @@ interface ViewportControlProps { name: string; } -export default class ViewportControl extends Component { - static defaultProps = { - onChange: () => {}, - default: { type: 'fix', value: 5 }, - value: DEFAULT_VIEWPORT, - }; +export default function ViewportControl({ + onChange = () => {}, + value = DEFAULT_VIEWPORT, + name, + ...restProps +}: ViewportControlProps): JSX.Element { + const handleChange = useCallback( + (ctrl: keyof Viewport, ctrlValue: number): void => { + onChange({ + ...value, + [ctrl]: ctrlValue, + }); + }, + [onChange, value], + ); - onChange = (ctrl: keyof Viewport, value: number): void => { - this.props.onChange?.({ - ...this.props.value!, - [ctrl]: value, - }); - }; + const renderTextControl = (ctrl: keyof Viewport): ReactNode => ( +
+ {ctrl} + handleChange(ctrl, ctrlValue)} + isFloat + /> +
+ ); - renderTextControl(ctrl: keyof Viewport): ReactNode { - return ( -
- {ctrl} - this.onChange(ctrl, value)} - isFloat - /> -
- ); - } + const renderPopover = (): ReactNode => ( +
+ {PARAMS.map(ctrl => renderTextControl(ctrl))} +
+ ); - renderPopover(): ReactNode { - return ( -
- {PARAMS.map(ctrl => this.renderTextControl(ctrl))} -
- ); - } - - renderLabel(): string { - if (this.props.value?.longitude && this.props.value?.latitude) { - return `${decimalToSexagesimal( - this.props.value.longitude, - )} | ${decimalToSexagesimal(this.props.value.latitude)}`; + const renderLabel = (): string => { + if (value?.longitude && value?.latitude) { + return `${decimalToSexagesimal(value.longitude)} | ${decimalToSexagesimal(value.latitude)}`; } return 'N/A'; - } + }; - render(): ReactNode { - return ( -
- - - - -
- ); - } + return ( +
+ + + + +
+ ); } diff --git a/superset-frontend/src/features/home/RightMenu.tsx b/superset-frontend/src/features/home/RightMenu.tsx index 553908f2550..a6f04365999 100644 --- a/superset-frontend/src/features/home/RightMenu.tsx +++ b/superset-frontend/src/features/home/RightMenu.tsx @@ -16,7 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import { useState, useEffect, FC, PureComponent, useMemo } from 'react'; +import { + useState, + useEffect, + FC, + useMemo, + ReactNode, + Component, + ErrorInfo, +} from 'react'; import rison from 'rison'; import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; @@ -533,11 +541,11 @@ const RightMenu = ({ style: { height: 'auto', minHeight: 'auto' }, label: (
css` - font-size: ${theme.fontSizeSM}px; - color: ${theme.colorTextSecondary || theme.colorText}; + css={(themeArg: SupersetTheme) => css` + font-size: ${themeArg.fontSizeSM}px; + color: ${themeArg.colorTextSecondary || themeArg.colorText}; white-space: pre-wrap; - padding: ${theme.sizeUnit}px ${theme.sizeUnit * 2}px; + padding: ${themeArg.sizeUnit}px ${themeArg.sizeUnit * 2}px; `} > {[ @@ -780,23 +788,39 @@ const RightMenuWithQueryWrapper: FC = props => { // Superset still has multiple entry points, and not all of them have // the same setup, and critically, not all of them have the QueryParamProvider. // This wrapper ensures the RightMenu renders regardless of the provider being present. -class RightMenuErrorWrapper extends PureComponent { - state = { - hasError: false, - }; +// Note: Error boundaries require class components in React - there is no hooks equivalent +// for getDerivedStateFromError and componentDidCatch. +interface RightMenuErrorWrapperState { + hasError: boolean; +} - static getDerivedStateFromError() { +// eslint-disable-next-line react-prefer-function-component/react-prefer-function-component -- componentDidCatch requires class component +class RightMenuErrorWrapper extends Component< + RightMenuProps & { children?: ReactNode }, + RightMenuErrorWrapperState +> { + constructor(props: RightMenuProps & { children?: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError(): RightMenuErrorWrapperState { return { hasError: true }; } + componentDidCatch(error: Error, errorInfo: ErrorInfo): void { + console.error('RightMenu error caught:', error, errorInfo); + } + noop = () => {}; render() { + const { children, ...rightMenuProps } = this.props; if (this.state.hasError) { - return ; + return ; } - return this.props.children; + return children; } } diff --git a/superset-frontend/src/pages/ChartCreation/ChartCreation.test.tsx b/superset-frontend/src/pages/ChartCreation/ChartCreation.test.tsx index 58fb5fad213..a62fba22bbd 100644 --- a/superset-frontend/src/pages/ChartCreation/ChartCreation.test.tsx +++ b/superset-frontend/src/pages/ChartCreation/ChartCreation.test.tsx @@ -24,10 +24,8 @@ import { waitFor, } from 'spec/helpers/testing-library'; import fetchMock from 'fetch-mock'; -import { createMemoryHistory } from 'history'; import { ChartCreation } from 'src/pages/ChartCreation'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; -import { supersetTheme } from '@apache-superset/core/ui'; jest.mock('src/components/DynamicPlugins', () => ({ usePluginContext: () => ({ @@ -78,24 +76,20 @@ const mockUserWithDatasetWrite: UserWithPermissionsAndRoles = { username: 'admin', isAnonymous: false, }; -const history = createMemoryHistory(); -history.push = jest.fn(); +const mockHistoryPush = jest.fn(); -const routeProps = { - history, - location: {} as any, - match: {} as any, -}; +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useHistory: () => ({ + push: mockHistoryPush, + }), +})); async function renderComponent(user = mockUser) { + mockHistoryPush.mockClear(); const rendered = render( - null} - theme={supersetTheme} - {...routeProps} - />, + null} />, { useRedux: true, useRouter: true, @@ -169,7 +163,7 @@ test('double-click viz type does nothing if no datasource is selected', async () expect( screen.getByRole('button', { name: 'Create new chart' }), ).toBeDisabled(); - expect(history.push).not.toHaveBeenCalled(); + expect(mockHistoryPush).not.toHaveBeenCalled(); }); test('double-click viz type submits with formatted URL if datasource is selected', async () => { @@ -191,7 +185,7 @@ test('double-click viz type submits with formatted URL if datasource is selected screen.getByRole('button', { name: 'Create new chart' }), ).toBeEnabled(); const formattedUrl = '/explore/?viz_type=table&datasource=table_1__table'; - expect(history.push).toHaveBeenCalledWith(formattedUrl); + expect(mockHistoryPush).toHaveBeenCalledWith(formattedUrl); }); test('dropdown displays matching datasets when user types a search term', async () => { @@ -333,18 +327,10 @@ test('shows loading spinner when dataset parameter is present in URL', async () writable: true, }); - render( - null} - theme={supersetTheme} - {...routeProps} - />, - { - useRedux: true, - useRouter: true, - }, - ); + render( null} />, { + useRedux: true, + useRouter: true, + }); expect(screen.getByRole('status')).toBeInTheDocument(); diff --git a/superset-frontend/src/pages/ChartCreation/index.tsx b/superset-frontend/src/pages/ChartCreation/index.tsx index 2c61fc59d53..3516ba2cc56 100644 --- a/superset-frontend/src/pages/ChartCreation/index.tsx +++ b/superset-frontend/src/pages/ChartCreation/index.tsx @@ -16,15 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { PureComponent, ReactNode } from 'react'; +import { ReactNode, useState, useEffect, useCallback, useMemo } from 'react'; import rison from 'rison'; import { t } from '@apache-superset/core'; import { isDefined, JsonResponse, SupersetClient } from '@superset-ui/core'; -import { styled } from '@apache-superset/core/ui'; -import { withTheme, Theme } from '@emotion/react'; +import { styled, useTheme } from '@apache-superset/core/ui'; import { getUrlParam } from 'src/utils/urlUtils'; import { FilterPlugins, URL_PARAMS } from 'src/constants'; -import { Link, withRouter, RouteComponentProps } from 'react-router-dom'; +import { Link, useHistory } from 'react-router-dom'; import { AsyncSelect, Button, @@ -45,20 +44,11 @@ import { } from 'src/features/datasets/DatasetSelectLabel'; import { Icons } from '@superset-ui/core/components/Icons'; -export interface ChartCreationProps extends RouteComponentProps { +export interface ChartCreationProps { user: UserWithPermissionsAndRoles; addSuccessToast: (arg: string) => void; - theme: Theme; } -export type ChartCreationState = { - datasource?: { label: string | ReactNode; value: string }; - datasetName?: string | string[] | null; - vizType: string | null; - canCreateDataset: boolean; - loading: boolean; -}; - const ESTIMATED_NAV_HEIGHT = 56; const ELEMENTS_EXCEPT_VIZ_GALLERY = ESTIMATED_NAV_HEIGHT + 250; @@ -173,217 +163,214 @@ const StyledStepDescription = styled.div` `} `; -export class ChartCreation extends PureComponent< - ChartCreationProps, - ChartCreationState -> { - constructor(props: ChartCreationProps) { - super(props); - const hasDatasetParam = new URLSearchParams(window.location.search).has( - 'dataset', - ); - this.state = { - vizType: null, - canCreateDataset: findPermission( - 'can_write', - 'Dataset', - props.user.roles, - ), - loading: hasDatasetParam, - }; +export const ChartCreation = ({ + user, + addSuccessToast, +}: ChartCreationProps) => { + const theme = useTheme(); + const history = useHistory(); - this.changeDatasource = this.changeDatasource.bind(this); - this.changeVizType = this.changeVizType.bind(this); - this.gotoSlice = this.gotoSlice.bind(this); - this.loadDatasources = this.loadDatasources.bind(this); - this.onVizTypeDoubleClick = this.onVizTypeDoubleClick.bind(this); - } + const canCreateDataset = useMemo( + () => findPermission('can_write', 'Dataset', user.roles), + [user.roles], + ); - componentDidMount() { - const params = new URLSearchParams(window.location.search).get('dataset'); - if (params) { - this.loadDatasources(params, 0, 1, true) - .then(r => { - const datasource = r.data[0]; - this.setState({ datasource, loading: false }); - }) - .catch(() => { - this.setState({ loading: false }); - }); - this.props.addSuccessToast(t('The dataset has been saved')); - } - } + const hasDatasetParam = useMemo( + () => new URLSearchParams(window.location.search).has('dataset'), + [], + ); - exploreUrl() { + const [datasource, setDatasource] = useState< + { label: string | ReactNode; value: string } | undefined + >(undefined); + const [vizType, setVizType] = useState(null); + const [loading, setLoading] = useState(hasDatasetParam); + + const exploreUrl = useCallback(() => { const dashboardId = getUrlParam(URL_PARAMS.dashboardId); - let url = `/explore/?viz_type=${this.state.vizType}&datasource=${this.state.datasource?.value}`; + let url = `/explore/?viz_type=${vizType}&datasource=${datasource?.value}`; if (isDefined(dashboardId)) { url += `&dashboard_id=${dashboardId}`; } return url; - } + }, [vizType, datasource?.value]); - gotoSlice() { - this.props.history.push(this.exploreUrl()); - } + const gotoSlice = useCallback(() => { + history.push(exploreUrl()); + }, [history, exploreUrl]); - changeDatasource(datasource: { label: string | ReactNode; value: string }) { - this.setState({ datasource }); - } + const changeDatasource = useCallback( + (newDatasource: { label: string | ReactNode; value: string }) => { + setDatasource(newDatasource); + }, + [], + ); - changeVizType(vizType: string | null) { - this.setState({ vizType }); - } + const changeVizType = useCallback((newVizType: string | null) => { + setVizType(newVizType); + }, []); - isBtnDisabled() { - return !(this.state.datasource?.value && this.state.vizType); - } + const isBtnDisabled = useCallback( + () => !(datasource?.value && vizType), + [datasource?.value, vizType], + ); - onVizTypeDoubleClick() { - if (!this.isBtnDisabled()) { - this.gotoSlice(); + const onVizTypeDoubleClick = useCallback(() => { + if (!isBtnDisabled()) { + gotoSlice(); } - } + }, [isBtnDisabled, gotoSlice]); - loadDatasources( - search: string, - page: number, - pageSize: number, - exactMatch = false, - ) { - const query = rison.encode({ - columns: [ - 'id', - 'table_name', - 'datasource_type', - 'database.database_name', - 'schema', - ], - filters: [ - { col: 'table_name', opr: exactMatch ? 'eq' : 'ct', value: search }, - ], - page, - page_size: pageSize, - order_column: 'table_name', - order_direction: 'asc', - }); - return SupersetClient.get({ - endpoint: `/api/v1/dataset/?q=${query}`, - }).then((response: JsonResponse) => { - const list: { - id: number; - label: string | ReactNode; - value: string; - table_name: string; - }[] = response.json.result.map((item: Dataset) => ({ - id: item.id, - value: `${item.id}__${item.datasource_type}`, - label: DatasetSelectLabel(item), - table_name: item.table_name, - })); - return { - data: list, - totalCount: response.json.count, - }; - }); - } + const loadDatasources = useCallback( + (search: string, page: number, pageSize: number, exactMatch = false) => { + const query = rison.encode({ + columns: [ + 'id', + 'table_name', + 'datasource_type', + 'database.database_name', + 'schema', + ], + filters: [ + { col: 'table_name', opr: exactMatch ? 'eq' : 'ct', value: search }, + ], + page, + page_size: pageSize, + order_column: 'table_name', + order_direction: 'asc', + }); + return SupersetClient.get({ + endpoint: `/api/v1/dataset/?q=${query}`, + }).then((response: JsonResponse) => { + const list: { + id: number; + label: string | ReactNode; + value: string; + table_name: string; + }[] = response.json.result.map((item: Dataset) => ({ + id: item.id, + value: `${item.id}__${item.datasource_type}`, + label: DatasetSelectLabel(item), + table_name: item.table_name, + })); + return { + data: list, + totalCount: response.json.count, + }; + }); + }, + [], + ); - render() { - const { theme } = this.props; - const isButtonDisabled = this.isBtnDisabled(); - const VIEW_INSTRUCTIONS_TEXT = t('view instructions'); - const datasetHelpText = this.state.canCreateDataset ? ( - - - {t('Add a dataset')} - {' '} - {t('or')}{' '} - - {`${VIEW_INSTRUCTIONS_TEXT} `} - - - . - - ) : ( - - - {`${VIEW_INSTRUCTIONS_TEXT} `} - - - . - - ); - - if (this.state.loading) { - return ; + useEffect(() => { + const params = new URLSearchParams(window.location.search).get('dataset'); + if (params) { + loadDatasources(params, 0, 1, true) + .then(r => { + const newDatasource = r.data[0]; + setDatasource(newDatasource); + setLoading(false); + }) + .catch(() => { + setLoading(false); + }); + addSuccessToast(t('The dataset has been saved')); } + }, [loadDatasources, addSuccessToast]); - return ( - -

{t('Create a new chart')}

- - {t('Choose a dataset')}} - status={this.state.datasource?.value ? 'finish' : 'process'} - description={ - - - {datasetHelpText} - - } - /> - {t('Choose chart type')}} - status={this.state.vizType ? 'finish' : 'process'} - description={ - - - - } - /> - -
- {isButtonDisabled && ( - - {t('Please select both a Dataset and a Chart type to proceed')} - - )} - -
-
- ); + const isButtonDisabled = isBtnDisabled(); + const VIEW_INSTRUCTIONS_TEXT = t('view instructions'); + const datasetHelpText = canCreateDataset ? ( + + + {t('Add a dataset')} + {' '} + {t('or')}{' '} + + {`${VIEW_INSTRUCTIONS_TEXT} `} + + + . + + ) : ( + + + {`${VIEW_INSTRUCTIONS_TEXT} `} + + + . + + ); + + if (loading) { + return ; } -} -export default withRouter(withToasts(withTheme(ChartCreation))); + return ( + +

{t('Create a new chart')}

+ + {t('Choose a dataset')}} + status={datasource?.value ? 'finish' : 'process'} + description={ + + + {datasetHelpText} + + } + /> + {t('Choose chart type')}} + status={vizType ? 'finish' : 'process'} + description={ + + + + } + /> + +
+ {isButtonDisabled && ( + + {t('Please select both a Dataset and a Chart type to proceed')} + + )} + +
+
+ ); +}; + +export default withToasts(ChartCreation);