chore(lint): convert class components to function components

Convert all remaining React class components to function components
using hooks (useState, useCallback, useEffect, useRef, useMemo) to
satisfy the react-prefer-function-component ESLint rule.

Key changes:
- Converted components in dashboard, explore, SqlLab, and Chart areas
- Updated associated test files with proper typing
- Fixed JSX.Element return types for components used as JSX
- Added explicit ControlHeader props where needed
- Fixed shouldFocus callback signature in WithPopoverMenu usage

Notable exceptions (not converted):
- ErrorBoundary (uses componentDidCatch)
- DragDroppable (react-dnd requires class instances)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Evan Rusackas
2026-02-11 09:15:51 -08:00
parent 63f1d9eb98
commit 5dffbc26ed
76 changed files with 11874 additions and 11869 deletions

View File

@@ -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<Props, State> {
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 (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={this.handleToggle}
>
{`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
</button>
<br />
<br />
{open ? children : null}
</div>
);
}
return (
<div>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={handleToggle}
>
{`${open ? 'Hide' : 'Show'} ${expandableWhat}`}
</button>
<br />
<br />
{open ? children : null}
</div>
);
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { Component, ReactNode } from 'react';
import { useState, useEffect, useCallback, useRef, ReactNode } from 'react';
import { t } from '@apache-superset/core';
import {
SupersetClient,
@@ -36,12 +36,6 @@ export type Props = {
postPayload?: string;
};
type State = {
didVerify: boolean;
error?: Error | SupersetApiError;
payload?: object;
};
export const renderError = (error: Error) => (
<div>
The following error occurred, make sure you have <br />
@@ -54,29 +48,37 @@ export const renderError = (error: Error) => (
</div>
);
export default class VerifyCORS extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { didVerify: false };
this.handleVerify = this.handleVerify.bind(this);
}
export default function VerifyCORS({
children,
endpoint,
host,
method,
postPayload,
}: Props): JSX.Element {
const [didVerify, setDidVerify] = useState(false);
const [error, setError] = useState<Error | SupersetApiError | undefined>(
undefined,
);
const [payload, setPayload] = useState<object | undefined>(undefined);
componentDidUpdate(prevProps: Props) {
const { endpoint, host, postPayload, method } = this.props;
const prevPropsRef = useRef({ endpoint, host, postPayload, method });
useEffect(() => {
const prevProps = prevPropsRef.current;
if (
(this.state.didVerify || this.state.error) &&
(didVerify || error) &&
(prevProps.endpoint !== endpoint ||
prevProps.host !== host ||
prevProps.postPayload !== postPayload ||
prevProps.method !== method)
) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({ didVerify: false, error: undefined });
setDidVerify(false);
setError(undefined);
}
}
prevPropsRef.current = { endpoint, host, postPayload, method };
}, [endpoint, host, postPayload, method, didVerify, error]);
handleVerify() {
const { endpoint, host, postPayload, method } = this.props;
const handleVerify = useCallback(() => {
SupersetClient.reset();
SupersetClient.configure({
credentials: 'include',
@@ -94,43 +96,40 @@ export default class VerifyCORS extends Component<Props, State> {
}
return { error: 'Must provide valid endpoint and payload.' };
})
.then(result =>
this.setState({ didVerify: true, error: undefined, payload: result }),
)
.catch(error => this.setState({ error }));
}
.then(result => {
setDidVerify(true);
setError(undefined);
setPayload(result);
})
.catch(err => setError(err));
}, [endpoint, host, method, postPayload]);
render() {
const { didVerify, error, payload } = this.state;
const { children } = this.props;
return didVerify ? (
children({ payload })
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={this.handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
</div>
)}
return didVerify ? (
<>{children({ payload })}</>
) : (
<div className="row">
<div className="col-md-10">
This example requires CORS requests from this domain. <br />
<br />
1) enable CORS requests in your Superset App from{' '}
{`${window.location.origin}`}
<br />
2) configure your Superset App host name below <br />
3) click below to verify authentication. You may debug CORS further
using the `@superset-ui/connection` story. <br />
<br />
<Button type="primary" size="small" onClick={handleVerify}>
{t('Verify')}
</Button>
<br />
<br />
</div>
);
}
{error && (
<div className="col-md-8">
<ErrorMessage error={error} />
</div>
)}
</div>
);
}

View File

@@ -17,7 +17,7 @@
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { memo, ReactNode } from 'react';
import { isDefined } from '../utils';
@@ -29,7 +29,7 @@ type Props = {
contentWidth?: number;
contentHeight?: number;
height: number;
renderContent: ({
renderContent?: ({
height,
width,
}: {
@@ -39,36 +39,35 @@ type Props = {
width: number;
};
export default class ChartFrame extends PureComponent<Props, {}> {
static defaultProps = {
renderContent() {},
};
function ChartFrame({
contentWidth,
contentHeight,
width,
height,
renderContent = () => null,
}: Props) {
const overflowX = checkNumber(contentWidth) && contentWidth > width;
const overflowY = checkNumber(contentHeight) && contentHeight > height;
render() {
const { contentWidth, contentHeight, width, height, renderContent } =
this.props;
const overflowX = checkNumber(contentWidth) && contentWidth > width;
const overflowY = checkNumber(contentHeight) && contentHeight > height;
if (overflowX || overflowY) {
return (
<div
style={{
height,
overflowX: overflowX ? 'auto' : 'hidden',
overflowY: overflowY ? 'auto' : 'hidden',
width,
}}
>
{renderContent({
height: Math.max(contentHeight ?? 0, height),
width: Math.max(contentWidth ?? 0, width),
})}
</div>
);
}
return renderContent({ height, width });
if (overflowX || overflowY) {
return (
<div
style={{
height,
overflowX: overflowX ? 'auto' : 'hidden',
overflowY: overflowY ? 'auto' : 'hidden',
width,
}}
>
{renderContent({
height: Math.max(contentHeight ?? 0, height),
width: Math.max(contentWidth ?? 0, width),
})}
</div>
);
}
return <>{renderContent({ height, width })}</>;
}
export default memo(ChartFrame);

View File

@@ -17,26 +17,19 @@
* under the License.
*/
import { CSSProperties, ReactNode, PureComponent } from 'react';
import { CSSProperties, ReactNode, memo, useMemo } from 'react';
import { ParentSize } from '@visx/responsive';
const defaultProps = {
className: '',
height: 'auto' as number | string,
position: 'top',
width: 'auto' as number | string,
};
type Props = {
className: string;
className?: string;
debounceTime?: number;
width: number | string;
height: number | string;
width?: number | string;
height?: number | string;
legendJustifyContent?: 'center' | 'flex-start' | 'flex-end';
position: 'top' | 'left' | 'bottom' | 'right';
position?: 'top' | 'left' | 'bottom' | 'right';
renderChart: (dim: { width: number; height: number }) => ReactNode;
renderLegend?: (params: { direction: string }) => ReactNode;
} & Readonly<typeof defaultProps>;
};
const LEGEND_STYLE_BASE: CSSProperties = {
display: 'flex',
@@ -52,95 +45,101 @@ const CHART_STYLE_BASE: CSSProperties = {
position: 'relative',
};
class WithLegend extends PureComponent<Props, {}> {
static defaultProps = defaultProps;
getContainerDirection(): CSSProperties['flexDirection'] {
const { position } = this.props;
if (position === 'left') {
return 'row';
}
if (position === 'right') {
return 'row-reverse';
}
if (position === 'bottom') {
return 'column-reverse';
}
return 'column';
function getContainerDirection(
position: Props['position'],
): CSSProperties['flexDirection'] {
if (position === 'left') {
return 'row';
}
if (position === 'right') {
return 'row-reverse';
}
if (position === 'bottom') {
return 'column-reverse';
}
getLegendJustifyContent() {
const { legendJustifyContent, position } = this.props;
if (legendJustifyContent) {
return legendJustifyContent;
}
if (position === 'left' || position === 'right') {
return 'flex-start';
}
return '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 (
<div className={`with-legend ${className}`} style={style}>
{renderLegend && (
<div className="legend-container" style={legendStyle}>
{renderLegend({
// Pass flexDirection for @vx/legend to arrange legend items
direction: legendDirection,
})}
</div>
)}
<div className="main-container" style={chartStyle}>
<ParentSize debounceTime={debounceTime}>
{(parent: { width: number; height: number }) =>
parent.width > 0 && parent.height > 0
? // Only render when necessary
renderChart(parent)
: null
}
</ParentSize>
</div>
</div>
);
}
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 (
<div className={`with-legend ${className}`} style={style}>
{renderLegend && (
<div className="legend-container" style={legendStyle}>
{renderLegend({
// Pass flexDirection for @vx/legend to arrange legend items
direction: legendDirection,
})}
</div>
)}
<div className="main-container" style={chartStyle}>
<ParentSize debounceTime={debounceTime}>
{(parent: { width: number; height: number }) =>
parent.width > 0 && parent.height > 0
? // Only render when necessary
renderChart(parent)
: null
}
</ParentSize>
</div>
</div>
);
}
export default memo(WithLegend);

View File

@@ -17,31 +17,21 @@
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
const defaultProps = {
className: '',
};
import { memo, ReactNode } from 'react';
type Props = {
className?: string;
children: ReactNode;
} & Readonly<typeof defaultProps>;
};
const CONTAINER_STYLE = { padding: 8 };
class TooltipFrame extends PureComponent<Props, {}> {
static defaultProps = defaultProps;
render() {
const { className, children } = this.props;
return (
<div className={className} style={CONTAINER_STYLE}>
{children}
</div>
);
}
function TooltipFrame({ className = '', children }: Props) {
return (
<div className={className} style={CONTAINER_STYLE}>
{children}
</div>
);
}
export default TooltipFrame;
export default memo(TooltipFrame);

View File

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

View File

@@ -21,8 +21,10 @@ import {
ReactNode,
RefObject,
ComponentType,
PureComponent,
Fragment,
useCallback,
useMemo,
useRef,
} from 'react';
import {
@@ -32,22 +34,19 @@ import {
} from 'react-error-boundary';
import { ParentSize } from '@visx/responsive';
import { createSelector } from 'reselect';
import { withTheme } from '@emotion/react';
import { useTheme } from '@emotion/react';
import { parseLength, Dimension } from '../../dimension';
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
import SuperChartCore, { Props as SuperChartCoreProps } from './SuperChartCore';
import SuperChartCore, {
Props as SuperChartCoreProps,
SuperChartCoreRef,
} from './SuperChartCore';
import DefaultFallbackComponent from './FallbackComponent';
import ChartProps, { ChartPropsConfig } from '../models/ChartProps';
import NoResultsComponent from './NoResultsComponent';
import { isMatrixifyEnabled } from '../types/matrixify';
import MatrixifyGridRenderer from './Matrixify/MatrixifyGridRenderer';
const defaultProps = {
FallbackComponent: DefaultFallbackComponent,
height: 400 as string | number,
width: '100%' as string | number,
enableNoResults: true,
};
import { SupersetTheme } from '@apache-superset/core/ui';
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
@@ -102,215 +101,261 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
inContextMenu?: boolean;
};
type PropsWithDefault = Props & Readonly<typeof defaultProps>;
class SuperChart extends PureComponent<Props, {}> {
function SuperChart({
id,
className,
chartType,
preTransformProps,
overrideTransformProps,
postTransformProps,
onRenderSuccess,
onRenderFailure,
disableErrorBoundary,
FallbackComponent = DefaultFallbackComponent,
onErrorBoundary,
Wrapper,
queriesData,
enableNoResults = true,
noResults,
theme: themeProp,
debounceTime,
height = 400,
width = '100%',
...rest
}: Props): JSX.Element {
/**
* SuperChart's core
* SuperChart's core ref
*/
core?: SuperChartCore | null;
const coreRef = useRef<SuperChartCoreRef | null>(null);
private createChartProps = ChartProps.createSelector();
// Use theme from hook, falling back to prop if provided
const themeFromContext = useTheme() as SupersetTheme;
const theme = themeProp ?? themeFromContext;
private parseDimension = createSelector(
[
({ width }: { width: string | number; height: string | number }) => width,
({ height }) => height,
],
(width, height) => {
// Parse them in case they are % or 'auto'
const widthInfo = parseLength(width);
const heightInfo = parseLength(height);
const boxHeight = heightInfo.isDynamic
? `${heightInfo.multiplier * 100}%`
: heightInfo.value;
const boxWidth = widthInfo.isDynamic
? `${widthInfo.multiplier * 100}%`
: widthInfo.value;
const style = {
height: boxHeight,
width: boxWidth,
};
const createChartProps = useMemo(() => ChartProps.createSelector(), []);
// bounding box will ensure that when one dimension is not dynamic
// e.g. height = 300
// the auto size will be bound to that value instead of being 100% by default
// e.g. height: 300 instead of height: '100%'
const BoundingBox =
widthInfo.isDynamic &&
heightInfo.isDynamic &&
widthInfo.multiplier === 1 &&
heightInfo.multiplier === 1
? Fragment
: ({ children }: { children: ReactNode }) => (
<div style={style}>{children}</div>
);
const parseDimension = useMemo(
() =>
createSelector(
[
({ width: w }: { width: string | number; height: string | number }) =>
w,
({
height: h,
}: {
width: string | number;
height: string | number;
}) => h,
],
(w, h) => {
// Parse them in case they are % or 'auto'
const widthInfo = parseLength(w);
const heightInfo = parseLength(h);
const boxHeight = heightInfo.isDynamic
? `${heightInfo.multiplier * 100}%`
: heightInfo.value;
const boxWidth = widthInfo.isDynamic
? `${widthInfo.multiplier * 100}%`
: widthInfo.value;
const style = {
height: boxHeight,
width: boxWidth,
};
return { BoundingBox, heightInfo, widthInfo };
},
// bounding box will ensure that when one dimension is not dynamic
// e.g. height = 300
// the auto size will be bound to that value instead of being 100% by default
// e.g. height: 300 instead of height: '100%'
const BoundingBox =
widthInfo.isDynamic &&
heightInfo.isDynamic &&
widthInfo.multiplier === 1 &&
heightInfo.multiplier === 1
? Fragment
: ({ children }: { children: ReactNode }) => (
<div style={style}>{children}</div>
);
return { BoundingBox, heightInfo, widthInfo };
},
),
[],
);
static defaultProps = defaultProps;
const setRef = useCallback((core: SuperChartCoreRef | null) => {
coreRef.current = core;
}, []);
private setRef = (core: SuperChartCore | null) => {
this.core = core;
};
const getQueryCount = useCallback(
() => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
[chartType],
);
private getQueryCount = () =>
getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
const renderChart = useCallback(
(chartWidth: number, chartHeight: number) => {
const chartProps = createChartProps({
...rest,
queriesData,
height: chartHeight,
width: chartWidth,
theme,
});
renderChart(width: number, height: number) {
const {
// 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 = (
<MatrixifyGridRenderer
formData={chartProps.rawFormData}
datasource={chartProps.datasource}
width={chartWidth}
height={chartHeight}
hooks={chartProps.hooks}
/>
);
// Apply wrapper if provided
const wrappedChart = Wrapper ? (
<Wrapper width={chartWidth} height={chartHeight}>
{matrixifyChart}
</Wrapper>
) : (
matrixifyChart
);
// Include error boundary unless disabled
return disableErrorBoundary === true ? (
wrappedChart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent
width={chartWidth}
height={chartHeight}
{...props}
/>
)}
onError={onErrorBoundary}
>
{wrappedChart}
</ErrorBoundary>
);
}
// 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}</>
) : (
<NoResultsComponent
id={id}
className={className}
height={chartHeight}
width={chartWidth}
/>
);
} else {
const chartWithoutWrapper = (
<SuperChartCore
ref={setRef}
id={id}
className={className}
chartType={chartType}
chartProps={chartProps}
preTransformProps={preTransformProps}
overrideTransformProps={overrideTransformProps}
postTransformProps={postTransformProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
);
chart = Wrapper ? (
<Wrapper width={chartWidth} height={chartHeight}>
{chartWithoutWrapper}
</Wrapper>
) : (
chartWithoutWrapper
);
}
// Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
chart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent
width={chartWidth}
height={chartHeight}
{...props}
/>
)}
onError={onErrorBoundary}
>
{chart}
</ErrorBoundary>
);
},
[
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 = (
<MatrixifyGridRenderer
formData={chartProps.rawFormData}
datasource={chartProps.datasource}
width={width}
height={height}
hooks={chartProps.hooks}
/>
);
// Apply wrapper if provided
const wrappedChart = Wrapper ? (
<Wrapper width={width} height={height}>
{matrixifyChart}
</Wrapper>
) : (
matrixifyChart
);
// Include error boundary unless disabled
return disableErrorBoundary === true ? (
wrappedChart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent width={width} height={height} {...props} />
)}
onError={onErrorBoundary}
>
{wrappedChart}
</ErrorBoundary>
);
}
// 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 || (
<NoResultsComponent
id={id}
className={className}
height={height}
width={width}
/>
);
} else {
const chartWithoutWrapper = (
<SuperChartCore
ref={this.setRef}
id={id}
className={className}
chartType={chartType}
chartProps={chartProps}
preTransformProps={preTransformProps}
overrideTransformProps={overrideTransformProps}
postTransformProps={postTransformProps}
onRenderSuccess={onRenderSuccess}
onRenderFailure={onRenderFailure}
/>
);
chart = Wrapper ? (
<Wrapper width={width} height={height}>
{chartWithoutWrapper}
</Wrapper>
) : (
chartWithoutWrapper
);
}
// Include the error boundary by default unless it is specifically disabled.
return disableErrorBoundary === true ? (
chart
) : (
<ErrorBoundary
FallbackComponent={props => (
<FallbackComponent width={width} height={height} {...props} />
)}
onError={onErrorBoundary}
>
{chart}
</ErrorBoundary>
// If any of the dimension is dynamic, get parent's dimension
if (widthInfo.isDynamic || heightInfo.isDynamic) {
return (
<BoundingBox>
<ParentSize debounceTime={debounceTime}>
{({ width: parentWidth, height: parentHeight }) =>
renderChart(
widthInfo.isDynamic ? Math.floor(parentWidth) : widthInfo.value,
heightInfo.isDynamic
? Math.floor(parentHeight)
: heightInfo.value,
)
}
</ParentSize>
</BoundingBox>
);
}
render() {
const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
this.props as PropsWithDefault,
);
// If any of the dimension is dynamic, get parent's dimension
if (widthInfo.isDynamic || heightInfo.isDynamic) {
const { debounceTime } = this.props;
return (
<BoundingBox>
<ParentSize debounceTime={debounceTime}>
{({ width, height }) =>
this.renderChart(
widthInfo.isDynamic ? Math.floor(width) : widthInfo.value,
heightInfo.isDynamic ? Math.floor(height) : heightInfo.value,
)
}
</ParentSize>
</BoundingBox>
);
}
return this.renderChart(widthInfo.value, heightInfo.value);
}
return renderChart(widthInfo.value, heightInfo.value);
}
export default withTheme(SuperChart);
export default SuperChart;

View File

@@ -17,8 +17,13 @@
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props */
import { PureComponent } from 'react';
import {
forwardRef,
useCallback,
useImperativeHandle,
useMemo,
useRef,
} from 'react';
import { t } from '@apache-superset/core';
import { createSelector } from 'reselect';
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
@@ -39,16 +44,6 @@ function IDENTITY<T>(x: T) {
const EMPTY = () => null;
const defaultProps = {
id: '',
className: '',
preTransformProps: IDENTITY,
overrideTransformProps: undefined,
postTransformProps: IDENTITY,
onRenderSuccess() {},
onRenderFailure() {},
};
interface LoadingProps {
error: { toString(): string };
}
@@ -78,174 +73,231 @@ export type Props = {
onRenderFailure?: HandlerFunction;
};
export default class SuperChartCore extends PureComponent<Props, {}> {
/**
* The HTML element that wraps all chart content
*/
container?: HTMLElement | null;
export interface SuperChartCoreRef {
container: HTMLElement | null;
}
/**
* memoized function so it will not recompute and return previous value
* unless one of
* - preTransformProps
* - chartProps
* is changed.
*/
preSelector = createSelector(
[
(input: {
const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
function SuperChartCore(
{
id = '',
className = '',
chartProps = BLANK_CHART_PROPS,
chartType,
preTransformProps = IDENTITY,
overrideTransformProps,
postTransformProps = IDENTITY,
onRenderSuccess = () => {},
onRenderFailure = () => {},
},
ref,
) {
const containerRef = useRef<HTMLElement | null>(null);
// 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 (
<Chart
{...this.processChartProps({
chartProps,
preTransformProps,
transformProps,
postTransformProps,
})}
/>
}) =>
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 (
<div className="alert alert-warning" role="alert">
<strong>{t('ERROR')}</strong>&nbsp;
<code>chartType=&quot;{chartType}&quot;</code> &mdash;
{error.toString()}
</div>
);
}
if (error) {
return (
<div className="alert alert-warning" role="alert">
<strong>{t('ERROR')}</strong>&nbsp;
<code>chartType=&quot;{loadingChartType}&quot;</code> &mdash;
{error.toString()}
</div>
);
}
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 (
<Chart
{...processChartProps({
chartProps: renderChartProps,
preTransformProps: pre,
transformProps,
postTransformProps: post,
})}
/>
);
},
[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<Props, {}> {
}
return (
<div {...containerProps} ref={this.setRef}>
<div {...containerProps} ref={setRef}>
<Renderer
preTransformProps={preTransformProps}
postTransformProps={postTransformProps}
@@ -273,5 +325,7 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
/>
</div>
);
}
}
},
);
export default SuperChartCore;

View File

@@ -17,8 +17,13 @@
* under the License.
*/
// eslint-disable-next-line no-restricted-syntax -- whole React import is required for `reactify.test.tsx` Jest test passing.
import { Component, ComponentClass, WeakValidationMap } from 'react';
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import type {
WeakValidationMap,
ForwardRefExoticComponent,
PropsWithoutRef,
RefAttributes,
} from 'react';
// TODO: Note that id and className can collide between Props and ReactifyProps
// leading to (likely) unexpected behaviors. We should either require Props to not
@@ -49,66 +54,78 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>;
}
export interface ReactifiedComponentRef {
container?: HTMLDivElement;
}
type ReactifiedComponent<Props> = ForwardRefExoticComponent<
PropsWithoutRef<Props & ReactifyProps> & RefAttributes<ReactifiedComponentRef>
> & {
defaultProps?: Partial<Props & ReactifyProps>;
propTypes?: WeakValidationMap<Props & ReactifyProps>;
};
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
): ComponentClass<Props & ReactifyProps> {
class ReactifiedComponent extends Component<Props & ReactifyProps> {
container?: HTMLDivElement;
): ReactifiedComponent<Props> {
const ReactifiedComponent = forwardRef<
ReactifiedComponentRef,
Props & ReactifyProps
>(function ReactifiedComponent(props, ref) {
const containerRef = useRef<HTMLDivElement>(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 <div ref={this.setContainerRef} id={id} className={className} />;
}
}
const ReactifiedClass: ComponentClass<Props & ReactifyProps> =
ReactifiedComponent;
return <div ref={containerRef} id={id} className={className} />;
});
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<Props>;
}

View File

@@ -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<string, never>,
NumberFormatValidatorState
> {
state: NumberFormatValidatorState = {
formatString: '.3~s',
testValues: [
987654321,
12345.6789,
3000,
400.14,
70.00002,
1,
0,
-1,
-70.00002,
-400.14,
-3000,
-12345.6789,
-987654321,
Number.POSITIVE_INFINITY,
Number.NEGATIVE_INFINITY,
NaN,
null,
undefined,
],
};
function NumberFormatValidator() {
const [formatString, setFormatString] = useState('.3~s');
constructor(props: Record<string, never>) {
super(props);
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
this.handleFormatChange = this.handleFormatChange.bind(this);
}
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format
string. See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/number-format</code> package enriches{' '}
<code>d3-format</code>
to handle invalid formats as well as edge case values. Use the
validator below to preview outputs from the specified format string.
See
<a
href="https://github.com/d3/d3-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Format Reference
</a>
for how to write a D3 format string.
</p>
</div>
</div>
);
}
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (number)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{`${v}`}</code>
</td>
<td>
<code>&quot;{formatNumber(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default {

View File

@@ -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<string, never>,
TimeFormatValidatorState
> {
state: TimeFormatValidatorState = {
formatString: '%Y-%m-%d %H:%M:%S',
testValues: [
new Date(Date.UTC(1986, 5, 14, 8, 30, 53)),
new Date(Date.UTC(2001, 9, 27, 13, 45, 2, 678)),
new Date(Date.UTC(2009, 1, 1, 0, 0, 0)),
new Date(Date.UTC(2018, 1, 1, 10, 20, 33)),
0,
null,
undefined,
],
};
function TimeFormatValidator() {
const [formatString, setFormatString] = useState('%Y-%m-%d %H:%M:%S');
constructor(props: Record<string, never>) {
super(props);
this.handleFormatChange = this.handleFormatChange.bind(this);
}
const handleFormatChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setFormatString(event.target.value);
},
[],
);
handleFormatChange(event: React.ChangeEvent<HTMLInputElement>) {
this.setState({
formatString: event.target.value,
});
}
render() {
const { formatString, testValues } = this.state;
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={this.handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>
{v instanceof Date ? v.toUTCString() : `${v}`}
</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
return (
<div className="container">
<div className="row" style={{ margin: '40px 20px 0 20px' }}>
<div className="col-sm">
<p>
This <code>@superset-ui/time-format</code> package enriches
<code>d3-time-format</code> to handle invalid formats as well as
edge case values. Use the validator below to preview outputs from
the specified format string. See &nbsp;
<a
href="https://github.com/d3/d3-time-format#locale_format"
target="_blank"
rel="noopener noreferrer"
>
D3 Time Format Reference
</a>
&nbsp;for how to write a D3 time format string.
</p>
</div>
</div>
);
}
<div className="row" style={{ margin: '10px 0 30px 0' }}>
<div className="col-sm" />
<div className="col-sm-8">
<div className="form">
<div className="form-group">
{/* eslint-disable-next-line jsx-a11y/label-has-associated-control */}
<label>
Enter D3 time format string:
<input
id="formatString"
className="form-control form-control-lg"
type="text"
value={formatString}
onChange={handleFormatChange}
/>
</label>
</div>
</div>
</div>
<div className="col-sm" />
</div>
<div className="row">
<div className="col-sm">
<table className="table table-striped table-sm">
<thead>
<tr>
<th>Input (time)</th>
<th>Formatted output (string)</th>
</tr>
</thead>
<tbody>
{testValues.map((v, index) => (
<tr key={index}>
<td>
<code>{v instanceof Date ? v.toUTCString() : `${v}`}</code>
</td>
<td>
<code>&quot;{formatTime(formatString, v)}&quot;</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
);
}
export default {

View File

@@ -227,15 +227,27 @@ describe('SuperChartCore', () => {
});
});
describe('.processChartProps()', () => {
test('use identity functions for unspecified transforms', () => {
const chart = new SuperChartCore({
chartType: ChartKeys.DILIGENT,
describe('processChartProps behavior', () => {
test('passes through chartProps unchanged when no transforms are specified', async () => {
// When no pre/post transform props are specified, the identity function is used
// which means chartProps should pass through to the chart unchanged.
// We verify this by checking that the chart renders correctly without transforms.
const chartProps2 = new ChartProps({
queriesData: [{ message: 'identity-test' }],
theme: supersetTheme,
});
const chartProps2 = new ChartProps();
expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
chartProps2,
render(
<SuperChartCore
chartType={ChartKeys.DILIGENT}
chartProps={chartProps2}
overrideTransformProps={props => props.queriesData[0]}
/>,
);
await waitFor(() => {
expect(screen.getByText('identity-test')).toBeInTheDocument();
});
});
});
});

View File

@@ -19,9 +19,9 @@
import '@testing-library/jest-dom';
import PropTypes from 'prop-types';
import { PureComponent } from 'react';
import { useEffect, useState } from 'react';
import { reactify } from '@superset-ui/core';
import { render, screen } from '@testing-library/react';
import { render, screen, waitFor } from '@testing-library/react';
import { RenderFuncType } from '../../../src/chart/components/reactify';
describe('reactify(renderFn)', () => {
@@ -52,48 +52,36 @@ describe('reactify(renderFn)', () => {
componentWillUnmount: willUnmountCb,
});
class TestComponent extends PureComponent<{}, { content: string }> {
constructor(props = {}) {
super(props);
this.state = { content: 'abc' };
}
function TestComponent() {
const [content, setContent] = useState('abc');
componentDidMount() {
setTimeout(() => {
this.setState({ content: 'def' });
useEffect(() => {
const timer = setTimeout(() => {
setContent('def');
}, 10);
}
return () => clearTimeout(timer);
}, []);
render() {
const { content } = this.state;
return <TheChart id="test" content={content} />;
}
return <TheChart id="test" content={content} />;
}
class AnotherTestComponent extends PureComponent<{}, {}> {
render() {
return <TheChartWithWillUnmountHook id="another_test" />;
}
function AnotherTestComponent() {
return <TheChartWithWillUnmountHook id="another_test" />;
}
test('returns a React component class', () =>
new Promise(done => {
render(<TestComponent />);
test('returns a React component and re-renders on prop changes', async () => {
render(<TestComponent />);
expect(renderFn).toHaveBeenCalledTimes(1);
expect(screen.getByText('abc')).toBeInTheDocument();
expect(screen.getByText('abc').parentNode).toHaveAttribute('id', 'test');
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(<AnotherChart id="test" />);
expect(anotherRenderFn).toHaveBeenCalled();
unmount();
});
test('calls willUnmount hook when it is provided', async () => {
const { unmount } = render(<AnotherTestComponent />);
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
});
test('calls willUnmount hook when it is provided', () =>
new Promise(done => {
const { unmount } = render(<AnotherTestComponent />);
setTimeout(() => {
unmount();
expect(willUnmountCb).toHaveBeenCalledTimes(1);
done(undefined);
}, 20);
}));
});

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
import { PureComponent } from 'react';
import { memo, useMemo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { ensureIsArray } from '@superset-ui/core';
import { styled } from '@apache-superset/core/ui';
@@ -45,18 +44,6 @@ interface HorizonChartProps {
offsetX?: number;
}
const defaultProps: Partial<HorizonChartProps> = {
className: '',
width: 800,
height: 600,
seriesHeight: 20,
bands: Math.floor(DEFAULT_COLORS.length / 2),
colors: DEFAULT_COLORS,
colorScale: 'series',
mode: 'offset',
offsetX: 0,
};
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-horizon {
@@ -80,24 +67,19 @@ const StyledDiv = styled.div`
`}
`;
class HorizonChart extends PureComponent<HorizonChartProps> {
static defaultProps = defaultProps;
render() {
const {
className,
width,
height,
data,
seriesHeight,
bands,
colors,
colorScale,
mode,
offsetX,
} = this.props;
let yDomain: [number, number] | undefined;
function HorizonChart({
className = '',
width = 800,
height = 600,
seriesHeight = 20,
data,
bands = Math.floor(DEFAULT_COLORS.length / 2),
colors = DEFAULT_COLORS,
colorScale = 'series',
mode = 'offset',
offsetX = 0,
}: HorizonChartProps) {
const yDomain = useMemo((): [number, number] | undefined => {
if (colorScale === 'overall') {
const allValues = data.reduce<DataValue[]>(
(acc, current) => acc.concat(current.values),
@@ -106,35 +88,36 @@ class HorizonChart extends PureComponent<HorizonChartProps> {
const rawExtent = d3Extent(allValues, d => d.y);
// Only set yDomain if we have valid min and max values
if (rawExtent[0] != null && rawExtent[1] != null) {
yDomain = [rawExtent[0], rawExtent[1]];
return [rawExtent[0], rawExtent[1]];
}
}
return undefined;
}, [colorScale, data]);
return (
<StyledDiv>
<div
className={`superset-legacy-chart-horizon ${className}`}
style={{ height }}
>
{data.map(row => (
<HorizonRow
key={row.key.join(',')}
width={width}
height={seriesHeight}
title={ensureIsArray(row.key).join(', ')}
data={row.values}
bands={bands}
colors={colors}
colorScale={colorScale}
mode={mode}
offsetX={offsetX}
yDomain={yDomain}
/>
))}
</div>
</StyledDiv>
);
}
return (
<StyledDiv>
<div
className={`superset-legacy-chart-horizon ${className}`}
style={{ height }}
>
{data.map(row => (
<HorizonRow
key={row.key.join(',')}
width={width}
height={seriesHeight}
title={ensureIsArray(row.key).join(', ')}
data={row.values}
bands={bands}
colors={colors}
colorScale={colorScale}
mode={mode}
offsetX={offsetX}
yDomain={yDomain}
/>
))}
</div>
</StyledDiv>
);
}
export default HorizonChart;
export default memo(HorizonChart);

View File

@@ -17,9 +17,7 @@
* under the License.
*/
/* eslint-disable no-continue, no-bitwise */
/* eslint-disable react/jsx-sort-default-props */
/* eslint-disable react/sort-prop-types */
import { PureComponent } from 'react';
import { useRef, useEffect, useCallback, memo } from 'react';
import { extent as d3Extent } from 'd3-array';
import { scaleLinear } from 'd3-scale';
@@ -52,162 +50,140 @@ interface HorizonRowProps {
yDomain?: [number, number];
}
const defaultProps: Partial<HorizonRowProps> = {
className: '',
width: 800,
height: 20,
bands: DEFAULT_COLORS.length >> 1,
colors: DEFAULT_COLORS,
colorScale: 'series',
mode: 'offset',
offsetX: 0,
title: '',
yDomain: undefined,
};
function HorizonRow({
className = '',
width = 800,
height = 20,
data: rawData,
bands = DEFAULT_COLORS.length >> 1,
colors = DEFAULT_COLORS,
colorScale = 'series',
mode = 'offset',
offsetX = 0,
title = '',
yDomain,
}: HorizonRowProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
class HorizonRow extends PureComponent<HorizonRowProps> {
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 (
<div className={`horizon-row ${className}`}>
<span className="title">{title}</span>
<canvas
ref={c => {
this.canvas = c;
}}
width={width}
height={height}
/>
</div>
);
}
return (
<div className={`horizon-row ${className}`}>
<span className="title">{title}</span>
<canvas ref={canvasRef} width={width} height={height} />
</div>
);
}
export default HorizonRow;
export default memo(HorizonRow);

View File

@@ -16,9 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/jsx-sort-default-props, react/sort-prop-types */
/* eslint-disable react/forbid-prop-types, react/require-default-props */
import { Component } from 'react';
import { useState, useCallback, useMemo, memo } from 'react';
import MapGL from 'react-map-gl';
import { WebMercatorViewport } from '@math.gl/web-mercator';
import ScatterPlotGlowOverlay from './ScatterPlotGlowOverlay';
@@ -63,30 +61,24 @@ interface MapBoxProps {
bounds?: [[number, number], [number, number]]; // May be undefined for empty datasets
}
interface MapBoxState {
viewport: Viewport;
}
const defaultProps: Partial<MapBoxProps> = {
width: 400,
height: 400,
globalOpacity: 1,
onViewportChange: NOOP,
pointRadius: DEFAULT_POINT_RADIUS,
pointRadiusUnit: 'Pixels',
};
class MapBox extends Component<MapBoxProps, MapBoxState> {
static defaultProps = defaultProps;
constructor(props: MapBoxProps) {
super(props);
const { width = 400, height = 400, bounds } = this.props;
// Get a viewport that fits the given bounds, which all marks to be clustered.
// Derive lat, lon and zoom from this viewport. This is only done on initial
// render as the bounds don't update as we pan/zoom in the current design.
function MapBox({
width = 400,
height = 400,
aggregatorName,
clusterer,
globalOpacity = 1,
hasCustomMetric,
mapStyle,
mapboxApiKey,
onViewportChange = NOOP,
pointRadius = DEFAULT_POINT_RADIUS,
pointRadiusUnit = 'Pixels',
renderWhileDragging,
rgb,
bounds,
}: MapBoxProps) {
// Compute initial viewport from bounds
const initialViewport = useMemo((): Viewport => {
let latitude = 0;
let longitude = 0;
let zoom = 1;
@@ -100,92 +92,72 @@ class MapBox extends Component<MapBoxProps, MapBoxState> {
({ latitude, longitude, zoom } = mercator);
}
this.state = {
viewport: {
longitude,
latitude,
zoom,
},
};
this.handleViewportChange = this.handleViewportChange.bind(this);
}
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<Viewport>(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 (
<MapGL
const clusters = clusterer.getClusters(bbox, Math.round(viewport.zoom));
const lngLatAccessor = useCallback((location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]] as [number, number];
}, []);
return (
<MapGL
{...viewport}
mapStyle={mapStyle}
width={width}
height={height}
mapboxApiAccessToken={mapboxApiKey}
onViewportChange={handleViewportChange}
preserveDrawingBuffer
>
<ScatterPlotGlowOverlay
{...viewport}
mapStyle={mapStyle}
width={width}
height={height}
mapboxApiAccessToken={mapboxApiKey}
onViewportChange={this.handleViewportChange}
preserveDrawingBuffer
>
<ScatterPlotGlowOverlay
{...viewport}
isDragging={isDragging}
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
lngLatAccessor={(location: GeoJSONLocation) => {
const { coordinates } = location.geometry;
return [coordinates[0], coordinates[1]];
}}
/>
</MapGL>
);
}
isDragging={isDragging}
locations={clusters}
dotRadius={pointRadius}
pointRadiusUnit={pointRadiusUnit}
rgb={rgb}
globalOpacity={globalOpacity}
compositeOperation="screen"
renderWhileDragging={renderWhileDragging}
aggregation={hasCustomMetric ? aggregatorName : undefined}
lngLatAccessor={lngLatAccessor}
/>
</MapGL>
);
}
export default MapBox;
export default memo(MapBox);

View File

@@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable react/require-default-props */
import { PureComponent } from 'react';
import { memo, useCallback } from 'react';
import { CanvasOverlay } from 'react-map-gl';
import { kmToPixels, MILES_PER_KM } from './utils/geo';
import roundDecimal from './utils/roundDecimal';
@@ -61,16 +60,10 @@ interface ScatterPlotGlowOverlayProps {
isDragging?: boolean;
}
const defaultProps: Partial<ScatterPlotGlowOverlayProps> = {
// Same as browser default.
compositeOperation: 'source-over',
dotRadius: 4,
lngLatAccessor: (location: GeoJSONLocation) => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
],
renderWhileDragging: true,
};
const defaultLngLatAccessor = (location: GeoJSONLocation): [number, number] => [
location.geometry.coordinates[0],
location.geometry.coordinates[1],
];
const computeClusterLabel = (
properties: Record<string, number | string | boolean | null | undefined>,
@@ -101,65 +94,293 @@ const computeClusterLabel = (
return count;
};
class ScatterPlotGlowOverlay extends PureComponent<ScatterPlotGlowOverlayProps> {
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<ScatterPlotGlowOverlayProps>
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 <CanvasOverlay redraw={this.redraw} />;
}
return <CanvasOverlay redraw={redraw} />;
}
export default ScatterPlotGlowOverlay;
export default memo(ScatterPlotGlowOverlay);

View File

@@ -17,27 +17,19 @@
* under the License.
*/
/* eslint-disable react/no-array-index-key */
import { PureComponent } from 'react';
import { styled } from '@apache-superset/core/ui';
import TTestTable, { DataEntry } from './TTestTable';
interface PairedTTestProps {
alpha: number;
className: string;
alpha?: number;
className?: string;
data: Record<string, DataEntry[]>;
groups: string[];
liftValPrec: number;
liftValPrec?: number;
metrics: string[];
pValPrec: number;
pValPrec?: number;
}
const defaultProps = {
alpha: 0.05,
className: '',
liftValPrec: 4,
pValPrec: 6,
};
const StyledDiv = styled.div`
${({ theme }) => `
.superset-legacy-chart-paired_ttest .scrollbar-container {
@@ -114,35 +106,36 @@ const StyledDiv = styled.div`
`}
`;
class PairedTTest extends PureComponent<PairedTTestProps> {
static defaultProps = defaultProps;
render() {
const { className, metrics, groups, data, alpha, pValPrec, liftValPrec } =
this.props;
return (
<StyledDiv>
<div className={`superset-legacy-chart-paired-t-test ${className}`}>
<div className="paired-ttest-table">
<div className="scrollbar-content">
{metrics.map((metric, i) => (
<TTestTable
key={i}
metric={metric}
groups={groups}
data={data[metric]}
alpha={alpha}
pValPrec={Math.min(pValPrec, 32)}
liftValPrec={Math.min(liftValPrec, 32)}
/>
))}
</div>
function PairedTTest({
alpha = 0.05,
className = '',
data,
groups,
liftValPrec = 4,
metrics,
pValPrec = 6,
}: PairedTTestProps) {
return (
<StyledDiv>
<div className={`superset-legacy-chart-paired-t-test ${className}`}>
<div className="paired-ttest-table">
<div className="scrollbar-content">
{metrics.map((metric, i) => (
<TTestTable
key={i}
metric={metric}
groups={groups}
data={data[metric]}
alpha={alpha}
pValPrec={Math.min(pValPrec, 32)}
liftValPrec={Math.min(liftValPrec, 32)}
/>
))}
</div>
</div>
</StyledDiv>
);
}
</div>
</StyledDiv>
);
}
export default PairedTTest;

View File

@@ -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<TTestTableProps, TTestTableState> {
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) => (
<Th key={i} column={group}>
{group}
</Th>
));
const numGroups = groups.length;
// Columns for p-value, lift-value, and significance (true/false)
columns.push(
<Th key={numGroups + 1} column="pValue">
p-value
</Th>,
);
columns.push(
<Th key={numGroups + 2} column="liftValue">
Lift %
</Th>,
);
columns.push(
<Th key={numGroups + 3} column="significant">
Significant
</Th>,
);
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) => (
<Th key={i} column={group}>
{group}
</Th>
));
const numGroups = groups.length;
// Columns for p-value, lift-value, and significance (true/false)
columns.push(
<Th key={numGroups + 1} column="pValue">
p-value
</Th>,
const rows = data.map((entry, i) => {
const values = groups.map(
(
group,
j, // group names
) => <Td key={j} column={group} data={entry.group[j]} />,
);
columns.push(
<Th key={numGroups + 2} column="liftValue">
Lift %
</Th>,
values.push(
<Td
key={numGroups + 1}
className={getPValueStatus(i)}
column="pValue"
data={pValues[i]}
/>,
);
columns.push(
<Th key={numGroups + 3} column="significant">
Significant
</Th>,
values.push(
<Td
key={numGroups + 2}
className={getLiftStatus(i)}
column="liftValue"
data={liftValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 3}
className={getSignificance(i).toString()}
column="significant"
data={getSignificance(i)}
/>,
);
const rows = data.map((entry, i) => {
const values = groups.map(
(
group,
j, // group names
) => <Td key={j} column={group} data={entry.group[j]} />,
);
values.push(
<Td
key={numGroups + 1}
className={this.getPValueStatus(i)}
column="pValue"
data={pValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 2}
className={this.getLiftStatus(i)}
column="liftValue"
data={liftValues[i]}
/>,
);
values.push(
<Td
key={numGroups + 3}
className={this.getSignificance(i).toString()}
column="significant"
data={this.getSignificance(i)}
/>,
);
return (
<Tr
key={i}
className={i === control ? 'control' : ''}
onClick={this.computeTTest.bind(this, i)}
>
{values}
</Tr>
);
});
// When sorted ascending, 'control' will always be at top
type SortConfigItem =
| string
| { column: string; sortFunction: (a: string, b: string) => number };
const sortConfig: SortConfigItem[] = (groups as SortConfigItem[]).concat([
{
column: 'pValue',
sortFunction: (a: string, b: string) => {
if (a === 'control') {
return -1;
}
if (b === 'control') {
return 1;
}
return 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 (
<div>
<h3>{metric}</h3>
<Table className="table" id={`table_${metric}`} sortable={sortConfig}>
<Thead>{columns}</Thead>
{rows}
</Table>
</div>
<Tr
key={i}
className={i === control ? 'control' : ''}
onClick={() => handleRowClick(i)}
>
{values}
</Tr>
);
}
});
// When sorted ascending, 'control' will always be at top
type SortConfigItem =
| string
| { column: string; sortFunction: (a: string, b: string) => number };
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 (
<div>
<h3>{metric}</h3>
<Table className="table" id={`table_${metric}`} sortable={sortConfig}>
<Thead>{columns}</Thead>
{rows}
</Table>
</div>
);
}
export default TTestTable;

View File

@@ -17,16 +17,14 @@
* under the License.
*/
import { PureComponent } from 'react';
import { memo } from 'react';
import { TableRenderer } from './TableRenderers';
import type { ComponentProps } from 'react';
type PivotTableProps = ComponentProps<typeof TableRenderer>;
class PivotTable extends PureComponent<PivotTableProps> {
render() {
return <TableRenderer {...this.props} />;
}
function PivotTable(props: PivotTableProps) {
return <TableRenderer {...props} />;
}
export default PivotTable;
export default memo(PivotTable);

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import cloudLayout from 'd3-cloud';
import { scaleLinear } from 'd3-scale';
import { seed, CategoricalColorNamespace } from '@superset-ui/core';
@@ -81,18 +81,7 @@ export interface WordCloudProps extends WordCloudVisualProps {
colorScheme: string;
}
export interface WordCloudState {
words: Word[];
scaleFactor: number;
}
const defaultProps: Required<WordCloudVisualProps> = {
encoding: {},
rotation: 'flat',
};
type FullWordCloudProps = WordCloudProps &
typeof defaultProps & { theme: SupersetTheme };
type FullWordCloudProps = WordCloudProps & { theme: SupersetTheme };
const SCALE_FACTOR_STEP = 0.5;
const MAX_SCALE_FACTOR = 3;
@@ -196,61 +185,80 @@ class SimpleEncoder {
}
}
class WordCloud extends PureComponent<FullWordCloudProps, WordCloudState> {
static defaultProps = defaultProps;
function WordCloud({
data,
encoding = {},
width,
height,
rotation = 'flat',
sliceId,
colorScheme,
theme,
}: FullWordCloudProps) {
const [words, setWords] = useState<Word[]>([]);
const [scaleFactor] = useState(1);
const isMountedRef = useRef(true);
isComponentMounted = false;
// Store previous props for comparison
const prevPropsRef = useRef<{
data: PlainObject[];
encoding: Partial<WordCloudEncoding>;
width: number;
height: number;
rotation: RotationType;
} | null>(null);
createEncoder = (encoding?: Partial<WordCloudEncoding>): SimpleEncoder =>
new SimpleEncoder(encoding ?? {}, {
color: this.props.theme.colorTextLabel,
fontFamily: this.props.theme.fontFamily,
fontSize: 20,
fontWeight: 'bold',
text: '',
});
const createEncoder = useCallback(
(enc?: Partial<WordCloudEncoding>): SimpleEncoder =>
new SimpleEncoder(enc ?? {}, {
color: theme.colorTextLabel,
fontFamily: theme.fontFamily,
fontSize: 20,
fontWeight: 'bold',
text: '',
}),
[theme.colorTextLabel, theme.fontFamily],
);
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<FullWordCloudProps, WordCloudState> {
);
const topResults = sortedData.slice(0, topResultsCount);
this.generateCloud(encoder, 1, (words: Word[]) =>
generateCloud(encoder, 1, (cloudWords: Word[]) =>
topResults.every((d: PlainObject) =>
words.find(({ text }) => encoder.getText(d) === text),
cloudWords.find(({ text }) => encoder.getText(d) === text),
),
);
}
}, [data, encoding, createEncoder, generateCloud]);
generateCloud(
encoder: SimpleEncoder,
scaleFactor: number,
isValid: (word: Word[]) => boolean,
) {
const { data, width, height, rotation } = this.props;
// 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 (
<svg
width={width}
height={height}
viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
>
<g>
{words.map(w => (
<text
key={w.text}
fontSize={`${w.size}px`}
fontWeight={w.weight}
fontFamily={w.font}
fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
>
{w.text}
</text>
))}
</g>
</svg>
);
}
const colorFn = CategoricalColorNamespace.getScale(colorScheme);
const viewBoxWidth = width * scaleFactor;
const viewBoxHeight = height * scaleFactor;
return (
<svg
width={width}
height={height}
viewBox={`-${viewBoxWidth / 2} -${viewBoxHeight / 2} ${viewBoxWidth} ${viewBoxHeight}`}
>
<g>
{words.map(w => (
<text
key={w.text}
fontSize={`${w.size}px`}
fontWeight={w.weight}
fontFamily={w.font}
fill={colorFn(encoder.getColor(w as PlainObject), sliceId)}
textAnchor="middle"
transform={`translate(${w.x}, ${w.y}) rotate(${w.rotate})`}
>
{w.text}
</text>
))}
</g>
</svg>
);
}
export default withTheme(WordCloud);

View File

@@ -92,7 +92,7 @@ describe('SqlLab App', () => {
useRedux: true,
store: storeExceedLocalStorage,
});
rerender(<App updated />);
rerender(<App />);
expect(storeExceedLocalStorage.getActions()).toContainEqual(
expect.objectContaining({
type: LOG_EVENT,
@@ -118,7 +118,7 @@ describe('SqlLab App', () => {
useRedux: true,
store: storeExceedLocalStorage,
});
rerender(<App updated />);
rerender(<App />);
expect(storeExceedLocalStorage.getActions()).toContainEqual(
expect.objectContaining({
type: LOG_EVENT,

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { connect } from 'react-redux';
import { Redirect } from 'react-router-dom';
import Mousetrap from 'mousetrap';
@@ -103,59 +103,85 @@ const SqlLabStyles = styled.div`
`};
`;
type PureProps = {
// add this for testing componentDidUpdate spec
updated?: boolean;
};
type AppProps = ReturnType<typeof mergeProps>;
type AppProps = ReturnType<typeof mergeProps> & 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<AppProps, AppState> {
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<AppProps, AppState> {
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 (
<Redirect
to={{
pathname: '/sqllab/history/',
}}
/>
);
}
if (hash && hash === '#search') {
return (
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
<QueryAutoRefresh
queries={queries}
queriesLastUpdate={queriesLastUpdate}
/>
<PopEditorTab>
<AppLayout>
<TabbedSqlEditors />
</AppLayout>
</PopEditorTab>
</SqlLabStyles>
<Redirect
to={{
pathname: '/sqllab/history/',
}}
/>
);
}
return (
<SqlLabStyles data-test="SqlLabApp" className="App SqlLab">
<QueryAutoRefresh
queries={queries}
queriesLastUpdate={queriesLastUpdate}
/>
<PopEditorTab>
<AppLayout>
<TabbedSqlEditors />
</AppLayout>
</PopEditorTab>
</SqlLabStyles>
);
}
function mapStateToProps(state: SqlLabRootState) {
@@ -250,10 +242,8 @@ const mapDispatchToProps = {
function mergeProps(
stateProps: ReturnType<typeof mapStateToProps>,
dispatchProps: typeof mapDispatchToProps,
state: PureProps,
) {
return {
...state,
...stateProps,
actions: dispatchProps,
};

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent } from 'react';
import { useEffect, useCallback, useMemo, useRef } from 'react';
import { EditableTabs } from '@superset-ui/core/components/Tabs';
import { connect } from 'react-redux';
import type { QueryEditor, SqlLabRootState } from 'src/SqlLab/types';
@@ -32,10 +32,10 @@ import SqlEditor from '../SqlEditor';
import SqlEditorTabHeader from '../SqlEditorTabHeader';
const DEFAULT_PROPS = {
queryEditors: [],
queryEditors: [] as QueryEditor[],
offline: false,
saveQueryWarning: null,
scheduleQueryWarning: null,
saveQueryWarning: null as string | null,
scheduleQueryWarning: null as string | null,
};
const StyledEditableTabs = styled(EditableTabs)`
@@ -94,166 +94,192 @@ const userOS = detectOS();
type TabbedSqlEditorsProps = ReturnType<typeof mergeProps>;
class TabbedSqlEditors extends PureComponent<TabbedSqlEditorsProps> {
constructor(props: TabbedSqlEditorsProps) {
super(props);
this.removeQueryEditor = this.removeQueryEditor.bind(this);
this.handleSelect = this.handleSelect.bind(this);
this.handleEdit = this.handleEdit.bind(this);
}
function TabbedSqlEditors({
actions,
queryEditors = DEFAULT_PROPS.queryEditors,
queries,
tabHistory,
displayLimit,
offline = DEFAULT_PROPS.offline,
defaultQueryLimit,
maxRow,
saveQueryWarning = DEFAULT_PROPS.saveQueryWarning,
scheduleQueryWarning = DEFAULT_PROPS.scheduleQueryWarning,
}: TabbedSqlEditorsProps) {
const activeQueryEditor = useMemo(() => {
if (tabHistory.length === 0) {
return queryEditors[0];
}
const qeid = tabHistory[tabHistory.length - 1];
return queryEditors.find(qe => qe.id === qeid) || null;
}, [tabHistory, queryEditors]);
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: <SqlEditorTabHeader queryEditor={qe} />,
children: (
<SqlEditor
queryEditor={qe}
defaultQueryLimit={defaultQueryLimit}
maxRow={maxRow}
displayLimit={displayLimit}
saveQueryWarning={saveQueryWarning}
scheduleQueryWarning={scheduleQueryWarning}
/>
),
})),
[
queryEditors,
defaultQueryLimit,
maxRow,
displayLimit,
saveQueryWarning,
scheduleQueryWarning,
],
);
const emptyTab = (
<StyledTab>
<TabTitle>{t('Add a new tab')}</TabTitle>
<Tooltip
id="add-tab"
placement="bottom"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
<Icons.PlusCircleOutlined
iconSize="s"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
</Tooltip>
</StyledTab>
);
const emptyTabState = {
key: '0',
label: emptyTab,
children: (
<EmptyState
image="empty_sql_chart.svg"
size="large"
description={t('Add a new tab to create SQL Query')}
/>
),
};
render() {
const editors = this.props.queryEditors?.map(qe => ({
key: qe.id,
label: <SqlEditorTabHeader queryEditor={qe} />,
children: (
<SqlEditor
queryEditor={qe}
defaultQueryLimit={this.props.defaultQueryLimit}
maxRow={this.props.maxRow}
displayLimit={this.props.displayLimit}
saveQueryWarning={this.props.saveQueryWarning}
scheduleQueryWarning={this.props.scheduleQueryWarning}
/>
),
}));
const tabItems = queryEditors?.length > 0 ? editors : [emptyTabState];
const emptyTab = (
<StyledTab>
<TabTitle>{t('Add a new tab')}</TabTitle>
return (
<StyledEditableTabs
activeKey={tabHistory[tabHistory.length - 1]}
id="a11y-query-editor-tabs"
className="SqlEditorTabs"
data-test="sql-editor-tabs"
onChange={handleSelect}
hideAdd={offline}
onTabClick={onTabClicked}
onEdit={handleEdit}
type={queryEditors?.length === 0 ? 'card' : 'editable-card'}
addIcon={
<Tooltip
id="add-tab"
placement="bottom"
placement="left"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
<Icons.PlusCircleOutlined
iconSize="s"
<Icons.PlusOutlined
iconSize="l"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
</Tooltip>
</StyledTab>
);
const emptyTabState = {
key: '0',
label: emptyTab,
children: (
<EmptyState
image="empty_sql_chart.svg"
size="large"
description={t('Add a new tab to create SQL Query')}
/>
),
};
const tabItems =
this.props.queryEditors?.length > 0 ? editors : [emptyTabState];
return (
<StyledEditableTabs
activeKey={this.props.tabHistory[this.props.tabHistory.length - 1]}
id="a11y-query-editor-tabs"
className="SqlEditorTabs"
data-test="sql-editor-tabs"
onChange={this.handleSelect}
hideAdd={this.props.offline}
onTabClick={this.onTabClicked}
onEdit={this.handleEdit}
type={this.props.queryEditors?.length === 0 ? 'card' : 'editable-card'}
addIcon={
<Tooltip
id="add-tab"
placement="left"
title={
userOS === 'Windows'
? t('New tab (Ctrl + q)')
: t('New tab (Ctrl + t)')
}
>
<Icons.PlusOutlined
iconSize="l"
css={css`
vertical-align: middle;
`}
data-test="add-tab-icon"
/>
</Tooltip>
}
items={tabItems}
/>
);
}
}
items={tabItems}
/>
);
}
export function mapStateToProps({ sqlLab, common }: SqlLabRootState) {

View File

@@ -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<ChartProps> = {
addFilter: () => BLANK,
onFilterMenuOpen: () => BLANK,
onFilterMenuClose: () => BLANK,
initialValues: BLANK,
setControlValue: () => BLANK,
triggerRender: false,
dashboardId: undefined,
chartStackTrace: undefined,
force: false,
isInView: true,
};
const Styles = styled.div<{ height: number; width?: number }>`
min-height: ${p => p.height}px;
position: relative;
@@ -183,252 +170,316 @@ const MessageSpan = styled.span`
color: ${({ theme }) => theme.colorText};
`;
class Chart extends PureComponent<ChartProps, {}> {
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<number>(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 (
<Styles
key={chartId}
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
>
<Loading
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
</Styles>
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 (
<Styles
key={chartId}
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
>
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
</Styles>
);
}
return (
<ChartErrorMessage
key={chartId}
chartId={chartId}
error={error}
subtitle={message}
link={queryResponse ? queryResponse.link : undefined}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
stackTrace={chartStackTrace}
/>
);
},
[
chartAlert,
chartId,
chartStackTrace,
dashboardId,
datasetsStatus,
datasource,
height,
],
);
const renderSpinner = useCallback(
(databaseName: string | undefined) => {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingDiv>
<Loading
position="inline-centered"
size={dashboardId ? 's' : 'm'}
muted={!!dashboardId}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingDiv>
);
},
[dashboardId],
);
const renderChartContainer = useCallback(
() => (
<div className="slice_container" data-test="slice-container">
{shouldRenderChart() ? (
<ChartRenderer
annotationData={annotationData}
actions={actions}
chartId={chartId}
datasource={datasource}
initialValues={initialValues}
formData={formData}
height={height}
width={width}
setControlValue={setControlValue}
vizType={vizType}
triggerRender={triggerRender}
chartAlert={chartAlert}
chartStatus={chartStatus}
queriesResponse={queriesResponse}
triggerQuery={triggerQuery}
chartIsStale={chartIsStale}
addFilter={addFilter}
onFilterMenuOpen={onFilterMenuOpen}
onFilterMenuClose={onFilterMenuClose}
ownState={ownState}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
onChartStateChange={onChartStateChange}
latestQueryFormData={latestQueryFormData}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
data-test={vizType}
/>
) : (
<Loading size={dashboardId ? 's' : 'm'} muted={!!dashboardId} />
)}
</div>
),
[
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 (
<ChartErrorMessage
key={chartId}
chartId={chartId}
error={error}
subtitle={message}
link={queryResponse ? queryResponse.link : undefined}
source={dashboardId ? ChartSource.Dashboard : ChartSource.Explore}
stackTrace={chartStackTrace}
<ErrorContainer height={height}>
{queriesResponse?.map(item =>
renderErrorMessage(item as ChartErrorType),
)}
</ErrorContainer>
);
}
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
return (
<EmptyState
size="large"
title={t('Add required control values to preview chart')}
description={getChartRequiredFieldsMissingMessage(true)}
image="chart.svg"
/>
);
}
if (
!isLoading &&
!chartAlert &&
!errorMessage &&
chartIsStale &&
ensureIsArray(queriesResponse).length === 0
) {
return (
<EmptyState
size="large"
title={t('Your chart is ready to go!')}
description={
<span>
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span role="button" tabIndex={0} onClick={onQuery}>
{t('click here')}
</span>
.
</span>
}
image="chart.svg"
/>
);
}
renderSpinner(databaseName: string | undefined) {
const message = databaseName
? t('Waiting on %s', databaseName)
: t('Waiting on database...');
return (
<LoadingDiv>
<Loading
position="inline-centered"
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
<MessageSpan>{message}</MessageSpan>
</LoadingDiv>
);
}
renderChartContainer() {
return (
<div className="slice_container" data-test="slice-container">
{this.shouldRenderChart() ? (
<ChartRenderer
{...this.props}
source={
this.props.dashboardId
? ChartSource.Dashboard
: ChartSource.Explore
}
data-test={this.props.vizType}
/>
) : (
<Loading
size={this.props.dashboardId ? 's' : 'm'}
muted={!!this.props.dashboardId}
/>
)}
</div>
);
}
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 (
<ErrorContainer height={height}>
{queriesResponse?.map(item =>
this.renderErrorMessage(item as ChartErrorType),
)}
</ErrorContainer>
);
}
if (errorMessage && ensureIsArray(queriesResponse).length === 0) {
return (
<EmptyState
size="large"
title={t('Add required control values to preview chart')}
description={getChartRequiredFieldsMissingMessage(true)}
image="chart.svg"
/>
);
}
if (
!isLoading &&
!chartAlert &&
!errorMessage &&
chartIsStale &&
ensureIsArray(queriesResponse).length === 0
) {
return (
<EmptyState
size="large"
title={t('Your chart is ready to go!')}
description={
<span>
{t(
'Click on "Create chart" button in the control panel on the left to preview a visualization or',
)}{' '}
<span role="button" tabIndex={0} onClick={this.props.onQuery}>
{t('click here')}
</span>
.
</span>
}
image="chart.svg"
/>
);
}
return (
<ErrorBoundary
onError={this.handleRenderContainerFailure}
showMessage={false}
return (
<ErrorBoundary onError={handleRenderContainerFailure} showMessage={false}>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
<Styles
data-ui-anchor="chart"
className="chart-container"
data-test="chart-container"
height={height}
width={width}
>
{showSpinner
? this.renderSpinner(databaseName)
: this.renderChartContainer()}
</Styles>
</ErrorBoundary>
);
}
{showSpinner ? renderSpinner(databaseName) : renderChartContainer()}
</Styles>
</ErrorBoundary>
);
}
export default Chart;

View File

@@ -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<ChartRendererProps> = {
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<ChartRendererProps, ChartRendererState> {
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<string, string> | undefined;
labelsColorMap: Record<string, string> | 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<ChartContextMenuRef>;
const suppressContextMenu = getChartMetadataRegistry().get(
formData.viz_type ?? propVizType,
)?.suppressContextMenu;
private hooks: ChartHooks;
const [state, setState] = useState<ChartRendererState>({
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<QueryData[] | null | undefined>(
cloneDeep(queriesResponse),
);
const contextMenuRef = createRef<ChartContextMenuRef>();
private renderStartTime: number;
// Track previous props for shouldComponentUpdate logic
const prevPropsRef = useRef<PrevPropsRef>({
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<ChartContextMenuRef>();
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<HTMLDivElement>): void => {
if (!state.inContextMenu) {
event.preventDefault();
handleOnContextMenu(event.clientX, event.clientY);
}
},
[handleOnContextMenu, state.inContextMenu],
);
const setDataMaskCallback = useCallback(
(dataMask: DataMask) => {
actions?.updateDataMask?.(chartId, dataMask);
},
[actions, chartId],
);
// Hooks object - memoized
const hooks = useMemo<ChartHooks>(
() => ({
onAddFilter: handleAddFilter,
onContextMenu: state.showContextMenu ? handleOnContextMenu : undefined,
onError: handleRenderFailure,
setControlValue: handleSetControlValue,
onFilterMenuOpen,
onFilterMenuClose,
onLegendStateChanged: handleLegendStateChanged,
setDataMask: setDataMaskCallback,
onLegendScroll: handleLegendScroll,
onChartStateChange,
}),
[
handleAddFilter,
handleLegendScroll,
handleLegendStateChanged,
handleOnContextMenu,
handleRenderFailure,
handleSetControlValue,
onChartStateChange,
onFilterMenuClose,
onFilterMenuOpen,
setDataMaskCallback,
state.showContextMenu,
],
);
// shouldComponentUpdate logic - implemented as a useMemo that tracks if we should render
// Note: The return value is not used directly, but the useMemo contains necessary
// side effects (updating refs).
useMemo(() => {
const prevProps = prevPropsRef.current;
const resultsReady =
nextProps.queriesResponse &&
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
!nextProps.queriesResponse?.[0]?.error;
queriesResponse &&
['success', 'rendered'].indexOf(chartStatus as string) > -1 &&
!queriesResponse?.[0]?.error;
if (resultsReady) {
if (!isEqual(this.state, nextState)) {
return true;
}
this.hasQueryResponseChange =
nextProps.queriesResponse !== this.props.queriesResponse;
hasQueryResponseChangeRef.current =
queriesResponse !== prevProps.queriesResponse;
if (this.hasQueryResponseChange) {
this.mutableQueriesResponse = cloneDeep(nextProps.queriesResponse);
if (hasQueryResponseChangeRef.current) {
mutableQueriesResponseRef.current = cloneDeep(queriesResponse);
}
// Check if any matrixify-related properties have changed
const hasMatrixifyChanges = (): boolean => {
const nextFormData = nextProps.formData as JsonObject;
const currentFormData = this.props.formData as JsonObject;
const nextFormData = formData as JsonObject;
const currentFormData = prevProps.formData as JsonObject;
const isMatrixifyEnabled =
nextFormData.matrixify_enable_vertical_layout === true ||
nextFormData.matrixify_enable_horizontal_layout === true;
@@ -289,285 +443,209 @@ class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
);
};
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<HTMLDivElement>): void {
if (!this.state.inContextMenu) {
event.preventDefault();
this.handleOnContextMenu(event.clientX, event.clientY);
}
}
handleLegendScroll(legendIndex: number): void {
this.setState({ legendIndex });
}
render(): ReactNode {
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
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 = (
<EmptyState
size="large"
title={noResultTitle}
description={noResultDescription}
image={noResultImage}
/>
);
} else {
noResultsComponent = (
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
);
}
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
const drillToDetailProps = getChartMetadataRegistry()
.get(vizType)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: this.state.inContextMenu }
: {};
// By pass no result component when server pagination is enabled & the table has:
// - a backend search query, OR
// - non-empty AG Grid filter model
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
const hasAgGridFilters =
ownState?.agGridFilterModel &&
Object.keys(ownState.agGridFilterModel).length > 0;
const currentFormDataExtended = currentFormData as JsonObject;
const bypassNoResult = !(
currentFormDataExtended?.server_pagination &&
(hasSearchText || hasAgGridFilters)
let noResultsComponent: ReactNode;
const noResultTitle = t('No results were returned for this query');
const noResultDescription =
source === ChartSource.Explore
? t(
'Make sure that the controls are configured properly and the datasource contains data for the selected time range',
)
: undefined;
const noResultImage = 'chart.svg';
if (
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
) {
noResultsComponent = (
<EmptyState
size="large"
title={noResultTitle}
description={noResultDescription}
image={noResultImage}
/>
);
return (
<>
{this.state.showContextMenu && (
<ChartContextMenu
ref={this.contextMenuRef}
id={chartId}
formData={currentFormData as QueryFormData}
onSelection={this.handleContextMenuSelected}
onClose={this.handleContextMenuClosed}
/>
)}
<div
onContextMenu={
this.state.showContextMenu ? this.onContextMenuFallback : undefined
}
>
<SuperChart
disableErrorBoundary
key={`${chartId}${webpackHash}`}
id={`chart-id-${chartId}`}
className={chartClassName}
chartType={vizType}
width={width}
height={height}
annotationData={annotationData}
datasource={datasource}
initialValues={initialValues}
formData={currentFormData}
ownState={ownState}
filterState={filterState}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
hooks={this.hooks as any}
behaviors={behaviors}
queriesData={this.mutableQueriesResponse ?? undefined}
onRenderSuccess={this.handleRenderSuccess}
onRenderFailure={this.handleRenderFailure}
noResults={noResultsComponent}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
legendState={this.state.legendState}
enableNoResults={bypassNoResult}
legendIndex={this.state.legendIndex}
isRefreshing={
Boolean(this.props.suppressLoadingSpinner) &&
chartStatus === 'loading'
}
{...drillToDetailProps}
/>
</div>
</>
} else {
noResultsComponent = (
<EmptyState title={noResultTitle} image={noResultImage} size="small" />
);
}
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
const drillToDetailProps = getChartMetadataRegistry()
.get(vizType)
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: state.inContextMenu }
: {};
// By pass no result component when server pagination is enabled & the table has:
// - a backend search query, OR
// - non-empty AG Grid filter model
const hasSearchText = (ownState?.searchText?.length || 0) > 0;
const hasAgGridFilters =
ownState?.agGridFilterModel &&
Object.keys(ownState.agGridFilterModel).length > 0;
const currentFormDataExtended = currentFormData as JsonObject;
const bypassNoResult = !(
currentFormDataExtended?.server_pagination &&
(hasSearchText || hasAgGridFilters)
);
return (
<>
{state.showContextMenu && (
<ChartContextMenu
ref={contextMenuRef}
id={chartId}
formData={currentFormData as QueryFormData}
onSelection={handleContextMenuSelected}
onClose={handleContextMenuClosed}
/>
)}
<div
onContextMenu={
state.showContextMenu ? onContextMenuFallback : undefined
}
>
<SuperChart
disableErrorBoundary
key={`${chartId}${webpackHash}`}
id={`chart-id-${chartId}`}
className={chartClassName}
chartType={vizType}
width={width}
height={height}
annotationData={annotationData}
datasource={datasource}
initialValues={initialValues}
formData={currentFormData}
ownState={ownState}
filterState={filterState}
hooks={hooks as unknown as Parameters<typeof SuperChart>[0]['hooks']}
behaviors={behaviors}
queriesData={mutableQueriesResponseRef.current ?? undefined}
onRenderSuccess={handleRenderSuccess}
onRenderFailure={handleRenderFailure}
noResults={noResultsComponent}
postTransformProps={postTransformProps}
emitCrossFilters={emitCrossFilters}
legendState={state.legendState}
enableNoResults={bypassNoResult}
legendIndex={state.legendIndex}
isRefreshing={
Boolean(restProps.suppressLoadingSpinner) &&
chartStatus === 'loading'
}
{...drillToDetailProps}
/>
</div>
</>
);
}
const ChartRenderer = memo(ChartRendererComponent);
export default ChartRenderer;

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { Component, cloneElement, ReactElement } from 'react';
import { cloneElement, ReactElement, useCallback } from 'react';
import { t } from '@apache-superset/core';
import { css, SupersetTheme } from '@apache-superset/core/ui';
import copyTextToClipboard from 'src/utils/copy';
@@ -24,108 +24,107 @@ import { Tooltip } from '@superset-ui/core/components';
import withToasts from '../MessageToasts/withToasts';
import type { CopyToClipboardProps } from './types';
const defaultProps: Partial<CopyToClipboardProps> = {
copyNode: <span>{t('Copy')}</span>,
onCopyEnd: () => {},
shouldShowText: true,
wrapped: true,
tooltipText: t('Copy to clipboard'),
hideTooltip: false,
};
function CopyToClip({
copyNode = <span>{t('Copy')}</span>,
onCopyEnd = () => {},
shouldShowText = true,
wrapped = true,
tooltipText = t('Copy to clipboard'),
hideTooltip = false,
getText,
text,
addSuccessToast,
addDangerToast,
}: CopyToClipboardProps) {
const copyToClipboard = useCallback(
(textToCopy: Promise<string>) => {
copyTextToClipboard(() => textToCopy)
.then(() => {
addSuccessToast(t('Copied to clipboard!'));
})
.catch(() => {
addDangerToast(
t(
'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
),
);
})
.finally(() => {
if (onCopyEnd) onCopyEnd();
});
},
[addSuccessToast, addDangerToast, onCopyEnd],
);
class CopyToClip extends Component<CopyToClipboardProps> {
static defaultProps = defaultProps;
constructor(props: CopyToClipboardProps) {
super(props);
this.copyToClipboard = this.copyToClipboard.bind(this);
this.onClick = this.onClick.bind(this);
}
onClick() {
if (this.props.getText) {
this.props.getText((d: string) => {
this.copyToClipboard(Promise.resolve(d));
const onClick = useCallback(() => {
if (getText) {
getText((d: string) => {
copyToClipboard(Promise.resolve(d));
});
} else {
this.copyToClipboard(Promise.resolve(this.props.text || ''));
copyToClipboard(Promise.resolve(text || ''));
}
}
}, [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<string>) {
copyTextToClipboard(() => textToCopy)
.then(() => {
this.props.addSuccessToast(t('Copied to clipboard!'));
})
.catch(() => {
this.props.addDangerToast(
t(
'Sorry, your browser does not support copying. Use Ctrl / Cmd + C!',
),
);
})
.finally(() => {
if (this.props.onCopyEnd) this.props.onCopyEnd();
});
}
renderTooltip(cursor: string) {
return (
const renderTooltip = useCallback(
(cursor: string) => (
<>
{!this.props.hideTooltip ? (
{!hideTooltip ? (
<Tooltip
id="copy-to-clipboard-tooltip"
placement="topRight"
style={{ cursor }}
title={this.props.tooltipText || ''}
title={tooltipText || ''}
trigger={['hover']}
arrow={{ pointAtCenter: true }}
>
{this.getDecoratedCopyNode()}
{getDecoratedCopyNode()}
</Tooltip>
) : (
this.getDecoratedCopyNode()
getDecoratedCopyNode()
)}
</>
);
}
),
[hideTooltip, tooltipText, getDecoratedCopyNode],
);
renderNotWrapped() {
return this.renderTooltip('pointer');
}
const renderNotWrapped = useCallback(
() => renderTooltip('pointer'),
[renderTooltip],
);
renderLink() {
return (
const renderLink = useCallback(
() => (
<span css={{ display: 'inline-flex', alignItems: 'center' }}>
{this.props.shouldShowText && this.props.text && (
{shouldShowText && text && (
<span
data-test="short-url"
css={(theme: SupersetTheme) => css`
margin-right: ${theme.sizeUnit}px;
`}
>
{this.props.text}
{text}
</span>
)}
{this.renderTooltip('pointer')}
{renderTooltip('pointer')}
</span>
);
}
),
[shouldShowText, text, renderTooltip],
);
render() {
const { wrapped } = this.props;
if (!wrapped) {
return this.renderNotWrapped();
}
return this.renderLink();
if (!wrapped) {
return renderNotWrapped();
}
return renderLink();
}
export const CopyToClipboard = withToasts(CopyToClip);

View File

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

View File

@@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import { PureComponent, ReactNode } from 'react';
import { ReactNode, useState, useCallback, useEffect, useMemo } from 'react';
import { nanoid } from 'nanoid';
import { t } from '@apache-superset/core';
import { styled, css, SupersetTheme } from '@apache-superset/core/ui';
@@ -33,8 +33,8 @@ import Fieldset from '../Fieldset';
import { recurseReactClone } from '../../utils';
import {
type CRUDCollectionProps,
type CRUDCollectionState,
type Sort,
SortOrder as SortOrderEnum,
} from '../../types';
const CrudButtonWrapper = styled.div`
@@ -52,18 +52,18 @@ const StyledButtonWrapper = styled.span`
`}
`;
type CollectionItem = { id: string | number; [key: string]: any };
type CollectionItem = { id: string | number; [key: string]: unknown };
function createKeyedCollection(arr: Array<object>) {
const collectionArray = arr.map(
(o: any) =>
(o: Record<string, unknown>) =>
({
...o,
id: o.id || nanoid(),
}) as CollectionItem,
);
const collection: Record<PropertyKey, any> = {};
const collection: Record<PropertyKey, CollectionItem> = {};
collectionArray.forEach((o: CollectionItem) => {
collection[o.id] = o;
});
@@ -74,270 +74,291 @@ function createKeyedCollection(arr: Array<object>) {
};
}
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<PropertyKey, boolean>
>({});
const [collection, setCollection] = useState<
Record<PropertyKey, CollectionItem>
>(() => createKeyedCollection(propsCollection).collection);
const [collectionArray, setCollectionArray] = useState<CollectionItem[]>(
() => createKeyedCollection(propsCollection).collectionArray,
);
const [sortColumn, setSortColumn] = useState<string>('');
const [sort, setSort] = useState<SortOrderEnum>(SortOrderEnum.Unsorted);
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<PropertyKey, CollectionItem>,
currentCollectionArray: CollectionItem[],
) => {
// Preserve existing order instead of recreating from Object.keys()
const existingIds = new Set(currentCollectionArray.map(item => item.id));
const newCollectionArray: CollectionItem[] = [];
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<string, FilterValue | null>,
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
) {
// 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<string, FilterValue | null>,
sorter: SorterResult<CollectionItem> | SorterResult<CollectionItem>[],
) => {
const columnSorter = Array.isArray(sorter) ? sorter[0] : sorter;
let newSortColumn = '';
let newSortOrder = SortOrderEnum.Unsorted;
if (columnSorter?.columnKey && columnSorter?.order) {
newSortColumn = columnSorter.columnKey as string;
newSortOrder =
columnSorter.order === 'ascend'
? SortOrderEnum.Asc
: SortOrderEnum.Desc;
}
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<CollectionItem> => {
const columns: ColumnsType<CollectionItem> = tableColumns.map(col => {
const label = getLabel(col);
const tooltip = getTooltip(col);
const isSortable = sortColumns.includes(col);
const currentSortOrder: SortOrder | null | undefined =
this.state.sortColumn === col
? this.state.sort === 1
sortColumn === col
? sort === SortOrderEnum.Asc
? 'ascend'
: this.state.sort === 2
: sort === SortOrderEnum.Desc
? 'descend'
: null
: null;
@@ -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<CollectionItem>;
}
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 (
<>
<CrudButtonWrapper>
{this.props.allowAddItem && (
<StyledButtonWrapper>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={this.onAddItem}
data-test="add-item-button"
>
<Icons.PlusOutlined
iconSize="m"
data-test="crud-add-table-item"
/>
{t('Add item')}
</Button>
</StyledButtonWrapper>
)}
</CrudButtonWrapper>
<Table<CollectionItem>
data-test="crud-table"
columns={tableColumns}
data={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 (
<>
<CrudButtonWrapper>
{allowAddItem && (
<StyledButtonWrapper>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={onAddItem}
data-test="add-item-button"
>
<Icons.PlusOutlined
iconSize="m"
data-test="crud-add-table-item"
/>
{t('Add item')}
</Button>
</StyledButtonWrapper>
)}
</CrudButtonWrapper>
<Table<CollectionItem>
data-test="crud-table"
columns={antdColumns}
data={collectionArray}
rowKey={(record: CollectionItem) => String(record.id)}
sticky={stickyHeader}
pagination={false}
onChange={handleTableChange}
locale={{ emptyText: emptyMessage }}
css={
stickyHeader &&
css`
height: 350px;
overflow: auto;
`
}
expandable={expandableConfig}
size={TableSize.Middle}
tableLayout="auto"
/>
</>
);
}

View File

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

View File

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

View File

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

View File

@@ -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<DashboardProps> {
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<string, unknown> = {
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>(activeFilters ?? {});
const appliedOwnDataChartsRef = useRef<JsonObject>(ownDataCharts ?? {});
const visibilityEventDataRef = useRef<VisibilityEventData>({
start_offset: 0,
ts: 0,
});
const prevLayoutRef = useRef<DashboardLayout>(layout);
const prevDashboardIdRef = useRef<number>(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<DashboardProps> {
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<DashboardProps> {
});
// 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 <Loading />;
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<string, unknown> = {
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 <Loading />;
}
return <>{children}</>;
}
export default Dashboard;

View File

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

View File

@@ -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<HTMLDivElement | null>(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 && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{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 ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={
canEdit && t('You can add the components in the edit mode.')
}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
const shouldDisplayEmptyState = gridComponent?.children?.length === 0;
const shouldDisplayTopLevelTabEmptyState =
shouldDisplayEmptyState && gridComponent?.type === TAB_TYPE;
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={this.setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={this.handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full':
gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={this.handleResizeStart}
onResize={this.handleResize}
onResizeStop={this.handleResizeStop}
onChangeTab={this.handleChangeTab}
const dashboardEmptyState = editMode && (
<EmptyState
title={t('Drag and drop components and charts to the dashboard')}
description={t(
'You can create a new chart or use existing ones from the panel on the right',
)}
size="large"
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
);
const topLevelTabEmptyState = editMode ? (
<EmptyState
title={t('Drag and drop components to this tab')}
size="large"
description={t(
`You can create a new chart or use existing ones from the panel on the right`,
)}
buttonText={
<>
<Icons.PlusOutlined iconSize="m" color={theme.colorPrimary} />
{t('Create a new chart')}
</>
}
buttonAction={() => {
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
});
}}
image="chart.svg"
/>
) : (
<EmptyState
title={t('There are no components added to this tab')}
size="large"
description={canEdit && t('You can add the components in the edit mode.')}
buttonText={canEdit ? t('Edit the dashboard') : undefined}
buttonAction={
canEdit
? () => {
setEditMode?.(true);
}
: undefined
}
image="chart.svg"
/>
);
return width < 100 ? null : (
<>
{shouldDisplayEmptyState && (
<DashboardEmptyStateContainer>
{shouldDisplayTopLevelTabEmptyState
? topLevelTabEmptyState
: dashboardEmptyState}
</DashboardEmptyStateContainer>
)}
<div className="dashboard-grid" ref={setGridRef}>
<GridContent
className="grid-content"
data-test="grid-content"
editMode={editMode}
>
{/* make the area above components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={0}
orientation="column"
onDrop={handleTopDropTargetDrop}
className={classNames({
'empty-droptarget': true,
'empty-droptarget--full': gridComponent?.children?.length === 0,
})}
editMode
dropToChild={gridComponent?.children?.length === 0}
>
{renderDraggableContent}
</Droppable>
)}
{gridComponent?.children?.map((id, index) => (
<Fragment key={id}>
<DashboardComponent
id={id}
parentId={gridComponent.id}
depth={depth + 1}
index={index}
availableColumnCount={GRID_COLUMN_COUNT}
columnWidth={columnWidth}
isComponentVisible={isComponentVisible}
onResizeStart={handleResizeStart}
onResize={handleResize}
onResizeStop={handleResizeStop}
onChangeTab={handleChangeTab}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
{/* make the area below components droppable */}
{editMode && (
<Droppable
component={gridComponent}
depth={depth}
parentComponent={null}
index={index + 1}
orientation="column"
onDrop={handleComponentDrop}
className="empty-droptarget"
editMode
>
{renderDraggableContent}
</Droppable>
)}
</Fragment>
))}
{isResizing &&
Array(GRID_COLUMN_COUNT)
.fill(null)
.map((_, i) => (
<GridColumnGuide
key={`grid-column-${i}`}
className="grid-column-guide"
style={{
left: i * GRID_GUTTER_SIZE + i * columnWidth,
width: columnWidth,
}}
/>
))}
</GridContent>
</div>
</>
);
}
))}
</GridContent>
</div>
</>
);
}
export default withTheme(DashboardGrid);
export default DashboardGrid;

View File

@@ -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<DashboardPublishedStatusType> {
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 (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
/>
</div>
</Tooltip>
);
}
// Show everybody the draft badge
if (!isPublished) {
// if they can edit the dash, make the badge a button
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
title={draftButtonTooltip}
>
<div>
<PublishedLabel
isPublished={isPublished}
onClick={this.togglePublished}
onClick={togglePublished}
/>
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
return (
<Tooltip
id="unpublished-dashboard-tooltip"
placement="bottom"
title={draftDivTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} />
</div>
</Tooltip>
);
}
// Show the published badge for the owner of the dashboard to toggle
if (userCanEdit && userCanSave) {
return (
<Tooltip
id="published-dashboard-tooltip"
placement="bottom"
title={publishedTooltip}
>
<div>
<PublishedLabel isPublished={isPublished} onClick={togglePublished} />
</div>
</Tooltip>
);
}
// Don't show anything if one doesn't own the dashboard and it is published
return null;
}

View File

@@ -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<number>;
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<SliceAdderProps, SliceAdderState> {
private slicesRequest?: AbortController | Promise<void>;
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<AbortController | Promise<void>>();
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<keyof Slice>(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 (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={this.props.editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
}
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 (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > 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 (
<DragDroppable
key={cellData.slice_id}
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={index}
depth={0}
disableDragDrop={isSelected}
editMode={editMode}
// we must use a custom drag preview within the List because
// it does not seem to work within a fixed-position container
useEmptyDragPreview
// List library expect style props here
// actual style should be applied to nested AddSliceCard component
style={{}}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<AddSliceCard
innerRef={dragSourceRef}
style={style}
sliceName={cellData.slice_name}
lastModified={cellData.changed_on_humanized}
visType={cellData.viz_type}
datasourceUrl={cellData.datasource_url}
datasourceName={cellData.datasource_name}
thumbnailUrl={cellData.thumbnail_url}
isSelected={isSelected}
/>
)}
</DragDroppable>
);
},
[filteredSlices, selectedSliceIdsSet, editMode],
);
return (
<div
css={css`
height: 100%;
display: flex;
flex-direction: column;
button > span > :first-of-type {
margin-right: 0;
}
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
showOnlyMyCharts ? t('Filter your charts') : t('Filter charts')
}
className="search-input"
onChange={ev => handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={sortBy}
onChange={handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<div
css={themeObj => 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;
`}
>
<NewChartButtonContainer>
<NewChartButton
buttonStyle="link"
buttonSize="xsmall"
icon={
<Icons.PlusOutlined iconSize="m" iconColor={theme.colorPrimary} />
}
onClick={() =>
navigateTo(`/chart/add?dashboard_id=${this.props.dashboardId}`, {
newWindow: true,
})
}
>
{t('Create new chart')}
</NewChartButton>
</NewChartButtonContainer>
<Controls>
<Input
placeholder={
this.state.showOnlyMyCharts
? t('Filter your charts')
: t('Filter charts')
}
className="search-input"
onChange={ev => this.handleChange(ev.target.value)}
data-test="dashboard-charts-filter-search-input"
/>
<StyledSelect
id="slice-adder-sortby"
value={this.state.sortBy}
onChange={this.handleSelect}
options={Object.entries(KEYS_TO_SORT).map(([key, label]) => ({
label: t('Sort by %s', label),
value: key,
}))}
placeholder={t('Sort by')}
/>
</Controls>
<Checkbox
onChange={e => onShowOnlyMyCharts(e.target.checked)}
checked={showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
</div>
{isLoading && <Loading />}
{!isLoading && filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => filteredSlices[index].slice_id}
>
{rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{errorMessage && (
<div
css={theme => 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;
`}
>
<Checkbox
onChange={e => this.onShowOnlyMyCharts(e.target.checked)}
checked={this.state.showOnlyMyCharts}
/>
{t('Show only my charts')}
<InfoTooltip
placement="top"
tooltip={t(
`You can choose to display all charts that you have access to or only the ones you own.
Your filter selection will be saved and remain active until you choose to change it.`,
)}
/>
{errorMessage}
</div>
{this.props.isLoading && <Loading />}
{!this.props.isLoading && this.state.filteredSlices.length > 0 && (
<ChartList>
<AutoSizer>
{({ height, width }: { height: number; width: number }) => (
<List
width={width}
height={height}
itemCount={this.state.filteredSlices.length}
itemSize={DEFAULT_CELL_HEIGHT}
itemKey={index => this.state.filteredSlices[index].slice_id}
>
{this.rowRenderer}
</List>
)}
</AutoSizer>
</ChartList>
)}
{this.props.errorMessage && (
<div
css={css`
padding: 16px;
`}
>
{this.props.errorMessage}
</div>
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={this.state.filteredSlices} />
</div>
);
}
)}
{/* Drag preview is just a single fixed-position element */}
<AddSliceDragPreview slices={filteredSlices} />
</div>
);
}
export default withTheme(SliceAdder);
export default SliceAdder;

View File

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

View File

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

View File

@@ -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<ModalTriggerRef['current']>(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 (
<ModalTrigger
ref={this.modal}
triggerNode={this.props.triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}
return (
<ModalTrigger
ref={modalRef}
triggerNode={triggerNode}
modalBody={
<FilterScopeModalBody>
<FilterScope {...filterScopeProps} />
</FilterScopeModalBody>
}
width="80%"
/>
);
}

View File

@@ -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<number, DashboardFilter>,
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<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(columns).reduce<FilterScopeMap>(
(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<FilterScopeMap>((map, { chartId: filterId, columns }) => {
const filterScopeByChartId = Object.keys(
columns,
).reduce<FilterScopeMap>((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<string | null>(
() =>
initialized.showSelector
? initialized.initialState.activeFilterField
: null,
);
const [searchText, setSearchText] = useState(() =>
initialized.showSelector ? initialized.initialState.searchText : '',
);
const [filterScopeMap, setFilterScopeMap] = useState<FilterScopeMap>(() =>
initialized.showSelector ? initialized.initialState.filterScopeMap : {},
);
const [filterFieldNodes] = useState<FilterFieldNode[]>(() =>
initialized.showSelector ? initialized.initialState.filterFieldNodes : [],
);
const [checkedFilterFields, setCheckedFilterFields] = useState<string[]>(
() =>
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<FilterScopeTreeNode[]>(
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<FilterScopeTreeNode[]>(
(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<HTMLInputElement>): void {
this.setState({ searchText: e.target.value }, this.filterTree);
}
const onSearchInputChange = useCallback(
(e: ChangeEvent<HTMLInputElement>): 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<string, ReturnType<typeof getFilterScopeFromNodesTree>>
>((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<FilterScopeSelectorStateWithSelector> 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<FilterScopeSelectorStateWithSelector> 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<FilterScopeTreeNode[]>(
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 (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={this.onChangeFilterField}
onCheck={this.onCheckFilterField}
onExpand={this.onExpandFilterField}
/>
);
}
renderFilterScopeTree(): ReactElement {
const state = this.state as FilterScopeSelectorStateWithSelector;
const {
filterScopeMap,
activeFilterField,
checkedFilterFields,
searchText,
} = state;
const renderFilterFieldList = (): ReactElement | null => (
<FilterFieldTree
activeKey={activeFilterField}
nodes={filterFieldNodes}
checked={checkedFilterFields}
expanded={expandedFilterIds}
onClick={onChangeFilterField}
onCheck={onCheckFilterField}
onExpand={onExpandFilterField}
/>
);
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}
/>
<FilterScopeTree
nodes={filterScopeMap[key].nodesFiltered}
checked={filterScopeMap[key].checked}
expanded={filterScopeMap[key].expanded}
onCheck={this.onCheckFilterScope}
onExpand={this.onExpandFilterScope}
onCheck={onCheckFilterScope}
onExpand={onExpandFilterScope}
// pass selectedFilterId prop to FilterScopeTree component,
// to hide checkbox for selected filter field itself
selectedChartId={selectedChartId}
/>
</>
);
}
};
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<
</span>
</div>
);
}
};
render(): ReactElement {
const { showSelector } = this.state;
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && renderEditingFiltersName()}
</ScopeHeader>
return (
<ScopeContainer>
<ScopeHeader>
<h4>{t('Configure filter scopes')}</h4>
{showSelector && this.renderEditingFiltersName()}
</ScopeHeader>
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
<ScopeBody className="filter-scope-body">
{!showSelector ? (
<div className="warning-message">
{t('There are no filters in this dashboard.')}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{renderFilterFieldList()}
</div>
) : (
<ScopeSelector className="filters-scope-selector">
<div className={cx('filter-field-pane multi-edit-mode')}>
{this.renderFilterFieldList()}
</div>
<div className="filter-scope-pane multi-edit-mode">
{this.renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<div className="filter-scope-pane multi-edit-mode">
{renderFilterScopeTree()}
</div>
</ScopeSelector>
)}
</ScopeBody>
<ActionsContainer>
<Button buttonSize="small" onClick={this.onClose}>
{t('Close')}
<ActionsContainer>
<Button buttonSize="small" onClick={onClose}>
{t('Close')}
</Button>
{showSelector && (
<Button buttonSize="small" buttonStyle="primary" onClick={onSave}>
{t('Save')}
</Button>
{showSelector && (
<Button
buttonSize="small"
buttonStyle="primary"
onClick={this.onSave}
>
{t('Save')}
</Button>
)}
</ActionsContainer>
</ScopeContainer>
);
}
)}
</ActionsContainer>
</ScopeContainer>
);
}

View File

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

View File

@@ -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<DividerProps> {
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 (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={this.handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
editMode={editMode}
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<div ref={dragSourceRef}>
{editMode && (
<HoverMenu position="left">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<DividerLine className="dashboard-component dashboard-component-divider" />
</div>
)}
</Draggable>
);
}
export default Divider;
export default memo(Divider);

View File

@@ -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<string, ComponentShape>) => 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<HeaderProps, HeaderState> {
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<string, ComponentShape>);
}
}
} as Record<string, ComponentShape>);
}
},
[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 (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation="row"
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({
dragSourceRef,
}: {
dragSourceRef: React.Ref<HTMLDivElement> | undefined;
}) => (
<div ref={dragSourceRef}>
{editMode &&
depth <= 2 && ( // drag handle looks bad when nested
<HoverMenu position="left">
<DragHandle position="left" />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
menuItems={[
<PopoverDropdown
id={`${component.id}-header-style`}
options={headerStyleOptions}
value={component.meta.headerSize as string}
onChange={this.handleChangeSize}
/>,
<BackgroundStyleDropdown
id={`${component.id}-background`}
value={component.meta.background as string}
onChange={this.handleChangeBackground}
/>,
]}
editMode={editMode}
>
<HeaderStyles
className={cx(
'dashboard-component',
'dashboard-component-header',
headerStyle?.className,
rowStyle?.className,
)}
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={this.handleChangeText}
showTooltip={false}
<EditableTitle
title={component.meta.text}
canEdit={editMode}
onSaveTitle={handleChangeText}
showTooltip={false}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
{!editMode && !embeddedMode && (
<AnchorLink
id={component.id}
dashboardId={Number(dashboardId)}
/>
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
)}
</HeaderStyles>
</WithPopoverMenu>
</div>
)}
</Draggable>
);
}
export default Header;
export default memo(Header);

View File

@@ -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<HTMLElement>;
}
class Markdown extends PureComponent<MarkdownProps, MarkdownState> {
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<string>(
component.meta.code as string,
);
const [editor, setEditorState] = useState<EditorInstance | null>(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<ResizeStartCallback>): 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<ResizeStartCallback>): 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(
() => (
<EditorHost
id={`markdown-editor-${this.props.id}`}
onChange={this.handleMarkdownChange}
id={`markdown-editor-${id}`}
onChange={handleMarkdownChange}
width="100%"
height="100%"
value={
// this allows "select all => 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<MarkdownProps, MarkdownState> {
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(
() => (
<SafeMarkdown
source={
hasError
? MARKDOWN_ERROR_MESSAGE
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
: markdownSource || MARKDOWN_PLACE_HOLDER
}
htmlSanitization={this.props.htmlSanitization}
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
);
}
),
[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(
() => [
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={editorMode}
onChange={handleChangeEditorMode}
/>,
],
[component.id, editorMode, handleChangeEditorMode],
);
const isEditing = editorMode === 'edit';
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={this.handleChangeFocus}
shouldFocus={this.shouldFocusMarkdown}
menuItems={[
<MarkdownModeDropdown
key={`${component.id}-mode`}
id={`${component.id}-mode`}
value={this.state.editorMode}
onChange={this.handleChangeEditorMode}
/>,
]}
editMode={editMode}
return (
<Draggable
component={component}
parentComponent={parentComponent}
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
index={index}
depth={depth}
onDrop={handleComponentDrop}
disableDragDrop={isFocused}
editMode={editMode}
>
{({ dragSourceRef }: DragChildProps) => (
<WithPopoverMenu
onChangeFocus={handleChangeFocus}
shouldFocus={shouldFocusMarkdown}
menuItems={menuItems}
editMode={editMode}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
id={component.id}
>
<MarkdownStyles
data-test="dashboard-markdown-editor"
className={cx(
'dashboard-markdown',
isEditing && 'dashboard-markdown--editing',
)}
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
>
<ResizableContainer
id={component.id}
adjustableWidth={parentComponent.type === ROW_TYPE}
adjustableHeight
widthStep={columnWidth}
widthMultiple={widthMultiple}
heightStep={GRID_BASE_UNIT}
heightMultiple={component.meta.height ?? GRID_MIN_ROW_UNITS}
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
minHeightMultiple={GRID_MIN_ROW_UNITS}
maxWidthMultiple={availableColumnCount + widthMultiple}
onResizeStart={this.handleResizeStart}
onResize={onResize}
onResizeStop={onResizeStop}
editMode={isFocused ? false : editMode}
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
<div
ref={dragSourceRef}
className="dashboard-component dashboard-component-chart-holder"
data-test="dashboard-component-chart-holder"
>
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton
onDelete={this.handleDeleteComponent}
/>
</HoverMenu>
)}
{editMode && isEditing
? this.renderEditMode()
: this.renderPreviewMode()}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
{editMode && (
<HoverMenu position="top">
<DeleteComponentButton onDelete={handleDeleteComponent} />
</HoverMenu>
)}
{editMode && isEditing ? renderEditMode : renderPreviewMode}
</div>
</ResizableContainer>
</MarkdownStyles>
</WithPopoverMenu>
)}
</Draggable>
);
}
interface ReduxState {

View File

@@ -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<DraggableNewComponentProps> {
static defaultProps = {
className: null,
IconComponent: undefined,
};
render() {
const { label, id, type, className, meta, IconComponent } = this.props;
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
function DraggableNewComponent({
label,
id,
type,
className,
meta,
IconComponent,
}: DraggableNewComponentProps) {
return (
<DragDroppable
component={{ type, id, meta }}
parentComponent={{
id: NEW_COMPONENTS_SOURCE_ID,
type: NEW_COMPONENT_SOURCE_TYPE,
}}
index={0}
depth={0}
editMode
>
{({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
<NewComponent ref={dragSourceRef} data-test="new-component">
<NewComponentPlaceholder
className={cx('new-component-placeholder', className)}
>
{IconComponent && <IconComponent iconSize="xl" />}
</NewComponentPlaceholder>
{label}
</NewComponent>
)}
</DragDroppable>
);
}
export default memo(DraggableNewComponent);

View File

@@ -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<BackgroundStyleDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}
export default function BackgroundStyleDropdown({
id,
value,
onChange,
}: BackgroundStyleDropdownProps) {
return (
<PopoverDropdown
id={id}
options={backgroundStyleOptions}
value={value}
onChange={onChange}
renderButton={renderButton}
renderOption={renderOption}
/>
);
}

View File

@@ -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<HTMLDivElement>;
children: ReactNode;
position?: 'left' | 'top';
innerRef?: RefObject<HTMLDivElement> | null;
children?: ReactNode;
onHover?: (data: { isHovered: boolean }) => void;
}
@@ -66,45 +65,41 @@ const HoverStyleOverrides = styled.div`
}
`;
export default class HoverMenu extends PureComponent<HoverMenuProps> {
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 (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={this.handleMouseEnter}
onMouseLeave={this.handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
return (
<HoverStyleOverrides className="hover-menu-container">
<div
ref={innerRef}
className={cx(
'hover-menu',
position === 'left' && 'hover-menu--left',
position === 'top' && 'hover-menu--top',
)}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
data-test="hover-menu"
>
{children}
</div>
</HoverStyleOverrides>
);
}
export default memo(HoverMenu);

View File

@@ -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<MarkdownModeDropdownProps> {
render() {
const { id, value, onChange } = this.props;
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}
export default function MarkdownModeDropdown({
id,
value,
onChange,
}: MarkdownModeDropdownProps) {
return (
<PopoverDropdown
data-test="markdown-mode-dropdown"
id={id}
options={dropdownOptions}
value={value}
onChange={onChange}
/>
);
}

View File

@@ -106,7 +106,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusA}
>
<div id="child-a" />
@@ -117,7 +119,9 @@ test('should unfocus when another component is clicked', async () => {
<WithPopoverMenu
{...props}
editMode
shouldFocus={(event, container) => container?.contains(event.target)}
shouldFocus={(event, container, _menuRef) =>
container?.contains(event.target) ?? false
}
onChangeFocus={onChangeFocusB}
>
<div id="child-b" />

View File

@@ -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<ShouldFocusContainer | null>(null);
const menuRef = useRef<HTMLDivElement | null>(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 (
<WithPopoverMenuStyles
ref={this.setRef}
onClick={this.handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style}
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles ref={this.setMenuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
return (
<WithPopoverMenuStyles
ref={containerRef}
onClick={handleClick}
role="none"
className={cx(
'with-popover-menu',
editMode && isFocused && 'with-popover-menu--focused',
)}
style={style ?? undefined}
>
{children}
{editMode && isFocused && (menuItems?.length ?? 0) > 0 && (
<PopoverMenuStyles ref={menuRef}>
{menuItems.map((node: ReactNode, i: number) => (
<div className="menu-item" key={`menu-item-${i}`}>
{node}
</div>
))}
</PopoverMenuStyles>
)}
</WithPopoverMenuStyles>
);
}
export default memo(WithPopoverMenu);

File diff suppressed because it is too large Load Diff

View File

@@ -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<Props, PopoverState> {
static defaultProps = defaultProps;
function AnnotationLayerControl({
colorScheme,
annotationError = {},
annotationQuery = {},
vizType = '',
validationErrors,
name,
actions,
value = [],
onChange = () => {},
refreshAnnotationData,
}: Props) {
const theme = useTheme();
const [popoverVisible, setPopoverVisible] = useState<
Record<number | string, boolean>
>({});
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 (
<div id={`annotation-pop-${id}`} data-test="popover-content">
<AnnotationLayer
{...(annotation || {})}
error={error}
colorScheme={colorScheme}
vizType={vizType}
addAnnotationLayer={(newAnnotation: Annotation) =>
addAnnotationLayer(annotation, newAnnotation)
}
removeAnnotationLayer={() => removeAnnotationLayer(annotation)}
close={() => {
handleVisibleChange(false, popoverKey);
setAddedAnnotationIndex(null);
}}
/>
</div>
);
},
[
colorScheme,
vizType,
addAnnotationLayer,
removeAnnotationLayer,
handleVisibleChange,
],
);
const renderInfo = useCallback(
(anno: Annotation) => {
if (annotationQuery[anno.name]) {
return (
<Icons.SyncOutlined iconColor={theme.colorPrimary} iconSize="m" />
);
}
}
}
if (annotationError[anno.name]) {
return (
<InfoTooltip
label="validation-errors"
type="error"
tooltip={annotationError[anno.name]}
/>
);
}
if (!anno.show) {
return <span style={{ color: theme.colorError }}> {t('Hidden')} </span>;
}
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) => (
<ControlPopover
key={i}
trigger="click"
title={t('Edit annotation layer')}
css={thm => ({
'&:hover': {
cursor: 'pointer',
backgroundColor: thm.colorFillContentHover,
},
})}
content={renderPopover(i, anno, annotationError[anno.name])}
open={popoverVisible[i]}
onOpenChange={visible => handleVisibleChange(visible, i)}
>
<CustomListItem selectable>
<span>{anno.name}</span>
<span style={{ float: 'right' }}>{renderInfo(anno)}</span>
</CustomListItem>
</ControlPopover>
));
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 (
<div id={`annotation-pop-${id}`} data-test="popover-content">
<AnnotationLayer
{...(annotation || {})}
error={error}
colorScheme={this.props.colorScheme}
vizType={this.props.vizType}
addAnnotationLayer={(newAnnotation: Annotation) =>
this.addAnnotationLayer(annotation, newAnnotation)
return (
<div>
<List bordered css={thm => ({ borderRadius: thm.borderRadius })}>
{annotations}
<ControlPopover
trigger="click"
content={renderPopover(addLayerPopoverKey, addedAnnotation, '')}
title={t('Add annotation layer')}
open={popoverVisible[addLayerPopoverKey]}
destroyTooltipOnHide
onOpenChange={visible =>
handleVisibleChange(visible, addLayerPopoverKey)
}
removeAnnotationLayer={() => this.removeAnnotationLayer(annotation)}
close={() => {
this.handleVisibleChange(false, popoverKey);
this.setState({ addedAnnotationIndex: null });
}}
/>
</div>
);
};
renderInfo(anno: Annotation) {
const { annotationError, annotationQuery, theme } = this.props;
if (annotationQuery[anno.name]) {
return <Icons.SyncOutlined iconColor={theme.colorPrimary} iconSize="m" />;
}
if (annotationError[anno.name]) {
return (
<InfoTooltip
label="validation-errors"
type="error"
tooltip={annotationError[anno.name]}
/>
);
}
if (!anno.show) {
return <span style={{ color: theme.colorError }}> {t('Hidden')} </span>;
}
return '';
}
render() {
const { addedAnnotationIndex } = this.state;
const addedAnnotation =
addedAnnotationIndex !== null
? this.props.value[addedAnnotationIndex]
: null;
const annotations = this.props.value.map((anno, i) => (
<ControlPopover
key={i}
trigger="click"
title={t('Edit annotation layer')}
css={theme => ({
'&: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)}
>
<CustomListItem selectable>
<span>{anno.name}</span>
<span style={{ float: 'right' }}>{this.renderInfo(anno)}</span>
</CustomListItem>
</ControlPopover>
));
const addLayerPopoverKey = 'add';
return (
<div>
<List bordered css={theme => ({ borderRadius: theme.borderRadius })}>
{annotations}
<ControlPopover
trigger="click"
content={this.renderPopover(
addLayerPopoverKey,
addedAnnotation,
'',
)}
title={t('Add annotation layer')}
open={this.state.popoverVisible[addLayerPopoverKey]}
destroyTooltipOnHide
onOpenChange={visible =>
this.handleVisibleChange(visible, addLayerPopoverKey)
}
>
<CustomListItem selectable>
<Icons.PlusOutlined
iconSize="m"
data-test="add-annotation-layer-button"
/>
{t('Add annotation layer')}
</CustomListItem>
</ControlPopover>
</List>
</div>
);
}
>
<CustomListItem selectable>
<Icons.PlusOutlined
iconSize="m"
data-test="add-annotation-layer-button"
/>
{t('Add annotation layer')}
</CustomListItem>
</ControlPopover>
</List>
</div>
);
}
// 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);

View File

@@ -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<CheckboxControlProps> {
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 = <Checkbox onChange={handleChange} checked={!!value} />;
renderCheckbox(): ReactNode {
return <Checkbox onChange={this.onChange} checked={!!this.props.value} />;
}
render(): ReactNode {
if (this.props.label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...this.props}
leftNode={this.renderCheckbox()}
onClick={this.onChange}
/>
</CheckBoxControlWrapper>
);
}
return this.renderCheckbox();
if (label) {
return (
<CheckBoxControlWrapper>
<ControlHeader
{...restProps}
label={label}
leftNode={checkbox}
onClick={handleChange}
/>
</CheckBoxControlWrapper>
);
}
return checkbox;
}

View File

@@ -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<CollectionControlProps> = {
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<CollectionControlProps> {
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 <div className="text-muted">{this.props.placeholder}</div>;
const renderList = () => {
if (value.length === 0) {
return <div className="text-muted">{placeholder}</div>;
}
const Control = (controlMap as Record<string, React.ComponentType<any>>)[
this.props.controlName
controlName
];
const keyAccessor =
this.props.keyAccessor ?? ((o: CollectionItem) => o.key ?? '');
return (
<SortableList
useDragHandle
lockAxis="y"
onSortEnd={this.onSortEnd.bind(this)}
onSortEnd={handleSortEnd}
bordered
css={(theme: SupersetTheme) => ({
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 (
<SortableListItem
selectable={false}
className="clearfix"
css={(theme: SupersetTheme) => ({
alignItems: 'center',
justifyContent: 'flex-start',
display: 'flex',
paddingInline: theme.sizeUnit * 6,
{value.map((o: CollectionItem, i: number) => (
<SortableListItem
selectable={false}
className="clearfix"
css={(themeArg: SupersetTheme) => ({
alignItems: 'center',
justifyContent: 'flex-start',
display: 'flex',
paddingInline: themeArg.sizeUnit * 6,
})}
key={keyAccessor(o)}
index={i}
>
<SortableDragger />
<div
css={(themeArg: SupersetTheme) => ({
flex: 1,
marginLeft: themeArg.sizeUnit * 2,
marginRight: themeArg.sizeUnit * 2,
})}
key={keyAccessor(o)}
index={i}
>
<SortableDragger />
<div
css={(theme: SupersetTheme) => ({
flex: 1,
marginLeft: theme.sizeUnit * 2,
marginRight: theme.sizeUnit * 2,
})}
>
<Control
{...commonProps}
{...o}
onChange={this.onChange.bind(this, i)}
/>
</div>
<IconTooltip
className="pointer"
placement="right"
onClick={this.removeItem.bind(this, i)}
tooltip={t('Remove item')}
mouseEnterDelay={0}
mouseLeaveDelay={0}
css={(theme: SupersetTheme) => ({
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,
},
})}
>
<Icons.CloseOutlined iconSize="s" />
</IconTooltip>
</SortableListItem>
);
})}
<Control
name={name}
description={description}
placeholder={placeholder}
addTooltip={addTooltip}
itemGenerator={itemGenerator}
keyAccessor={keyAccessor}
value={value}
isFloat={isFloat}
isInt={isInt}
controlName={controlName}
{...o}
onChange={(itemValue: CollectionItem) =>
handleChange(i, itemValue)
}
/>
</div>
<IconTooltip
className="pointer"
placement="right"
onClick={() => 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,
},
})}
>
<Icons.CloseOutlined iconSize="s" />
</IconTooltip>
</SortableListItem>
))}
</SortableList>
);
}
};
render() {
return (
<div data-test="CollectionControl" className="CollectionControl">
<HeaderContainer>
<ControlHeader {...this.props} />
<AddIconButton onClick={this.onAdd}>
<Icons.PlusOutlined
iconSize="s"
iconColor={this.props.theme.colorTextLightSolid}
/>
</AddIconButton>
</HeaderContainer>
{this.renderList()}
</div>
);
}
// Props for ControlHeader (excluding label and theme which are handled separately)
const controlHeaderProps = {
name,
label,
description,
};
return (
<div data-test="CollectionControl" className="CollectionControl">
<HeaderContainer>
<ControlHeader {...controlHeaderProps} />
<AddIconButton onClick={handleAdd}>
<Icons.PlusOutlined
iconSize="s"
iconColor={theme.colorTextLightSolid}
/>
</AddIconButton>
</HeaderContainer>
{renderList()}
</div>
);
}
export default withTheme(CollectionControl);

View File

@@ -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<typeof getTemporalColumns>[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<typeof getTemporalColumns>[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 ? (
<Tooltip
title={t(
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
)}
>
{editText}
</Tooltip>
) : (
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: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
const defaultDatasourceMenu = (
<Menu
onClick={this.handleMenuItemClick}
items={defaultDatasourceMenuItems}
/>
);
const queryDatasourceMenuItems = [
{
key: QUERY_PREVIEW,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('Query preview')}</div>
}
modalTitle={t('Query preview')}
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={this.toggleSaveDatasetModal}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
}
draggable={false}
resizable={false}
responsive
/>
),
},
];
if (canAccessSqlLab) {
queryDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: <span>{t('Save as dataset')}</span>,
});
const queryDatasourceMenu = (
<Menu
onClick={this.handleMenuItemClick}
items={queryDatasourceMenuItems}
/>
);
const { health_check_message: healthCheckMessage } = datasource;
const titleText =
isMissingDatasource && !datasource.name
? t('Missing dataset')
: getDatasourceTitle(datasource);
const tooltip = titleText;
return (
<Styles data-test="datasource-control" className="DatasourceControl">
<div className="data-container">
{datasourceIconLookup[getDatasetType(datasource)]}
{renderDatasourceTitle(titleText, tooltip)}
{healthCheckMessage && (
<Tooltip title={healthCheckMessage}>
<Icons.WarningOutlined
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
iconColor={theme.colorWarning}
/>
</Tooltip>
key: EDIT_DATASET,
label: !allowEdit ? (
<Tooltip
title={t(
'You must be a dataset owner in order to edit. Please reach out to a dataset owner to request modifications or edit access.',
)}
{extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)}
<Dropdown
popupRender={() =>
datasource.type === DatasourceType.Query
? queryDatasourceMenu
: defaultDatasourceMenu
}
trigger={['click']}
data-test="datasource-menu"
>
<Icons.MoreOutlined
iconSize="xl"
iconColor={theme.colorPrimary}
className="datasource-modal-trigger"
data-test="datasource-menu-trigger"
>
{editText}
</Tooltip>
) : (
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: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
const defaultDatasourceMenu = (
<Menu onClick={handleMenuItemClick} items={defaultDatasourceMenuItems} />
);
const queryDatasourceMenuItems = [
{
key: QUERY_PREVIEW,
label: (
<ModalTrigger
triggerNode={
<div data-test="view-query-menu-item">{t('Query preview')}</div>
}
modalTitle={t('Query preview')}
modalBody={
<ViewQuery
sql={datasource?.sql || datasource?.select_star || ''}
datasource={`${datasource.id}__${datasource.type}`}
/>
</Dropdown>
}
modalFooter={
<ViewQueryModalFooter
changeDatasource={toggleSaveDatasetModal}
datasource={{
id: String(datasource.id),
sql: datasource.sql || '',
type: datasource.type,
}}
/>
}
draggable={false}
resizable={false}
responsive
/>
),
},
];
if (canAccessSqlLab) {
queryDatasourceMenuItems.push({
key: VIEW_IN_SQL_LAB,
label: (
<Link
to={{
pathname: '/sqllab',
state: { requestedQuery },
}}
onClick={preventRouterLinkWhileMetaClicked}
>
{t('View in SQL Lab')}
</Link>
),
});
}
queryDatasourceMenuItems.push({
key: SAVE_AS_DATASET,
label: <span>{t('Save as dataset')}</span>,
});
const queryDatasourceMenu = (
<Menu onClick={handleMenuItemClick} items={queryDatasourceMenuItems} />
);
const { health_check_message: healthCheckMessage } = datasource;
const titleText =
isMissingDatasource && !datasource.name
? t('Missing dataset')
: getDatasourceTitle(datasource);
const tooltip = titleText;
return (
<Styles data-test="datasource-control" className="DatasourceControl">
<div className="data-container">
{datasourceIconLookup[getDatasetType(datasource)]}
{renderDatasourceTitle(titleText, tooltip)}
{healthCheckMessage && (
<Tooltip title={healthCheckMessage}>
<Icons.WarningOutlined
css={css`
margin-left: ${theme.sizeUnit * 2}px;
`}
iconColor={theme.colorWarning}
/>
</Tooltip>
)}
{extra?.warning_markdown && (
<WarningIconWithTooltip warningMarkdown={extra.warning_markdown} />
)}
<Dropdown
popupRender={() =>
datasource.type === DatasourceType.Query
? queryDatasourceMenu
: defaultDatasourceMenu
}
trigger={['click']}
data-test="datasource-menu"
>
<Icons.MoreOutlined
iconSize="xl"
iconColor={theme.colorPrimary}
className="datasource-modal-trigger"
data-test="datasource-menu-trigger"
/>
</Dropdown>
</div>
{/* missing dataset */}
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
<ErrorAlert
type="warning"
message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
/>
</div>
{/* missing dataset */}
{isMissingDatasource && isMissingParams && (
<div className="error-alert">
)}
{isMissingDatasource && !isMissingParams && (
<div className="error-alert">
{extra?.error ? (
<ErrorMessageWithStackTrace
title={extra.error.statusText || extra.error.message}
subtitle={
extra.error.statusText ? extra.error.message : undefined
}
error={extra.error}
source="explore"
/>
) : (
<ErrorAlert
type="warning"
message={t('Missing URL parameters')}
description={t(
'The URL is missing the dataset_id or slice_id parameters.',
)}
message={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
)}
</p>
<p>
<Button
buttonStyle="primary"
onClick={() =>
handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
</Button>
</p>
</>
}
/>
</div>
)}
{isMissingDatasource && !isMissingParams && (
<div className="error-alert">
{extra?.error ? (
<ErrorMessageWithStackTrace
title={extra.error.statusText || extra.error.message}
subtitle={
extra.error.statusText ? extra.error.message : undefined
}
error={extra.error}
source="explore"
/>
) : (
<ErrorAlert
type="warning"
message={t('Missing dataset')}
descriptionPre={false}
descriptionDetailsCollapsed={false}
descriptionDetails={
<>
<p>
{t(
'The dataset linked to this chart may have been deleted.',
)}
</p>
<p>
<Button
buttonStyle="primary"
onClick={() =>
this.handleMenuItemClick({ key: CHANGE_DATASET })
}
>
{t('Swap dataset')}
</Button>
</p>
</>
}
/>
)}
</div>
)}
{showEditDatasourceModal && (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleEditDatasourceModal}
/>
)}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={this.onDatasourceSave}
onHide={this.toggleChangeDatasourceModal}
show={showChangeDatasourceModal}
onChange={onChange}
/>
)}
{showSaveDatasetModal && (
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={this.toggleSaveDatasetModal}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={getDatasourceAsSaveableDataset(datasource)}
openWindow={false}
formData={this.props.form_data}
/>
)}
</Styles>
);
}
)}
</div>
)}
{showEditDatasourceModal && (
<DatasourceModal
datasource={datasource}
show={showEditDatasourceModal}
onDatasourceSave={handleDatasourceSave}
onHide={toggleEditDatasourceModal}
/>
)}
{showChangeDatasourceModal && (
<ChangeDatasourceModal
onDatasourceSave={handleDatasourceSave}
onHide={toggleChangeDatasourceModal}
show={showChangeDatasourceModal}
onChange={onChange}
/>
)}
{showSaveDatasetModal && (
<SaveDatasetModal
visible={showSaveDatasetModal}
onHide={toggleSaveDatasetModal}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={getDatasourceAsSaveableDataset(datasource)}
openWindow={false}
formData={form_data}
/>
)}
</Styles>
);
}
// withTheme injects the theme prop, so we need to cast the component type
export default withTheme(
DatasourceControl as React.ComponentType<
Omit<DatasourceControlProps, 'theme'>
>,
);

View File

@@ -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<AdhocFilter[]>(() =>
(value || []).map(filter =>
isDictionaryForAdhocFilter(filter) ? new AdhocFilter(filter) : filter,
);
),
);
const [partitionColumn, setPartitionColumn] = useState<string | null>(null);
this.optionRenderer = option => <FilterDefinitionOption option={option} />;
this.valueRenderer = (adhocFilter, index) => (
<AdhocFilterOption
key={index}
index={index}
adhocFilter={adhocFilter}
onFilterEdit={this.onFilterEdit}
options={this.state.options}
sections={this.props.sections}
operators={this.props.operators as Operators[] | undefined}
datasource={this.props.datasource}
onRemoveFilter={e => {
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) => (
<AdhocFilterOption
key={index}
index={index}
adhocFilter={adhocFilter}
onFilterEdit={onFilterEdit}
options={options}
sections={sections}
operators={operators as Operators[] | undefined}
datasource={datasource}
onRemoveFilter={e => {
e.stopPropagation();
onRemoveFilter(index);
}}
onMoveLabel={moveLabel}
onDropLabel={onDropLabel}
partitionColumn={partitionColumn}
/>
),
[
onFilterEdit,
options,
sections,
operators,
datasource,
onRemoveFilter,
moveLabel,
onDropLabel,
partitionColumn,
],
);
const addNewFilterPopoverTrigger = useCallback(
(trigger: ReactNode) => (
<AdhocFilterPopoverTrigger
operators={this.props.operators as Operators[] | undefined}
sections={this.props.sections}
operators={operators as Operators[] | undefined}
sections={sections}
adhocFilter={new AdhocFilter({})}
datasource={(this.props.datasource as Record<string, unknown>) || {}}
options={this.state.options}
onFilterEdit={this.onNewFilter}
partitionColumn={this.state.partitionColumn ?? undefined}
datasource={(datasource as Record<string, unknown>) || {}}
options={options}
onFilterEdit={onNewFilter}
partitionColumn={partitionColumn ?? undefined}
>
{trigger}
</AdhocFilterPopoverTrigger>
);
}
),
[operators, sections, datasource, options, onNewFilter, partitionColumn],
);
render() {
return (
<div className="metrics-select" data-test="adhoc-filter-control">
<HeaderContainer>
<ControlHeader {...this.props} />
</HeaderContainer>
<LabelsContainer>
{[
...(this.state.values.length > 0
? this.state.values.map((value, index) =>
this.valueRenderer(value, index),
)
: []),
this.addNewFilterPopoverTrigger(
<AddControlLabel role="button" data-test="add-filter-button">
<Icons.PlusOutlined iconSize="m" />
{t('Add filter')}
</AddControlLabel>,
),
]}
</LabelsContainer>
</div>
);
}
return (
<div className="metrics-select" data-test="adhoc-filter-control">
<HeaderContainer>
<ControlHeader label={label} name={name} />
</HeaderContainer>
<LabelsContainer>
{[
...(values.length > 0
? values.map((val, index) => valueRenderer(val, index))
: []),
addNewFilterPopoverTrigger(
<AddControlLabel role="button" data-test="add-filter-button">
<Icons.PlusOutlined iconSize="m" />
{t('Add filter')}
</AddControlLabel>,
),
]}
</LabelsContainer>
</div>
);
}
// @ts-expect-error - defaultProps for backward compatibility
AdhocFilterControl.defaultProps = defaultProps;
export default withTheme(AdhocFilterControl);
export default AdhocFilterControl;

View File

@@ -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<HTMLDivElement>;
function AdhocFilterEditPopover({
adhocFilter: propsAdhocFilter,
onChange,
onClose,
onResize,
options,
datasource,
partitionColumn,
operators,
requireSave,
...popoverProps
}: AdhocFilterEditPopoverProps) {
const popoverContentRef = useRef<HTMLDivElement>(null);
dragStartX = 0;
const dragStartRef = useRef({
x: 0,
y: 0,
width: 0,
height: 0,
});
dragStartY = 0;
const [adhocFilter, setAdhocFilter] = useState<AdhocFilter>(propsAdhocFilter);
const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH);
const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT);
const [isSimpleTabValid, setIsSimpleTabValid] = useState(true);
const [selectedLayers, setSelectedLayers] = useState<LayerOption[]>([
{ id: null, value: -1, label: 'All' },
]);
const [layerOptions, setLayerOptions] = useState<LayerOption[]>([]);
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 (
<FilterPopoverContentContainer
id="filter-edit-popover"
{...popoverProps}
data-test="filter-edit-popover"
ref={this.popoverContentRef}
>
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: this.state.height, width: this.state.width }}
allowOverflow
onChange={this.onTabChange}
items={[
{
key: ExpressionTypes.Simple,
label: t('Simple'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators as Operators[] | undefined}
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={options as ColumnType[]}
datasource={datasource as unknown as Dataset}
onHeightChange={this.adjustHeight}
partitionColumn={partitionColumn}
popoverRef={this.popoverContentRef.current}
validHandler={this.setSimpleTabIsValid}
/>
</ErrorBoundary>
),
},
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={this.state.adhocFilter}
onChange={this.onAdhocFilterChange}
options={this.props.options}
height={this.state.height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]}
/>
{hasDeckSlices && (
<LayerSelectContainer>
<Select
options={this.state.layerOptions}
onChange={
this.onLayerChange as unknown as (value: unknown) => void
}
value={selectedLayers}
mode="multiple"
/>
</LayerSelectContainer>
)}
<FilterActionsContainer>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={this.props.onClose}
cta
>
{t('Close')}
</Button>
<Button
data-test="adhoc-filter-edit-popover-save-button"
disabled={
!stateIsValid ||
!this.state.isSimpleTabValid ||
!hasUnsavedChanges
}
buttonStyle="primary"
buttonSize="small"
onClick={this.onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={this.onDragDown}
className="edit-popover-resize"
return (
<FilterPopoverContentContainer
id="filter-edit-popover"
{...popoverProps}
data-test="filter-edit-popover"
ref={popoverContentRef}
>
<Tabs
id="adhoc-filter-edit-tabs"
defaultActiveKey={adhocFilter.expressionType}
className="adhoc-filter-edit-tabs"
data-test="adhoc-filter-edit-tabs"
style={{ minHeight: height, width }}
allowOverflow
items={[
{
key: ExpressionTypes.Simple,
label: t('Simple'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSimpleTabContent
operators={operators as Operators[] | undefined}
adhocFilter={adhocFilter}
onChange={onAdhocFilterChange}
options={options as ColumnType[]}
datasource={datasource as unknown as Dataset}
onHeightChange={adjustHeight}
partitionColumn={partitionColumn}
popoverRef={popoverContentRef.current}
validHandler={setSimpleTabIsValid}
/>
</ErrorBoundary>
),
},
{
key: ExpressionTypes.Sql,
label: t('Custom SQL'),
children: (
<ErrorBoundary>
<AdhocFilterEditPopoverSqlTabContent
adhocFilter={adhocFilter}
onChange={onAdhocFilterChange}
options={options}
height={height}
datasource={datasource}
/>
</ErrorBoundary>
),
},
]}
/>
{hasDeckSlices && (
<LayerSelectContainer>
<Select
options={layerOptions}
onChange={onLayerChange as unknown as (value: unknown) => void}
value={selectedLayers}
mode="multiple"
/>
</FilterActionsContainer>
</FilterPopoverContentContainer>
);
}
</LayerSelectContainer>
)}
<FilterActionsContainer>
<Button
buttonStyle="secondary"
buttonSize="small"
onClick={onClose}
cta
>
{t('Close')}
</Button>
<Button
data-test="adhoc-filter-edit-popover-save-button"
disabled={!stateIsValid || !isSimpleTabValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
onClick={onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={onDragDown}
className="edit-popover-resize"
/>
</FilterActionsContainer>
</FilterPopoverContentContainer>
);
}
export default AdhocFilterEditPopover;

View File

@@ -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 = (
<ExplorePopoverContent>
<AdhocFilterEditPopover
adhocFilter={adhocFilter}
options={options}
datasource={datasource}
partitionColumn={partitionColumn}
onResize={onPopoverResize}
onClose={closePopover ?? (() => {})}
sections={sections}
operators={operators}
onChange={onFilterEdit}
requireSave={requireSave}
/>
</ExplorePopoverContent>
);
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 = (
<ExplorePopoverContent>
<AdhocFilterEditPopover
adhocFilter={adhocFilter}
options={this.props.options}
datasource={this.props.datasource}
partitionColumn={this.props.partitionColumn}
onResize={this.onPopoverResize}
onClose={closePopover ?? (() => {})}
sections={this.props.sections}
operators={this.props.operators}
onChange={this.props.onFilterEdit}
requireSave={this.props.requireSave}
/>
</ExplorePopoverContent>
);
return (
<ControlPopover
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
destroyTooltipOnHide
>
{this.props.children}
</ControlPopover>
);
}
return (
<ControlPopover
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
destroyTooltipOnHide
>
{children}
</ControlPopover>
);
}
export default AdhocFilterPopoverTrigger;

View File

@@ -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 (
<div>
<ControlHeader {...this.props} />
<Collapse
ghost
items={[
{
key: 'fixed-or-metric',
showArrow: false,
label: (
<Label>
{this.state.type === controlTypes.fixed && (
<span>{this.state.fixedValue}</span>
)}
{this.state.type === controlTypes.metric && (
<span>
<span>{t('metric')}: </span>
<strong>
{this.state.metricValue
? this.state.metricValue.label
: null}
</strong>
</span>
)}
</Label>
),
children: (
<div className="well">
<PopoverSection
title={t('Fixed')}
isSelected={type === controlTypes.fixed}
onSelect={() => {
this.setType(controlTypes.fixed);
}}
>
<TextControl
isFloat
onChange={this.setFixedValue}
onFocus={() => {
this.setType(controlTypes.fixed);
return {};
}}
value={this.state.fixedValue}
/>
</PopoverSection>
<PopoverSection
title={t('Based on a metric')}
isSelected={type === controlTypes.metric}
onSelect={() => {
this.setType(controlTypes.metric);
}}
>
<MetricsControl
name="metric"
columns={columns ?? undefined}
savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
this.setType(controlTypes.metric);
}}
onChange={this.setMetric}
value={this.state.metricValue}
datasource={this.props.datasource}
/>
</PopoverSection>
</div>
),
},
]}
/>
</div>
);
}
}
// @ts-expect-error - defaultProps for backward compatibility
FixedOrMetricControl.defaultProps = defaultProps;
const [type, setTypeState] = useState<'fix' | 'metric'>(initialType);
const [fixedValue, setFixedValueState] = useState<string | number>(
initialFixedValue,
);
const [metricValue, setMetricValueState] = useState<MetricValue | null>(
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 (
<div>
<ControlHeader name={name} label={label} description={description} />
<Collapse
ghost
items={[
{
key: 'fixed-or-metric',
showArrow: false,
label: (
<Label>
{type === controlTypes.fixed && <span>{fixedValue}</span>}
{type === controlTypes.metric && (
<span>
<span>{t('metric')}: </span>
<strong>{metricValue ? metricValue.label : null}</strong>
</span>
)}
</Label>
),
children: (
<div className="well">
<PopoverSection
title={t('Fixed')}
isSelected={displayType === controlTypes.fixed}
onSelect={() => {
setType(controlTypes.fixed);
}}
>
<TextControl
isFloat
onChange={setFixedValue}
onFocus={() => {
setType(controlTypes.fixed);
return {};
}}
value={fixedValue}
/>
</PopoverSection>
<PopoverSection
title={t('Based on a metric')}
isSelected={displayType === controlTypes.metric}
onSelect={() => {
setType(controlTypes.metric);
}}
>
<MetricsControl
name="metric"
columns={columns ?? undefined}
savedMetrics={metrics ?? undefined}
multi={false}
onFocus={() => {
setType(controlTypes.metric);
}}
onChange={setMetric}
value={metricValue}
datasource={datasource}
/>
</PopoverSection>
</div>
),
},
]}
/>
</div>
);
}

View File

@@ -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<AdhocMetric>(propsAdhocMetric);
const [savedMetric, setSavedMetric] = useState<SavedMetricType | undefined>(
propsSavedMetric,
);
const [width, setWidth] = useState(POPOVER_INITIAL_WIDTH);
const [height, setHeight] = useState(POPOVER_INITIAL_HEIGHT);
aceEditorRef: RefObject<editors.EditorHandle>;
const aceEditorRef = useRef<editors.EditorHandle>(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 <StyledColumnOption column={column} showType />;
}
const onTabChange = useCallback(
(tab: string): void => {
refreshAceEditor();
getCurrentTab(tab);
},
[refreshAceEditor, getCurrentTab],
);
renderMetricOption(savedMetric: SavedMetricType): React.ReactNode {
return <StyledMetricOption metric={savedMetric} showType />;
}
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 <StyledColumnOption column={column} showType />;
},
[],
);
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<typeof getColumnKeywords>[0],
const renderMetricOption = useCallback(
(metric: SavedMetricType): React.ReactNode => (
<StyledMetricOption metric={metric} showType />
),
[],
);
const columnsArray = columns;
const keywords = useMemo(
() =>
sqlKeywords.concat(
getColumnKeywords(
columnsArray as Parameters<typeof getColumnKeywords>[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 (
<Form
layout="vertical"
id="metrics-edit-popover"
data-test="metrics-edit-popover"
{...popoverProps}
>
<Tabs
id="adhoc-metric-edit-tabs"
data-test="adhoc-metric-edit-tabs"
defaultActiveKey={this.defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
style={{ height: this.state.height, width: this.state.width }}
onChange={this.onTabChange}
allowOverflow
items={[
{
key: SAVED_TAB_KEY,
label: t('Saved'),
children:
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={ensureIsArray(savedMetricsOptions).map(
savedMetric => ({
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}
/>
</FormItem>
) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={t(
'Add metrics to dataset in "Edit datasource" modal',
)}
return (
<Form
layout="vertical"
id="metrics-edit-popover"
data-test="metrics-edit-popover"
{...popoverProps}
>
<Tabs
id="adhoc-metric-edit-tabs"
data-test="adhoc-metric-edit-tabs"
defaultActiveKey={defaultActiveTabKey}
className="adhoc-metric-edit-tabs"
style={{ height, width }}
onChange={onTabChange}
allowOverflow
items={[
{
key: SAVED_TAB_KEY,
label: t('Saved'),
children:
ensureIsArray(savedMetricsOptions).length > 0 ? (
<FormItem label={t('Saved metric')}>
<StyledSelect
options={ensureIsArray(savedMetricsOptions).map(metric => ({
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}
/>
) : (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={
<>
<span
tabIndex={0}
role="button"
onClick={() => {
this.props.handleDatasetModal?.(true);
this.props.onClose();
}}
>
{t('Create a dataset')}
</span>
{t(' to add metrics')}
</>
}
/>
),
},
{
key: EXPRESSION_TYPES.SIMPLE,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Simple ad-hoc metrics are not enabled for this dataset',
</FormItem>
) : datasource?.type === DatasourceType.Table ? (
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={t(
'Add metrics to dataset in "Edit datasource" modal',
)}
>
{t('Simple')}
</Tooltip>
/>
) : (
t('Simple')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<>
<FormItem label={t('column')}>
<Select
options={columnsArray.map(column => ({
value: column.column_name,
key: (column as { id?: unknown }).id,
label: this.renderColumnOption(column),
column_name: column.column_name,
verbose_name: column.verbose_name ?? '',
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...columnSelectProps}
/>
</FormItem>
<FormItem label={t('aggregate')}>
<Select
options={AGGREGATES_OPTIONS.map(option => ({
value: option,
label: option,
key: option,
}))}
{...aggregateSelectProps}
/>
</FormItem>
</>
),
},
{
key: EXPRESSION_TYPES.SQL,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Custom SQL ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Custom SQL')}
</Tooltip>
) : (
t('Custom SQL')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<SQLEditorWithValidation
data-test="sql-editor"
ref={this.aceEditorRef}
keywords={keywords}
height={`${this.state.height - 120}px`}
onChange={this.onSqlExpressionChange}
width="100%"
lineNumbers={false}
value={
adhocMetric.sqlExpression ||
adhocMetric.translateToSql({ transformCountDistinct: true })
<EmptyState
image="empty.svg"
size="small"
title={t('No saved metrics found')}
description={
<>
<span
tabIndex={0}
role="button"
onClick={() => {
handleDatasetModal?.(true);
onClose();
}}
>
{t('Create a dataset')}
</span>
{t(' to add metrics')}
</>
}
wordWrap
showValidation
expressionType="metric"
datasourceId={datasource?.id}
datasourceType={datasource?.type}
/>
),
},
]}
},
{
key: EXPRESSION_TYPES.SIMPLE,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Simple ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Simple')}
</Tooltip>
) : (
t('Simple')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<>
<FormItem label={t('column')}>
<Select
options={columnsArray.map(column => ({
value: column.column_name,
key: (column as { id?: unknown }).id,
label: renderColumnOption(column),
column_name: column.column_name,
verbose_name: column.verbose_name ?? '',
}))}
optionFilterProps={['column_name', 'verbose_name']}
{...columnSelectProps}
/>
</FormItem>
<FormItem label={t('aggregate')}>
<Select
options={AGGREGATES_OPTIONS.map(option => ({
value: option,
label: option,
key: option,
}))}
{...aggregateSelectProps}
/>
</FormItem>
</>
),
},
{
key: EXPRESSION_TYPES.SQL,
label: extra.disallow_adhoc_metrics ? (
<Tooltip
title={t(
'Custom SQL ad-hoc metrics are not enabled for this dataset',
)}
>
{t('Custom SQL')}
</Tooltip>
) : (
t('Custom SQL')
),
disabled: extra.disallow_adhoc_metrics,
children: (
<SQLEditorWithValidation
data-test="sql-editor"
ref={aceEditorRef as RefObject<editors.EditorHandle>}
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}
/>
),
},
]}
/>
<div>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={onResetStateAndClose}
data-test="AdhocMetricEdit#cancel"
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
data-test="AdhocMetricEdit#save"
onClick={onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={onDragDown}
className="edit-popover-resize"
/>
<div>
<Button
buttonSize="small"
buttonStyle="secondary"
onClick={this.onResetStateAndClose}
data-test="AdhocMetricEdit#cancel"
cta
>
{t('Close')}
</Button>
<Button
disabled={!stateIsValid || !hasUnsavedChanges}
buttonStyle="primary"
buttonSize="small"
data-test="AdhocMetricEdit#save"
onClick={this.onSave}
cta
>
{t('Save')}
</Button>
<Icons.ArrowsAltOutlined
role="button"
aria-label={t('Resize')}
tabIndex={0}
onMouseDown={this.onDragDown}
className="edit-popover-resize"
/>
</div>
</Form>
);
}
</div>
</Form>
);
}
// @ts-expect-error - defaultProps for backward compatibility
AdhocMetricEditPopover.defaultProps = defaultProps;
export default memo(AdhocMetricEditPopover);

View File

@@ -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<AdhocMetricOptionProps> {
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 (
<AdhocMetricPopoverTrigger
return (
<AdhocMetricPopoverTrigger
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric}
datasource={datasource!}
>
<OptionControlLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
onMetricEdit={onMetricEdit}
columns={columns ?? []}
savedMetricsOptions={savedMetricsOptions ?? []}
savedMetric={savedMetric}
datasource={datasource!}
>
<OptionControlLabel
// eslint-disable-next-line @typescript-eslint/no-explicit-any
savedMetric={savedMetric as any}
adhocMetric={adhocMetric}
label={adhocMetric.label}
onRemove={() => this.onRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index ?? 0}
type={type ?? DndItemType.AdhocMetricOption}
withCaret={withCaret}
isFunction
multi={multi}
datasourceWarningMessage={datasourceWarningMessage}
/>
</AdhocMetricPopoverTrigger>
);
}
label={adhocMetric.label}
onRemove={() => handleRemoveMetric()}
onMoveLabel={onMoveLabel}
onDropLabel={onDropLabel}
index={index}
type={type}
withCaret={withCaret}
isFunction
multi={multi}
datasourceWarningMessage={datasourceWarningMessage}
/>
</AdhocMetricPopoverTrigger>
);
}
export default AdhocMetricOption;
export default memo(AdhocMetricOption);

View File

@@ -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 = (
<ExplorePopoverContent>
<AdhocMetricEditPopover
adhocMetric={adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric as savedMetricType}
datasource={
datasource as unknown as {
type?: string;
id?: number | string;
extra?: string;
}
}
handleDatasetModal={this.handleDatasetModal}
onResize={this.onPopoverResize}
onClose={closePopover}
onChange={
this.onChange as (newMetric: unknown, oldMetric?: unknown) => void
}
getCurrentTab={this.getCurrentTab}
getCurrentLabel={this.getCurrentLabel}
isNewMetric={this.props.isNew}
isLabelModified={
this.state.labelModified &&
adhocMetricLabel !== this.state.title.label
}
/>
</ExplorePopoverContent>
);
const popoverTitle = (
<AdhocMetricEditPopoverTitle
title={title}
onChange={this.onLabelChange}
isEditDisabled={this.state.isTitleEditDisabled}
/>
);
return (
<>
{this.state.showSaveDatasetModal && (
<SaveDatasetModal
visible={this.state.showSaveDatasetModal}
onHide={() => this.handleDatasetModal(false)}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={datasource}
/>
)}
<ControlPopover
placement="right"
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={togglePopover}
title={popoverTitle}
destroyTooltipOnHide
>
{this.props.children}
</ControlPopover>
</>
);
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 = (
<ExplorePopoverContent>
<AdhocMetricEditPopover
adhocMetric={state.adhocMetric}
columns={columns}
savedMetricsOptions={savedMetricsOptions}
savedMetric={savedMetric as savedMetricType}
datasource={
datasource as unknown as {
type?: string;
id?: number | string;
extra?: string;
}
}
handleDatasetModal={handleDatasetModal}
onResize={onPopoverResize}
onClose={close}
onChange={onChange as (newMetric: unknown, oldMetric?: unknown) => void}
getCurrentTab={getCurrentTab}
getCurrentLabel={getCurrentLabel}
isNewMetric={isNew}
isLabelModified={
state.labelModified && adhocMetricLabel !== state.title.label
}
/>
</ExplorePopoverContent>
);
const popoverTitle = (
<AdhocMetricEditPopoverTitle
title={title}
onChange={onLabelChange}
isEditDisabled={state.isTitleEditDisabled}
/>
);
return (
<>
{state.showSaveDatasetModal && (
<SaveDatasetModal
visible={state.showSaveDatasetModal}
onHide={() => handleDatasetModal(false)}
buttonTextOnSave={t('Save')}
buttonTextOnOverwrite={t('Overwrite')}
modalDescription={t(
'Save this query as a virtual dataset to continue exploring',
)}
datasource={datasource}
/>
)}
<ControlPopover
placement="right"
trigger="click"
content={overlayContent}
defaultOpen={visible}
open={visible}
onOpenChange={toggle}
title={popoverTitle}
destroyTooltipOnHide
>
{children}
</ControlPopover>
</>
);
}
export default memo(AdhocMetricPopoverTrigger);

View File

@@ -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<SelectOption[]>(() =>
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 && <ControlHeader {...headerProps} />,
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 (
<div
css={theme => 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 */}
<Select {...(selectProps as any)} />
</div>
);
}
return (
<div
css={theme => 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;
}
`}
>
<Select {...(selectProps as Parameters<typeof Select>[0])} />
</div>
);
}
export default SelectControl;

View File

@@ -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<SpatialType>(
v.type || spatialTypes.latlong,
);
const [delimiter, setDelimiter] = useState(v.delimiter || ',');
const [latCol, setLatCol] = useState<string | undefined>(
v.latCol || defaultCol,
);
const [lonCol, setLonCol] = useState<string | undefined>(
v.lonCol || defaultCol,
);
const [lonlatCol, setLonlatCol] = useState<string | undefined>(
v.lonlatCol || defaultCol,
);
const [reverseCheckbox, setReverseCheckbox] = useState(
v.reverseCheckbox || false,
);
const [geohashCol, setGeohashCol] = useState<string | undefined>(
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<string, string | undefined> = {
latCol,
lonCol,
lonlatCol,
geohashCol,
delimiter,
};
const setterMap: Record<
string,
React.Dispatch<React.SetStateAction<string | undefined>>
> = {
latCol: setLatCol,
lonCol: setLonCol,
lonlatCol: setLonlatCol,
geohashCol: setGeohashCol,
delimiter: setDelimiter as React.Dispatch<
React.SetStateAction<string | undefined>
>,
};
renderSelect(name: keyof SpatialControlState, type: SpatialType): ReactNode {
return (
<SelectControl
ariaLabel={name}
name={name}
choices={this.props.choices}
value={this.state[name] as string}
choices={choices}
value={stateMap[name]}
clearable={false}
onFocus={() => {
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 (
<span>
{t('Reverse lat/long ')}
<Checkbox
checked={this.state.reverseCheckbox}
onChange={this.toggleCheckbox}
/>
</span>
);
}
const renderReverseCheckbox = (): ReactNode => (
<span>
{t('Reverse lat/long ')}
<Checkbox checked={reverseCheckbox} onChange={toggleCheckbox} />
</span>
);
renderPopoverContent(): ReactNode {
return (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={this.state.type === spatialTypes.latlong}
onSelect={() => this.setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Longitude')}
{this.renderSelect('lonCol', spatialTypes.latlong)}
</Col>
<Col xs={24} md={12}>
{t('Latitude')}
{this.renderSelect('latCol', spatialTypes.latlong)}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Delimited long & lat single column')}
info={t(
'Multiple formats accepted, look the geopy.points ' +
'Python library for more details',
)}
isSelected={this.state.type === spatialTypes.delimited}
onSelect={() => this.setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{this.renderSelect('lonlatCol', spatialTypes.delimited)}
</Col>
<Col xs={24} md={12}>
{this.renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Geohash')}
isSelected={this.state.type === spatialTypes.geohash}
onSelect={() => this.setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{this.renderSelect('geohashCol', spatialTypes.geohash)}
</Col>
<Col xs={24} md={12}>
{this.renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
</div>
);
}
const renderPopoverContent = (): ReactNode => (
<div style={{ width: '300px' }}>
<PopoverSection
title={t('Longitude & Latitude columns')}
isSelected={type === spatialTypes.latlong}
onSelect={() => setType(spatialTypes.latlong)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Longitude')}
{renderSelect('lonCol', spatialTypes.latlong)}
</Col>
<Col xs={24} md={12}>
{t('Latitude')}
{renderSelect('latCol', spatialTypes.latlong)}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Delimited long & lat single column')}
info={t(
'Multiple formats accepted, look the geopy.points ' +
'Python library for more details',
)}
isSelected={type === spatialTypes.delimited}
onSelect={() => setType(spatialTypes.delimited)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{renderSelect('lonlatCol', spatialTypes.delimited)}
</Col>
<Col xs={24} md={12}>
{renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
<PopoverSection
title={t('Geohash')}
isSelected={type === spatialTypes.geohash}
onSelect={() => setType(spatialTypes.geohash)}
>
<Row gutter={16}>
<Col xs={24} md={12}>
{t('Column')}
{renderSelect('geohashCol', spatialTypes.geohash)}
</Col>
<Col xs={24} md={12}>
{renderReverseCheckbox()}
</Col>
</Row>
</PopoverSection>
</div>
);
render(): ReactNode {
return (
<div>
<ControlHeader {...this.props} />
<Popover
content={this.renderPopoverContent()}
placement="topLeft"
trigger="click"
>
<Label className="pointer">{this.renderLabelContent()}</Label>
</Popover>
</div>
);
}
return (
<div>
<ControlHeader name={name} label={label} description={description} />
<Popover
content={renderPopoverContent()}
placement="topLeft"
trigger="click"
>
<Label className="pointer">{renderLabelContent()}</Label>
</Popover>
</div>
);
}

View File

@@ -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(<TextAreaControl {...props} />);
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(<TextAreaControl {...props} />);
const textArea = screen.getByRole('textbox');
fireEvent.change(textArea, { target: { value: 'x' } });

View File

@@ -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<string, unknown>;
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<TextAreaControlProps> {
static defaultProps = defaultProps;
const debouncedOnChangeRef = useRef<ReturnType<
typeof debounce<(value: string) => void>
> | null>(null);
debouncedOnChange:
| ReturnType<typeof debounce<(value: string) => 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 = (
<div>
<TextAreaEditor
mode={language}
style={style}
minLines={effectiveMinLines}
maxLines={inModal ? 1000 : maxLines}
editorProps={{ $blockScrolling: true }}
onLoad={onEditorLoad}
defaultValue={initialValue}
readOnly={readOnly}
key={name}
{...restProps}
onChange={handleChange}
/>
</div>
);
if (tooltipOptions && Object.keys(tooltipOptions).length > 0) {
return <Tooltip {...tooltipOptions}>{codeEditor}</Tooltip>;
}
return codeEditor;
}
const textArea = (
<div>
<TextAreaEditor
mode={language}
style={style}
minLines={minLines}
maxLines={inModal ? 1000 : maxLinesProp}
editorProps={{ $blockScrolling: true }}
onLoad={onEditorLoad}
<Input.TextArea
placeholder={t('textarea')}
onChange={handleChange}
defaultValue={initialValue}
readOnly={readOnly}
key={name}
{...editorProps}
onChange={this.handleChange.bind(this)}
disabled={readOnly}
style={{ height }}
aria-required={ariaRequired}
/>
</div>
);
if (tooltipOptions) {
return <Tooltip {...tooltipOptions}>{codeEditor}</Tooltip>;
if (tooltipOptions && Object.keys(tooltipOptions).length > 0) {
return <Tooltip {...tooltipOptions}>{textArea}</Tooltip>;
}
return codeEditor;
}
return textArea;
},
[
minLines,
maxLines,
language,
theme,
textAreaStyles,
resize,
readOnly,
onEditorLoad,
initialValue,
name,
restProps,
handleChange,
tooltipOptions,
height,
ariaRequired,
],
);
const textArea = (
<div>
<Input.TextArea
placeholder={t('textarea')}
onChange={this.handleChange.bind(this)}
defaultValue={this.props.initialValue}
disabled={this.props.readOnly}
style={{ height: this.props.height }}
aria-required={this.props['aria-required']}
/>
</div>
);
if (this.props.tooltipOptions) {
return <Tooltip {...this.props.tooltipOptions}>{textArea}</Tooltip>;
}
return textArea;
}
// Extract only ControlHeader-compatible props from restProps
const {
label,
description,
validationErrors,
renderTrigger,
rightNode,
leftNode,
onClick,
hovered,
tooltipOnClick,
warning,
danger,
} = restProps as Record<string, unknown>;
renderModalBody() {
return (
const controlHeader = useMemo(
() => (
<ControlHeader
name={name}
label={label as React.ReactNode}
description={description as React.ReactNode}
validationErrors={validationErrors as string[] | undefined}
renderTrigger={renderTrigger as boolean | undefined}
rightNode={rightNode as React.ReactNode}
leftNode={leftNode as React.ReactNode}
onClick={onClick as (() => 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(
() => (
<>
<div>{this.props.aboveEditorSection}</div>
{this.renderEditor(true)}
<div>{aboveEditorSection}</div>
{renderEditor(true)}
</>
);
}
),
[aboveEditorSection, renderEditor],
);
render() {
const controlHeader = <ControlHeader {...this.props} />;
return (
<div>
{controlHeader}
{this.renderEditor()}
{this.props.offerEditInModal && (
<ModalTrigger
// eslint-disable-next-line @typescript-eslint/no-explicit-any
modalTitle={controlHeader as any}
triggerNode={
<Button
buttonSize="small"
style={{ marginTop: this.props.theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', this.props.language)}
</Button>
}
modalBody={this.renderModalBody()}
responsive
/>
)}
</div>
);
}
return (
<div>
{controlHeader}
{renderEditor()}
{offerEditInModal && (
<ModalTrigger
modalTitle={String(label || '')}
triggerNode={
<Button
buttonSize="small"
style={{ marginTop: theme?.sizeUnit ?? 4 }}
>
{t('Edit %s in modal', language)}
</Button>
}
modalBody={modalBody}
responsive
/>
)}
</div>
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export default withTheme(TextAreaControl as any);
export default TextAreaControl;

View File

@@ -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<T extends InputValueType = InputValueType> {
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<T extends InputValueType = InputValueType> {
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<TextControlProps<T>, TextControlState> {
initialValue?: TextControlProps['value'];
function TextControl<T extends InputValueType = InputValueType>({
name,
label,
description,
disabled,
isFloat,
isInt,
onChange,
onFocus,
placeholder,
value,
controlId,
renderTrigger,
validationErrors,
hovered,
showHeader,
}: TextControlProps<T>) {
const [localValue, setLocalValue] = useState<string>(safeStringify(value));
const prevValueRef = useRef<T | null | undefined>(value);
constructor(props: TextControlProps<T>) {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<div>
<ControlHeader {...this.props} />
<Input
type="text"
data-test="inline-name"
placeholder={this.props.placeholder}
onChange={this.onChangeWrapper}
onFocus={this.props.onFocus}
value={value}
disabled={this.props.disabled}
aria-label={this.props.label}
/>
</div>
);
}
// Note: controlId and showHeader props are not used by ControlHeader
return (
<div>
<ControlHeader
name={name}
label={label}
description={description}
renderTrigger={renderTrigger}
validationErrors={validationErrors}
hovered={hovered}
/>
<Input
type="text"
data-test="inline-name"
placeholder={placeholder}
onChange={onChangeWrapper}
onFocus={onFocus}
value={displayValue}
disabled={disabled}
aria-label={label}
/>
</div>
);
}
export default TextControl;

View File

@@ -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<TimeSeriesColumnControlState>(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<HTMLInputElement>) {
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<HTMLInputElement>) => {
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,
) => (
<StyledRow>
<StyledCol xs={24} md={11}>
{label}
@@ -228,214 +232,241 @@ export default class TimeSeriesColumnControl extends Component<
{control}
</Col>
</StyledRow>
);
}
),
[],
);
const renderPopover = useCallback(() => {
const handleLabelChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('label', e);
const handleTooltipChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('tooltip', e);
const handleColTypeChange = (opt: string) => onSelectChange('colType', opt);
const handleSparkTypeChange = (opt: string) =>
onSelectChange('sparkType', opt);
const handleWidthChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('width', e);
const handleHeightChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('height', e);
const handleTimeLagChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('timeLag', e);
const handleTimeRatioChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('timeRatio', e);
const handleComparisonTypeChange = (opt: string) =>
onSelectChange('comparisonType', opt);
const handleShowYAxisChange = (value: boolean) =>
onCheckboxChange('showYAxis', value);
const handleD3formatChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('d3format', e);
const handleDateFormatChange = (e: React.ChangeEvent<HTMLInputElement>) =>
onTextInputChange('dateFormat', e);
renderPopover() {
return (
<div id="ts-col-popo" style={{ width: 320 }}>
{this.formRow(
{formRow(
t('Label'),
t('The column header label'),
'time-lag',
<Input
value={this.state.label}
onChange={this.onTextInputChange.bind(this, 'label')}
value={state.label}
onChange={handleLabelChange}
placeholder={t('Label')}
/>,
)}
{this.formRow(
{formRow(
t('Tooltip'),
t('Column header tooltip'),
'col-tooltip',
<Input
value={this.state.tooltip}
onChange={this.onTextInputChange.bind(this, 'tooltip')}
value={state.tooltip}
onChange={handleTooltipChange}
placeholder={t('Tooltip')}
/>,
)}
{this.formRow(
{formRow(
t('Type'),
t('Type of comparison, value difference or percentage'),
'col-type',
<Select
ariaLabel={t('Type')}
value={this.state.colType || undefined}
onChange={this.onSelectChange.bind(this, 'colType')}
value={state.colType || undefined}
onChange={handleColTypeChange}
options={colTypeOptions}
/>,
)}
<Divider />
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Chart type'),
t('Type of chart to display in sparkline'),
'spark-type',
<Select
ariaLabel={t('Chart Type')}
value={this.state.sparkType || undefined}
onChange={this.onSelectChange.bind(this, 'sparkType')}
value={state.sparkType || undefined}
onChange={handleSparkTypeChange}
options={sparkTypeOptions}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Width'),
t('Width of the sparkline'),
'spark-width',
<Input
value={this.state.width}
onChange={this.onTextInputChange.bind(this, 'width')}
value={state.width}
onChange={handleWidthChange}
placeholder={t('Width')}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Height'),
t('Height of the sparkline'),
'spark-width',
<Input
value={this.state.height}
onChange={this.onTextInputChange.bind(this, 'height')}
value={state.height}
onChange={handleHeightChange}
placeholder={t('Height')}
/>,
)}
{['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',
<Input
value={this.state.timeLag}
onChange={this.onTextInputChange.bind(this, 'timeLag')}
value={state.timeLag}
onChange={handleTimeLagChange}
placeholder={t('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',
<Input
value={this.state.timeRatio}
onChange={this.onTextInputChange.bind(this, 'timeRatio')}
value={state.timeRatio}
onChange={handleTimeRatioChange}
placeholder={t('Time Ratio')}
/>,
)}
{this.state.colType === 'time' &&
this.formRow(
{state.colType === 'time' &&
formRow(
t('Type'),
t('Type of comparison, value difference or percentage'),
'comp-type',
<Select
ariaLabel={t('Type')}
value={this.state.comparisonType || undefined}
onChange={this.onSelectChange.bind(this, 'comparisonType')}
value={state.comparisonType || undefined}
onChange={handleComparisonTypeChange}
options={comparisonTypeOptions}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Show Y-axis'),
t(
'Show Y-axis on the sparkline. Will display the manually set min/max if set or min/max values in the data otherwise.',
),
'show-y-axis-bounds',
<CheckboxControl
value={this.state.showYAxis}
onChange={this.onCheckboxChange.bind(this, 'showYAxis')}
value={state.showYAxis}
onChange={handleShowYAxisChange}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Y-axis bounds'),
t('Manually set min/max values for the y-axis.'),
'y-axis-bounds',
<BoundsControl
value={this.state.yAxisBounds}
onChange={this.onYAxisBoundsChange.bind(this)}
value={state.yAxisBounds}
onChange={onYAxisBoundsChange}
/>,
)}
{this.state.colType !== 'spark' &&
this.formRow(
{state.colType !== 'spark' &&
formRow(
t('Color bounds'),
t(`Number bounds used for color encoding from red to blue.
Reverse the numbers for blue to red. To get pure red or blue,
you can enter either only min or max.`),
'bounds',
<BoundsControl
value={this.state.bounds}
onChange={this.onBoundsChange.bind(this)}
/>,
<BoundsControl value={state.bounds} onChange={onBoundsChange} />,
)}
{this.formRow(
{formRow(
t('Number format'),
t('Optional d3 number format string'),
'd3-format',
<Input
value={this.state.d3format}
onChange={this.onTextInputChange.bind(this, 'd3format')}
value={state.d3format}
onChange={handleD3formatChange}
placeholder={t('Number format string')}
/>,
)}
{this.state.colType === 'spark' &&
this.formRow(
{state.colType === 'spark' &&
formRow(
t('Date format'),
t('Optional d3 date format string'),
'date-format',
<Input
value={this.state.dateFormat}
onChange={this.onTextInputChange.bind(this, 'dateFormat')}
value={state.dateFormat}
onChange={handleDateFormatChange}
placeholder={t('Date format string')}
/>,
)}
<ButtonBar>
<Button buttonSize="small" onClick={this.onClose} cta>
<Button buttonSize="small" onClick={onClose} cta>
{t('Close')}
</Button>
<Button
buttonStyle="primary"
buttonSize="small"
onClick={this.onSave}
cta
>
<Button buttonStyle="primary" buttonSize="small" onClick={onSave} cta>
{t('Save')}
</Button>
</ButtonBar>
</div>
);
}
}, [
state,
formRow,
onTextInputChange,
onSelectChange,
onCheckboxChange,
onBoundsChange,
onYAxisBoundsChange,
onClose,
onSave,
]);
render() {
return (
<span>
{this.textSummary()}{' '}
<ControlPopover
trigger="click"
content={this.renderPopover()}
title={t('Column Configuration')}
open={this.state.popoverVisible}
onOpenChange={this.onPopoverVisibleChange}
return (
<span>
{textSummary()}{' '}
<ControlPopover
trigger="click"
content={renderPopover()}
title={t('Column Configuration')}
open={state.popoverVisible}
onOpenChange={onPopoverVisibleChange}
>
<span
css={theme => ({
display: 'inline-block',
cursor: 'pointer',
'& svg path': {
fill: theme.colorIcon,
transition: `fill ${theme.motionDurationMid} ease-out`,
},
'&:hover svg path': {
fill: theme.colorPrimary,
},
})}
>
<span
css={theme => ({
display: 'inline-block',
cursor: 'pointer',
'& svg path': {
fill: theme.colorIcon,
transition: `fill ${theme.motionDurationMid} ease-out`,
},
'&:hover svg path': {
fill: theme.colorPrimary,
},
})}
>
<Icons.EditOutlined iconSize="s" />
</span>
</ControlPopover>
</span>
);
}
<Icons.EditOutlined iconSize="s" />
</span>
</ControlPopover>
</span>
);
}
export default TimeSeriesColumnControl;

View File

@@ -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<ViewportControlProps> {
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 => (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
value={value?.[ctrl]}
onChange={(ctrlValue: number) => handleChange(ctrl, ctrlValue)}
isFloat
/>
</div>
);
renderTextControl(ctrl: keyof Viewport): ReactNode {
return (
<div key={ctrl}>
<FormLabel>{ctrl}</FormLabel>
<TextControl
value={this.props.value?.[ctrl]}
onChange={(value: number) => this.onChange(ctrl, value)}
isFloat
/>
</div>
);
}
const renderPopover = (): ReactNode => (
<div id={`filter-popover-${name}`}>
{PARAMS.map(ctrl => renderTextControl(ctrl))}
</div>
);
renderPopover(): ReactNode {
return (
<div id={`filter-popover-${this.props.name}`}>
{PARAMS.map(ctrl => this.renderTextControl(ctrl))}
</div>
);
}
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 (
<div>
<ControlHeader {...this.props} />
<Popover
trigger="click"
placement="right"
content={this.renderPopover()}
title={t('Viewport')}
>
<Label className="pointer">{this.renderLabel()}</Label>
</Popover>
</div>
);
}
return (
<div>
<ControlHeader {...restProps} name={name} />
<Popover
trigger="click"
placement="right"
content={renderPopover()}
title={t('Viewport')}
>
<Label className="pointer">{renderLabel()}</Label>
</Popover>
</div>
);
}

View File

@@ -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: (
<div
css={(theme: SupersetTheme) => 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<RightMenuProps> = 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<RightMenuProps> {
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 <RightMenu setQuery={this.noop} {...this.props} />;
return <RightMenu setQuery={this.noop} {...rightMenuProps} />;
}
return this.props.children;
return children;
}
}

View File

@@ -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(
<ChartCreation
user={user}
addSuccessToast={() => null}
theme={supersetTheme}
{...routeProps}
/>,
<ChartCreation user={user} addSuccessToast={() => 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(
<ChartCreation
user={mockUser}
addSuccessToast={() => null}
theme={supersetTheme}
{...routeProps}
/>,
{
useRedux: true,
useRouter: true,
},
);
render(<ChartCreation user={mockUser} addSuccessToast={() => null} />, {
useRedux: true,
useRouter: true,
});
expect(screen.getByRole('status')).toBeInTheDocument();

View File

@@ -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<string | null>(null);
const [loading, setLoading] = useState<boolean>(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 ? (
<span data-test="dataset-write">
<Link to="/dataset/add/" data-test="add-chart-new-dataset">
{t('Add a dataset')}
</Link>{' '}
{t('or')}{' '}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
data-test="add-chart-new-dataset-instructions"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
) : (
<span data-test="no-dataset-write">
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
);
if (this.state.loading) {
return <Loading />;
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 (
<StyledContainer>
<h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small">
<Steps.Step
title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
status={this.state.datasource?.value ? 'finish' : 'process'}
description={
<StyledStepDescription className="dataset">
<AsyncSelect
autoFocus
ariaLabel={t('Dataset')}
name="select-datasource"
onChange={this.changeDatasource}
options={this.loadDatasources}
optionFilterProps={['id', 'table_name']}
placeholder={t('Choose a dataset')}
showSearch
value={this.state.datasource}
/>
{datasetHelpText}
</StyledStepDescription>
}
/>
<Steps.Step
title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={this.state.vizType ? 'finish' : 'process'}
description={
<StyledStepDescription>
<VizTypeGallery
denyList={denyList}
className="viz-gallery"
onChange={this.changeVizType}
onDoubleClick={this.onVizTypeDoubleClick}
selectedViz={this.state.vizType}
/>
</StyledStepDescription>
}
/>
</Steps>
<div className="footer">
{isButtonDisabled && (
<span>
{t('Please select both a Dataset and a Chart type to proceed')}
</span>
)}
<Button
buttonStyle="primary"
disabled={isButtonDisabled}
onClick={this.gotoSlice}
>
{t('Create new chart')}
</Button>
</div>
</StyledContainer>
);
const isButtonDisabled = isBtnDisabled();
const VIEW_INSTRUCTIONS_TEXT = t('view instructions');
const datasetHelpText = canCreateDataset ? (
<span data-test="dataset-write">
<Link to="/dataset/add/" data-test="add-chart-new-dataset">
{t('Add a dataset')}
</Link>{' '}
{t('or')}{' '}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
data-test="add-chart-new-dataset-instructions"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
) : (
<span data-test="no-dataset-write">
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${VIEW_INSTRUCTIONS_TEXT} `}
<Icons.Full iconSize="m" iconColor={theme.colorPrimary} />
</a>
.
</span>
);
if (loading) {
return <Loading />;
}
}
export default withRouter(withToasts(withTheme(ChartCreation)));
return (
<StyledContainer>
<h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small">
<Steps.Step
title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
status={datasource?.value ? 'finish' : 'process'}
description={
<StyledStepDescription className="dataset">
<AsyncSelect
autoFocus
ariaLabel={t('Dataset')}
name="select-datasource"
onChange={changeDatasource}
options={loadDatasources}
optionFilterProps={['id', 'table_name']}
placeholder={t('Choose a dataset')}
showSearch
value={datasource}
/>
{datasetHelpText}
</StyledStepDescription>
}
/>
<Steps.Step
title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={vizType ? 'finish' : 'process'}
description={
<StyledStepDescription>
<VizTypeGallery
denyList={denyList}
className="viz-gallery"
onChange={changeVizType}
onDoubleClick={onVizTypeDoubleClick}
selectedViz={vizType}
/>
</StyledStepDescription>
}
/>
</Steps>
<div className="footer">
{isButtonDisabled && (
<span>
{t('Please select both a Dataset and a Chart type to proceed')}
</span>
)}
<Button
buttonStyle="primary"
disabled={isButtonDisabled}
onClick={gotoSlice}
>
{t('Create new chart')}
</Button>
</div>
</StyledContainer>
);
};
export default withToasts(ChartCreation);