mirror of
https://github.com/apache/superset.git
synced 2026-07-21 06:05:46 +00:00
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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
<code>chartType="{chartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>"{formatNumber(formatString, v)}"</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>"{formatNumber(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
for how to write a D3 time format string.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<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>"{formatTime(formatString, v)}"</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
|
||||
<a
|
||||
href="https://github.com/d3/d3-time-format#locale_format"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
D3 Time Format Reference
|
||||
</a>
|
||||
for how to write a D3 time format string.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<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>"{formatTime(formatString, v)}"</code>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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('');
|
||||
});
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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'>
|
||||
>,
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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' } });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user