mirror of
https://github.com/apache/superset.git
synced 2026-05-20 15:25:12 +00:00
Compare commits
26 Commits
chat-proto
...
backup/lin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db03fb10e6 | ||
|
|
52758b36ff | ||
|
|
8d4e5a91f1 | ||
|
|
2a7af9a237 | ||
|
|
354004ec10 | ||
|
|
d36248460e | ||
|
|
ae2012a6e9 | ||
|
|
bedf0a47a6 | ||
|
|
ae13b10ce3 | ||
|
|
f37fab8a2d | ||
|
|
bf97203bc9 | ||
|
|
f29094d6a7 | ||
|
|
f050ffd6e1 | ||
|
|
18a6678f19 | ||
|
|
131096da90 | ||
|
|
4d8c3efd50 | ||
|
|
07a063df50 | ||
|
|
7de8453dec | ||
|
|
d783b7e68b | ||
|
|
bf3d55809f | ||
|
|
1e5493bb49 | ||
|
|
5fe8ba8fac | ||
|
|
5f89ee3af1 | ||
|
|
0879c8cddc | ||
|
|
319fb87e44 | ||
|
|
5dffbc26ed |
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ChartDataProvider,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import Expandable from './Expandable';
|
||||
import VerifyCORS, { renderError } from './VerifyCORS';
|
||||
|
||||
@@ -64,6 +65,7 @@ export default function createQueryStory({
|
||||
return (
|
||||
<>
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={chartType}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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,106 @@ 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): JSX.Element | null {
|
||||
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
|
||||
// Note: handleFetchData depends on callback props, so changing callbacks
|
||||
// will also trigger a refetch. This mirrors the original class behavior
|
||||
// where componentDidMount always fetched.
|
||||
useEffect(() => {
|
||||
handleFetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, sliceId]);
|
||||
|
||||
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 }) as JSX.Element;
|
||||
case 'loaded':
|
||||
return children({ payload }) as JSX.Element;
|
||||
case 'error':
|
||||
return children({ error }) as JSX.Element;
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { t } from '@apache-superset/core';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import {
|
||||
QueryFormData,
|
||||
QueryData,
|
||||
@@ -34,6 +35,7 @@ import getChartBuildQueryRegistry from '../registries/ChartBuildQueryRegistrySin
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import getChartControlPanelRegistry from '../registries/ChartControlPanelRegistrySingleton';
|
||||
import SuperChart from './SuperChart';
|
||||
import { SupersetTheme } from '@apache-superset/core/ui';
|
||||
|
||||
// Using more specific states that align with chart loading process
|
||||
type LoadingState = 'uninitialized' | 'loading' | 'loaded' | 'error';
|
||||
@@ -185,6 +187,8 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
const [error, setError] = useState<Error>();
|
||||
const [formData, setFormData] = useState<QueryFormData>();
|
||||
|
||||
const theme = useTheme() as SupersetTheme;
|
||||
|
||||
const chartClientRef = useRef<ChartClient>();
|
||||
const abortControllerRef = useRef<AbortController>();
|
||||
|
||||
@@ -484,6 +488,7 @@ export default function StatefulChart(props: StatefulChartProps) {
|
||||
onRenderSuccess={onRenderSuccess}
|
||||
onRenderFailure={onRenderFailure}
|
||||
hooks={hooks}
|
||||
theme={theme}
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -56,7 +55,9 @@ export type WrapperProps = Dimension & {
|
||||
};
|
||||
|
||||
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
Omit<ChartPropsConfig, 'width' | 'height'> & {
|
||||
Omit<ChartPropsConfig, 'width' | 'height' | 'theme'> & {
|
||||
/** Theme object (optional, falls back to ThemeProvider context) */
|
||||
theme?: SupersetTheme;
|
||||
/**
|
||||
* Set this to true to disable error boundary built-in in SuperChart
|
||||
* and let the error propagate to upper level
|
||||
@@ -102,215 +103,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,82 @@ 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) {
|
||||
// Preserve legacy behavior where `this` was a component instance
|
||||
// exposing a `container` property
|
||||
callbacks.componentWillUnmount.call({
|
||||
container: containerRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
ChartDataProvider,
|
||||
SupersetClient,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { BigNumberChartPlugin } from '@superset-ui/plugin-chart-echarts';
|
||||
import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud';
|
||||
|
||||
@@ -88,6 +89,7 @@ export const dataProvider = ({
|
||||
return (
|
||||
<>
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={visType}
|
||||
formData={payload.formData}
|
||||
height={Number(height)}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
DiligentChartPlugin,
|
||||
BuggyChartPlugin,
|
||||
@@ -37,6 +38,7 @@ export default {
|
||||
|
||||
export const basic = ({ width, height }: { width: string; height: string }) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -61,6 +63,7 @@ export const container50pct = ({
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -83,6 +86,7 @@ export const Resizable = () => (
|
||||
<ResizableChartDemo>
|
||||
{size => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={size.width}
|
||||
height={size.height}
|
||||
@@ -100,6 +104,7 @@ export const fixedWidth100height = ({
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
height={height}
|
||||
width={width}
|
||||
@@ -125,6 +130,7 @@ export const fixedHeight100Width = ({
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
height={height}
|
||||
width={width}
|
||||
@@ -149,6 +155,7 @@ export const withErrorBoundary = ({
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.BUGGY}
|
||||
height={height}
|
||||
width={width}
|
||||
@@ -173,6 +180,7 @@ export const withWrapper = ({
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -202,7 +210,12 @@ export const withNoResults = ({
|
||||
width: string;
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width={width} height={height} />
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
withNoResults.storyName = 'With no results';
|
||||
withNoResults.args = {
|
||||
@@ -221,7 +234,12 @@ export const withNoResultsAndMedium = ({
|
||||
width: string;
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width={width} height={height} />
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
|
||||
withNoResultsAndMedium.storyName = 'With no results and medium';
|
||||
@@ -241,7 +259,12 @@ export const withNoResultsAndSmall = ({
|
||||
width: string;
|
||||
height: string;
|
||||
}) => (
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width={width} height={height} />
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
);
|
||||
withNoResultsAndSmall.storyName = 'With no results and small';
|
||||
withNoResultsAndSmall.args = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,6 +24,7 @@ import { triggerResizeObserver } from 'resize-observer-polyfill';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
|
||||
import { promiseTimeout, SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { WrapperProps } from '../../../src/chart/components/SuperChart';
|
||||
|
||||
import {
|
||||
@@ -118,6 +119,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -138,6 +140,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
FallbackComponent={CustomFallbackComponent}
|
||||
/>,
|
||||
);
|
||||
@@ -154,6 +157,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={handleError}
|
||||
/>,
|
||||
);
|
||||
@@ -178,6 +182,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={inactiveErrorHandler}
|
||||
/>
|
||||
</ErrorBoundary>,
|
||||
@@ -205,6 +210,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -285,6 +291,7 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -332,6 +339,7 @@ describe('SuperChart', () => {
|
||||
queriesData={DEFAULT_QUERIES_DATA}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -347,7 +355,12 @@ describe('SuperChart', () => {
|
||||
describe('supports NoResultsComponent', () => {
|
||||
test('renders NoResultsComponent when queriesData is missing', () => {
|
||||
render(
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
|
||||
<SuperChart
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
@@ -360,6 +373,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[{ data: null }]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -387,6 +401,7 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={100}
|
||||
height={100}
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -411,6 +426,7 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
Wrapper={MyWrapper}
|
||||
/>
|
||||
</div>,
|
||||
@@ -475,6 +491,7 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -500,6 +517,7 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: null }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -527,6 +545,7 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
noResults={<CustomNoResults />}
|
||||
@@ -556,6 +575,7 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
onErrorBoundary={onErrorBoundary}
|
||||
|
||||
@@ -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);
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import CalendarChartPlugin from '@superset-ui/legacy-plugin-chart-calendar';
|
||||
import data from './data';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -100,6 +101,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="calendar"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import ChordChartPlugin from '@superset-ui/legacy-plugin-chart-chord';
|
||||
import data from './data';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -67,6 +68,7 @@ export const Basic = ({
|
||||
sortByMetric: boolean;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Chord}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { JsonObject, seed, SuperChart, SequentialD3 } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { useTheme } from '@apache-superset/core/ui';
|
||||
import CountryMapChartPlugin, {
|
||||
countries,
|
||||
@@ -91,6 +92,7 @@ export const BasicCountryMapStory = ({
|
||||
}
|
||||
return (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="country-map"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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.length > 0
|
||||
? 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);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import HorizonChartPlugin from '@superset-ui/legacy-plugin-chart-horizon';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -55,6 +56,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Horizon}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -82,6 +82,7 @@ export const MapBoxViz = ({
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
theme={theme}
|
||||
chartType="map-box"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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,311 @@ 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;
|
||||
if (sumControl === 0) return 'NaN';
|
||||
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) {
|
||||
const computeTTest = useCallback(
|
||||
(controlIndex: number) => {
|
||||
// Compute lift and p-values for each row
|
||||
// against the selected control
|
||||
const newPValues: (string | number)[] = [];
|
||||
const newLiftValues: (string | number)[] = [];
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
for (let i = 0; i < data.length; i += 1) {
|
||||
if (i === controlIndex) {
|
||||
newPValues.push('control');
|
||||
newLiftValues.push('control');
|
||||
} else {
|
||||
newPValues.push(
|
||||
computePValue(data[i].values, data[controlIndex].values),
|
||||
);
|
||||
newLiftValues.push(
|
||||
computeLift(data[i].values, data[controlIndex].values),
|
||||
);
|
||||
}
|
||||
}
|
||||
setControl(controlIndex);
|
||||
setLiftValues(newLiftValues);
|
||||
setPValues(newPValues);
|
||||
},
|
||||
[data, computeLift, computePValue],
|
||||
);
|
||||
|
||||
// Recompute table when data or control row changes, keeping control index in range
|
||||
useEffect(() => {
|
||||
if (!data || data.length === 0) {
|
||||
setControl(0);
|
||||
setLiftValues([]);
|
||||
setPValues([]);
|
||||
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 safeControlIndex = Math.min(control, data.length - 1);
|
||||
if (safeControlIndex !== control) {
|
||||
setControl(safeControlIndex);
|
||||
computeTTest(safeControlIndex);
|
||||
} else {
|
||||
computeTTest(control);
|
||||
}
|
||||
this.setState({ control, liftValues, pValues });
|
||||
}, [computeTTest, control, data]);
|
||||
|
||||
const getLiftStatus = useCallback(
|
||||
(row: number): string => {
|
||||
// Get a css class name for coloring
|
||||
if (row === control) {
|
||||
return 'control';
|
||||
}
|
||||
const liftVal = liftValues[row];
|
||||
const numericLiftVal = Number(liftVal);
|
||||
if (Number.isNaN(numericLiftVal) || !Number.isFinite(numericLiftVal)) {
|
||||
return 'invalid'; // infinite or NaN values
|
||||
}
|
||||
|
||||
return numericLiftVal >= 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];
|
||||
const numericPVal = Number(pVal);
|
||||
if (Number.isNaN(numericPVal) || !Number.isFinite(numericPVal)) {
|
||||
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 new 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;
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import PairedTTestChartPlugin from '@superset-ui/legacy-plugin-chart-paired-t-test';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -63,6 +64,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="paired-t-test"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import TTestTable from '../src/TTestTable';
|
||||
import type { DataEntry } from '../src/TTestTable';
|
||||
|
||||
// Mock the distributions module to return a predictable cdf value.
|
||||
// cdf returns 0.01 so that p-value = 2 * 0.01 = 0.02
|
||||
jest.mock('distributions', () => {
|
||||
class MockStudentt {
|
||||
cdf(_x: number): number {
|
||||
return 0.01;
|
||||
}
|
||||
}
|
||||
return {
|
||||
__esModule: true,
|
||||
default: { Studentt: MockStudentt },
|
||||
};
|
||||
});
|
||||
|
||||
const mockData: DataEntry[] = [
|
||||
{
|
||||
group: ['group-A'],
|
||||
values: [
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 2, y: 20 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: ['group-B'],
|
||||
values: [
|
||||
{ x: 1, y: 15 },
|
||||
{ x: 2, y: 25 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultProps = {
|
||||
alpha: 0.05,
|
||||
data: mockData,
|
||||
groups: ['category'],
|
||||
liftValPrec: 4,
|
||||
metric: 'revenue',
|
||||
pValPrec: 6,
|
||||
};
|
||||
|
||||
test('renders the metric name as an h3 heading', async () => {
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('revenue')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const heading = screen.getByText('revenue');
|
||||
expect(heading.tagName).toBe('H3');
|
||||
});
|
||||
|
||||
test('renders a table with the correct column headers', async () => {
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('category')).toBeInTheDocument();
|
||||
expect(screen.getByText('p-value')).toBeInTheDocument();
|
||||
expect(screen.getByText('Lift %')).toBeInTheDocument();
|
||||
expect(screen.getByText('Significant')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders group columns matching the groups prop', async () => {
|
||||
const multiGroupData: DataEntry[] = [
|
||||
{
|
||||
group: ['group-A', 'sub-1'],
|
||||
values: [{ x: 1, y: 10 }],
|
||||
},
|
||||
{
|
||||
group: ['group-B', 'sub-2'],
|
||||
values: [{ x: 1, y: 15 }],
|
||||
},
|
||||
];
|
||||
|
||||
render(
|
||||
<TTestTable
|
||||
{...defaultProps}
|
||||
groups={['category', 'subcategory']}
|
||||
data={multiGroupData}
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('category')).toBeInTheDocument();
|
||||
expect(screen.getByText('subcategory')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('first row is treated as control by default and shows "control" for p-value and lift columns', async () => {
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
// After componentDidMount, the first row should be control
|
||||
await waitFor(() => {
|
||||
const controlTexts = screen.getAllByText('control');
|
||||
// The control row has "control" in pValue and liftValue columns
|
||||
expect(controlTexts.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('computes lift values correctly for non-control rows', async () => {
|
||||
// Control (group-A): sum of y = 10 + 20 = 30
|
||||
// group-B: sum of y = 15 + 25 = 40
|
||||
// Lift = ((40 - 30) / 30) * 100 = 33.3333%
|
||||
// With liftValPrec=4 => "33.3333"
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('33.3333')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('computes p-value using the mocked distributions module', async () => {
|
||||
// Mock cdf returns 0.01, so p-value = 2 * 0.01 = 0.02
|
||||
// With pValPrec=6 => "0.020000"
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('0.020000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('marks non-control row as significant when p-value is below alpha', async () => {
|
||||
// p-value = 0.02 < alpha = 0.05, so significance is true
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('marks non-control row as not significant when p-value is above alpha', async () => {
|
||||
// p-value = 0.02 > alpha = 0.01, so significance is false
|
||||
render(<TTestTable {...defaultProps} alpha={0.01} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('false')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('returns NaN lift when control values sum to zero (division by zero guard)', async () => {
|
||||
const zeroControlData: DataEntry[] = [
|
||||
{
|
||||
group: ['zero-group'],
|
||||
values: [
|
||||
{ x: 1, y: 0 },
|
||||
{ x: 2, y: 0 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: ['other-group'],
|
||||
values: [
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 2, y: 20 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<TTestTable {...defaultProps} data={zeroControlData} />);
|
||||
|
||||
// The lift computation: ((sumValues - sumControl) / sumControl) * 100
|
||||
// = ((30 - 0) / 0) * 100 = Infinity
|
||||
// Infinity.toFixed(4) in jsdom returns "NaN", and the component renders it.
|
||||
// The getLiftStatus method classifies this as "invalid" (NaN or non-finite).
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('NaN')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('throws an error when groups array is empty', () => {
|
||||
// Suppress React error boundary console output for this test
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
expect(() => {
|
||||
render(<TTestTable {...defaultProps} groups={[]} />);
|
||||
}).toThrow('Group by param is required');
|
||||
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('clicking a non-control row changes it to the new control', async () => {
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
// Wait for initial render with group-A as control
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('group-A')).toBeInTheDocument();
|
||||
expect(screen.getByText('group-B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initially group-A is control, so its row shows "control" in p-value and lift columns.
|
||||
// The non-control row (group-B) shows computed values.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('33.3333')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the group-B row to make it the new control.
|
||||
// The row containing "group-B" text is what we need to click.
|
||||
const groupBCell = screen.getByText('group-B');
|
||||
const groupBRow = groupBCell.closest('tr');
|
||||
expect(groupBRow).not.toBeNull();
|
||||
fireEvent.click(groupBRow!);
|
||||
|
||||
// After clicking, group-B becomes control.
|
||||
// group-A lift: ((30 - 40) / 40) * 100 = -25.0000
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('-25.0000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders group name data in the table cells', async () => {
|
||||
render(<TTestTable {...defaultProps} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('group-A')).toBeInTheDocument();
|
||||
expect(screen.getByText('group-B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders with three data rows and computes values for each non-control row', async () => {
|
||||
const threeRowData: DataEntry[] = [
|
||||
{
|
||||
group: ['control-group'],
|
||||
values: [
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 2, y: 10 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: ['test-group-1'],
|
||||
values: [
|
||||
{ x: 1, y: 15 },
|
||||
{ x: 2, y: 15 },
|
||||
],
|
||||
},
|
||||
{
|
||||
group: ['test-group-2'],
|
||||
values: [
|
||||
{ x: 1, y: 20 },
|
||||
{ x: 2, y: 20 },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
render(<TTestTable {...defaultProps} data={threeRowData} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('control-group')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-group-1')).toBeInTheDocument();
|
||||
expect(screen.getByText('test-group-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// control-group: sum = 20
|
||||
// test-group-1: sum = 30, lift = ((30-20)/20)*100 = 50.0000
|
||||
// test-group-2: sum = 40, lift = ((40-20)/20)*100 = 100.0000
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('50.0000')).toBeInTheDocument();
|
||||
expect(screen.getByText('100.0000')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import ParallelCoordinatesChartPlugin from '@superset-ui/legacy-plugin-chart-parallel-coordinates';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -70,6 +71,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="parallel-coordinates"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import PartitionChartPlugin from '@superset-ui/legacy-plugin-chart-partition';
|
||||
import data from './data';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -79,6 +80,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Partition}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
/* eslint-disable no-magic-numbers, sort-keys */
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import RoseChartPlugin from '@superset-ui/legacy-plugin-chart-rose';
|
||||
import data from './data';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -80,6 +81,7 @@ export const Basic = ({
|
||||
roseAreaProportion: boolean;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Rose}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
/* eslint-disable no-magic-numbers, sort-keys */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import WorldMapChartPlugin from '@superset-ui/legacy-plugin-chart-world-map';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -53,6 +54,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="world-map"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { ArcChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -66,6 +67,7 @@ export const ArcChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_arc"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { GridChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -61,6 +62,7 @@ export const GridChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_grid"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { HexChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -61,6 +62,7 @@ export const HexChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_hex"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { useTheme } from '@apache-superset/core/ui';
|
||||
import { PathChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
@@ -57,6 +58,7 @@ export const PathChartViz = ({
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_path"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { PolygonChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -83,6 +84,7 @@ export const PolygonChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_polygon"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -164,6 +166,7 @@ export const GeojsonPolygonViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_polygon"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { ScatterChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -68,6 +69,7 @@ export const ScatterChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_scatter"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint-disable sort-keys */
|
||||
/* eslint-disable no-magic-numbers */
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { ScreengridChartPlugin } from '@superset-ui/legacy-preset-chart-deckgl';
|
||||
import { withResizableChartDemo, dummyDatasource } from '@storybook-shared';
|
||||
import payload from './payload';
|
||||
@@ -55,6 +56,7 @@ export const ScreengridChartViz = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="deck_screengrid"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { EchartsBoxPlotChartPlugin } from '@superset-ui/plugin-chart-echarts';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -65,6 +66,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="box-plot"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { BubbleChartPlugin } from '@superset-ui/legacy-preset-chart-nvd3';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -71,6 +72,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.LegacyBubble}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { BulletChartPlugin } from '@superset-ui/legacy-preset-chart-nvd3';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -69,6 +70,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Bullet}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { CompareChartPlugin } from '@superset-ui/legacy-preset-chart-nvd3';
|
||||
import { dummyDatasource, withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -64,6 +65,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="compare"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -160,6 +162,7 @@ const timeFormatData = [
|
||||
|
||||
export const timeFormat = () => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="compare"
|
||||
width={400}
|
||||
height={400}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import AgGridTableChartPlugin from '../index';
|
||||
import transformProps from '../transformProps';
|
||||
import { basicFormData, basicData } from './data';
|
||||
@@ -74,6 +75,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { CartodiagramPlugin } from '@superset-ui/plugin-chart-cartodiagram';
|
||||
import {
|
||||
EchartsPieChartPlugin,
|
||||
@@ -145,6 +146,7 @@ export const BasicMap = ({
|
||||
borderRadius: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { BigNumberTotalChartPlugin } from '@superset-ui/plugin-chart-echarts';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -57,6 +58,7 @@ export const TotalBasic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number-total"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -79,6 +81,7 @@ export const TotalNoData = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number-total"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { BigNumberChartPlugin } from '@superset-ui/plugin-chart-echarts';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import testData from './data';
|
||||
@@ -99,6 +100,7 @@ export const BasicWithTrendline = ({
|
||||
yAxisFormat: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -116,6 +118,7 @@ export const BasicWithTrendline = ({
|
||||
|
||||
export const weeklyTimeGranularity = () => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number"
|
||||
width={400}
|
||||
height={400}
|
||||
@@ -129,6 +132,7 @@ export const weeklyTimeGranularity = () => (
|
||||
|
||||
export const nullInTheMiddle = () => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number"
|
||||
width={400}
|
||||
height={400}
|
||||
@@ -139,6 +143,7 @@ export const nullInTheMiddle = () => (
|
||||
|
||||
export const fixedRange = () => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number"
|
||||
width={400}
|
||||
height={400}
|
||||
@@ -158,6 +163,7 @@ export const fixedRange = () => (
|
||||
|
||||
export const noFixedRange = () => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="big-number"
|
||||
width={400}
|
||||
height={400}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsBoxPlotChartPlugin,
|
||||
BoxPlotTransformProps,
|
||||
@@ -59,6 +60,7 @@ export const BoxPlot = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-boxplot"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { simpleBubbleData } from './data';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
|
||||
@@ -109,6 +110,7 @@ export const BubbleChart = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Bubble}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsFunnelChartPlugin,
|
||||
FunnelTransformProps,
|
||||
@@ -93,6 +94,7 @@ export const Funnel = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Funnel}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsGaugeChartPlugin,
|
||||
GaugeTransformProps,
|
||||
@@ -96,6 +97,7 @@ export const Gauge = ({
|
||||
endAngle: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-gauge"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsGraphChartPlugin,
|
||||
GraphTransformProps,
|
||||
@@ -114,6 +115,7 @@ export const Graph = ({
|
||||
showSymbolThreshold: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-graph"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsTimeseriesChartPlugin,
|
||||
MixedTimeseriesTransformProps,
|
||||
@@ -108,6 +109,7 @@ export const Timeseries = ({
|
||||
];
|
||||
return (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="mixed-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -260,6 +262,7 @@ export const WithNegativeNumbers = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="mixed-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsPieChartPlugin,
|
||||
PieTransformProps,
|
||||
@@ -62,6 +63,7 @@ export const WeekdayPie = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Pie}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -141,6 +143,7 @@ export const PopulationPie = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Pie}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -222,6 +225,7 @@ export const SalesPie = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Pie}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsRadarChartPlugin,
|
||||
RadarTransformProps,
|
||||
@@ -94,6 +95,7 @@ export const Radar = ({
|
||||
numberFormat: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Radar}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsSunburstChartPlugin,
|
||||
SunburstTransformProps,
|
||||
@@ -51,6 +52,7 @@ export const Sunburst = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-sunburst"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
getChartTransformPropsRegistry,
|
||||
VizType,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsAreaChartPlugin,
|
||||
TimeseriesTransformProps,
|
||||
@@ -174,6 +175,7 @@ export const AreaSeries = ({
|
||||
.filter(row => forecastEnabled || !!row.Boston);
|
||||
return (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Area}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsTimeseriesChartPlugin,
|
||||
TimeseriesTransformProps,
|
||||
@@ -90,6 +91,7 @@ export const Timeseries = ({
|
||||
.filter(row => forecastEnabled || !!row.Boston);
|
||||
return (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -169,6 +171,7 @@ export const WithNegativeNumbers = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -213,6 +216,7 @@ export const ConfidenceBand = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -245,6 +249,7 @@ export const StackWithNulls = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-timeseries"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsTreeChartPlugin,
|
||||
TreeTransformProps,
|
||||
@@ -106,6 +107,7 @@ export const Tree = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-tree"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsTreemapChartPlugin,
|
||||
TreemapTransformProps,
|
||||
@@ -53,6 +54,7 @@ export const Treemap = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="echarts-treemap"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
VizType,
|
||||
getChartTransformPropsRegistry,
|
||||
} from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import {
|
||||
EchartsWaterfallChartPlugin,
|
||||
WaterfallTransformProps,
|
||||
@@ -80,6 +81,7 @@ export const Waterfall = ({
|
||||
yAxisFormat: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Waterfall}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, getChartTransformPropsRegistry } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { HandlebarsChartPlugin } from '@superset-ui/plugin-chart-handlebars';
|
||||
import { kpiData, leaderboardData, timelineData } from './data';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -150,6 +151,7 @@ export const InteractiveHandlebars = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -194,6 +196,7 @@ export const KPIDashboard = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -221,6 +224,7 @@ export const Leaderboard = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
@@ -248,6 +252,7 @@ export const Timeline = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VIZ_TYPE}
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { PivotTableChartPlugin } from '@superset-ui/plugin-chart-pivot-table';
|
||||
import { basicFormData, basicData } from './testData';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
@@ -92,6 +93,7 @@ export const Basic = ({
|
||||
colSubtotalPosition: string;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.PivotTable}
|
||||
datasource={{
|
||||
columnFormats: {},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
@@ -16,576 +16,316 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import '@testing-library/jest-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { supersetTheme, ThemeProvider } from '@apache-superset/core/ui';
|
||||
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
|
||||
import type { PivotData } from '../../src/react-pivottable/utilities';
|
||||
import { aggregatorTemplates } from '../../src/react-pivottable/utilities';
|
||||
|
||||
let tableRenderer: TableRenderer;
|
||||
let mockGetAggregatedData: jest.Mock;
|
||||
let mockSortAndCacheData: jest.Mock;
|
||||
jest.mock(
|
||||
'react-icons/fa',
|
||||
() => ({
|
||||
FaSort: () => <span data-testid="sort-icon" />,
|
||||
FaSortDown: () => <span data-testid="sort-desc-icon" />,
|
||||
FaSortUp: () => <span data-testid="sort-asc-icon" />,
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
const columnIndex = 0;
|
||||
const visibleColKeys = [['col1'], ['col2']];
|
||||
const pivotData = {
|
||||
subtotals: {
|
||||
rowEnabled: true,
|
||||
rowPartialOnTop: false,
|
||||
},
|
||||
} as any;
|
||||
const maxRowIndex = 2;
|
||||
|
||||
const mockProps = {
|
||||
rows: ['row1'],
|
||||
cols: ['col1'],
|
||||
data: [],
|
||||
aggregatorName: 'Sum',
|
||||
vals: ['value'],
|
||||
valueFilter: {},
|
||||
sorters: {},
|
||||
rowOrder: 'key_a_to_z',
|
||||
colOrder: 'key_a_to_z',
|
||||
tableOptions: {},
|
||||
namesMapping: {},
|
||||
allowRenderHtml: false,
|
||||
onContextMenu: jest.fn(),
|
||||
aggregatorsFactory: jest.fn(),
|
||||
defaultFormatter: jest.fn(),
|
||||
customFormatters: {},
|
||||
rowEnabled: true,
|
||||
rowPartialOnTop: false,
|
||||
colEnabled: false,
|
||||
colPartialOnTop: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
|
||||
mockGetAggregatedData = jest.fn();
|
||||
mockSortAndCacheData = jest.fn();
|
||||
|
||||
tableRenderer.getAggregatedData = mockGetAggregatedData;
|
||||
tableRenderer.sortAndCacheData = mockSortAndCacheData;
|
||||
|
||||
tableRenderer.cachedBasePivotSettings = {
|
||||
pivotData: {
|
||||
subtotals: {
|
||||
rowEnabled: true,
|
||||
rowPartialOnTop: false,
|
||||
colEnabled: false,
|
||||
colPartialOnTop: false,
|
||||
},
|
||||
},
|
||||
rowKeys: [['A'], ['B'], ['C']],
|
||||
} as any;
|
||||
|
||||
tableRenderer.state = {
|
||||
sortingOrder: [],
|
||||
activeSortColumn: null,
|
||||
collapsedRows: {},
|
||||
collapsedCols: {},
|
||||
} as any;
|
||||
/**
|
||||
* A minimal aggregatorsFactory that mirrors the production one.
|
||||
* PivotData's constructor calls `aggregatorsFactory(defaultFormatter)`
|
||||
* to obtain a map of aggregator constructors keyed by name.
|
||||
* The `formatter` argument is ignored here because the tests only
|
||||
* care about rendering output, not number formatting precision.
|
||||
*/
|
||||
const aggregatorsFactory = () => ({
|
||||
Count: aggregatorTemplates.count(),
|
||||
Sum: aggregatorTemplates.sum(),
|
||||
});
|
||||
|
||||
const mockGroups = {
|
||||
B: {
|
||||
currentVal: 20,
|
||||
B1: { currentVal: 15 },
|
||||
B2: { currentVal: 5 },
|
||||
},
|
||||
A: {
|
||||
currentVal: 10,
|
||||
A1: { currentVal: 8 },
|
||||
A2: { currentVal: 2 },
|
||||
},
|
||||
C: {
|
||||
currentVal: 30,
|
||||
C1: { currentVal: 25 },
|
||||
C2: { currentVal: 5 },
|
||||
},
|
||||
};
|
||||
const SAMPLE_DATA = [
|
||||
{ color: 'blue', shape: 'circle', value: 10 },
|
||||
{ color: 'blue', shape: 'square', value: 20 },
|
||||
{ color: 'red', shape: 'circle', value: 30 },
|
||||
{ color: 'red', shape: 'square', value: 40 },
|
||||
];
|
||||
|
||||
const createMockPivotData = (rowData: Record<string, number>) =>
|
||||
({
|
||||
rowKeys: Object.keys(rowData).map(key => key.split('.')),
|
||||
getAggregator: (rowKey: string[], colName: string) => ({
|
||||
value: () => rowData[rowKey.join('.')],
|
||||
}),
|
||||
}) as unknown as PivotData;
|
||||
function renderWithTheme(ui: React.ReactElement) {
|
||||
return render(<ThemeProvider theme={supersetTheme}>{ui}</ThemeProvider>);
|
||||
}
|
||||
|
||||
test('should set initial ascending sort when no active sort column', () => {
|
||||
mockGetAggregatedData.mockReturnValue({
|
||||
A: { currentVal: 30 },
|
||||
B: { currentVal: 10 },
|
||||
C: { currentVal: 20 },
|
||||
});
|
||||
|
||||
const setStateMock = jest.fn();
|
||||
tableRenderer.setState = setStateMock;
|
||||
|
||||
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
|
||||
|
||||
expect(setStateMock).toHaveBeenCalled();
|
||||
|
||||
const [stateUpdater] = setStateMock.mock.calls[0];
|
||||
|
||||
expect(typeof stateUpdater).toBe('function');
|
||||
|
||||
const previousState = {
|
||||
sortingOrder: [],
|
||||
activeSortColumn: 0,
|
||||
function buildDefaultProps(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
data: SAMPLE_DATA,
|
||||
rows: ['color'] as string[],
|
||||
cols: ['shape'] as string[],
|
||||
aggregatorName: 'Count',
|
||||
vals: [] as string[],
|
||||
aggregatorsFactory,
|
||||
tableOptions: {},
|
||||
onContextMenu: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const newState = stateUpdater(previousState);
|
||||
test('TableRenderer renders a table element with the pvtTable class', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
expect(newState.sortingOrder[columnIndex]).toBe('asc');
|
||||
expect(newState.activeSortColumn).toBe(columnIndex);
|
||||
|
||||
expect(mockGetAggregatedData).toHaveBeenCalledWith(
|
||||
pivotData,
|
||||
visibleColKeys[columnIndex],
|
||||
false,
|
||||
);
|
||||
|
||||
expect(mockSortAndCacheData).toHaveBeenCalledWith(
|
||||
{ A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } },
|
||||
'asc',
|
||||
true,
|
||||
false,
|
||||
maxRowIndex,
|
||||
);
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
expect(table).toHaveClass('pvtTable');
|
||||
});
|
||||
|
||||
test('should toggle from asc to desc when clicking same column', () => {
|
||||
mockGetAggregatedData.mockReturnValue({
|
||||
A: { currentVal: 30 },
|
||||
B: { currentVal: 10 },
|
||||
C: { currentVal: 20 },
|
||||
test('TableRenderer renders column headers from pivot data', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// The column attribute values ("circle" and "square") should appear as
|
||||
// column headers in the rendered table.
|
||||
expect(screen.getByText('circle')).toBeInTheDocument();
|
||||
expect(screen.getByText('square')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('TableRenderer renders row headers from pivot data', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// The row attribute values ("blue" and "red") should appear as
|
||||
// row headers in the rendered table.
|
||||
expect(screen.getByText('blue')).toBeInTheDocument();
|
||||
expect(screen.getByText('red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('TableRenderer renders aggregated cell values', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// With "Count" aggregator, each cell (row x col intersection) should
|
||||
// contain "1" because each combination appears exactly once.
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
const cellTexts = cells.map(cell => cell.textContent);
|
||||
|
||||
// There should be cell values of "1" for each of the four intersections
|
||||
// (blue+circle, blue+square, red+circle, red+square).
|
||||
const onesCount = cellTexts.filter(text => text === '1').length;
|
||||
expect(onesCount).toBeGreaterThanOrEqual(4);
|
||||
});
|
||||
|
||||
test('TableRenderer renders row totals when rowTotals is enabled', () => {
|
||||
const props = buildDefaultProps({
|
||||
tableOptions: { rowTotals: true, colTotals: true },
|
||||
});
|
||||
const setStateMock = jest.fn(stateUpdater => {
|
||||
if (typeof stateUpdater === 'function') {
|
||||
const newState = stateUpdater({
|
||||
sortingOrder: ['asc' as never],
|
||||
activeSortColumn: 0,
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
tableRenderer.state = {
|
||||
...tableRenderer.state,
|
||||
...newState,
|
||||
};
|
||||
}
|
||||
// Row totals column should show "2" for each color (blue has 2 records,
|
||||
// red has 2 records).
|
||||
const totalCells = screen
|
||||
.getAllByRole('gridcell')
|
||||
.filter(cell => cell.classList.contains('pvtTotal'));
|
||||
expect(totalCells.length).toBeGreaterThan(0);
|
||||
|
||||
const totalValues = totalCells.map(cell => cell.textContent);
|
||||
expect(totalValues).toContain('2');
|
||||
});
|
||||
|
||||
test('TableRenderer renders col totals row when colTotals is enabled', () => {
|
||||
const props = buildDefaultProps({
|
||||
tableOptions: { rowTotals: true, colTotals: true },
|
||||
});
|
||||
tableRenderer.setState = setStateMock;
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
|
||||
|
||||
expect(mockSortAndCacheData).toHaveBeenCalledWith(
|
||||
{ A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } },
|
||||
'desc',
|
||||
true,
|
||||
false,
|
||||
maxRowIndex,
|
||||
);
|
||||
// The totals row should have cells with class pvtRowTotal.
|
||||
const rowTotalCells = screen
|
||||
.getAllByRole('gridcell')
|
||||
.filter(cell => cell.classList.contains('pvtRowTotal'));
|
||||
expect(rowTotalCells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should check second call in sequence', () => {
|
||||
mockGetAggregatedData.mockReturnValue({
|
||||
A: { currentVal: 30 },
|
||||
B: { currentVal: 10 },
|
||||
C: { currentVal: 20 },
|
||||
test('TableRenderer renders grand total when both totals are enabled', () => {
|
||||
const props = buildDefaultProps({
|
||||
tableOptions: { rowTotals: true, colTotals: true },
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
mockSortAndCacheData.mockClear();
|
||||
// The grand total cell should show "4" (total record count).
|
||||
const grandTotalCells = screen
|
||||
.getAllByRole('gridcell')
|
||||
.filter(cell => cell.classList.contains('pvtGrandTotal'));
|
||||
expect(grandTotalCells.length).toBe(1);
|
||||
expect(grandTotalCells[0]).toHaveTextContent('4');
|
||||
});
|
||||
|
||||
const setStateMock = jest.fn(stateUpdater => {
|
||||
if (typeof stateUpdater === 'function') {
|
||||
const newState = stateUpdater(tableRenderer.state);
|
||||
tableRenderer.state = {
|
||||
...tableRenderer.state,
|
||||
...newState,
|
||||
};
|
||||
}
|
||||
test('TableRenderer handles empty data gracefully', () => {
|
||||
const props = buildDefaultProps({ data: [] });
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// The table should still render without crashing, just with no data rows.
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
// With empty data, there are no regular value cells (pvtVal).
|
||||
const valueCells = document.querySelectorAll('.pvtVal');
|
||||
expect(valueCells).toHaveLength(0);
|
||||
|
||||
// No row headers should be present.
|
||||
const rowLabels = document.querySelectorAll('.pvtRowLabel');
|
||||
expect(rowLabels).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('TableRenderer handles data with no rows dimension', () => {
|
||||
const props = buildDefaultProps({
|
||||
rows: [],
|
||||
cols: ['color'],
|
||||
});
|
||||
tableRenderer.setState = setStateMock;
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
tableRenderer.state = {
|
||||
sortingOrder: [],
|
||||
activeSortColumn: 0,
|
||||
collapsedRows: {},
|
||||
collapsedCols: {},
|
||||
} as any;
|
||||
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
tableRenderer.state = {
|
||||
sortingOrder: ['asc' as never],
|
||||
activeSortColumn: 0 as any,
|
||||
collapsedRows: {},
|
||||
collapsedCols: {},
|
||||
} as any;
|
||||
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
|
||||
|
||||
expect(mockSortAndCacheData).toHaveBeenCalledTimes(2);
|
||||
|
||||
expect(mockSortAndCacheData.mock.calls[0]).toEqual([
|
||||
{ A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } },
|
||||
'asc',
|
||||
true,
|
||||
false,
|
||||
maxRowIndex,
|
||||
]);
|
||||
|
||||
expect(mockSortAndCacheData.mock.calls[1]).toEqual([
|
||||
{ A: { currentVal: 30 }, B: { currentVal: 10 }, C: { currentVal: 20 } },
|
||||
'desc',
|
||||
true,
|
||||
false,
|
||||
maxRowIndex,
|
||||
]);
|
||||
// Column headers should still render.
|
||||
expect(screen.getByText('blue')).toBeInTheDocument();
|
||||
expect(screen.getByText('red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should sort hierarchical data in descending order', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const groups = {
|
||||
A: {
|
||||
currentVal: 30,
|
||||
A1: { currentVal: 13 },
|
||||
A2: { currentVal: 17 },
|
||||
},
|
||||
B: {
|
||||
currentVal: 10,
|
||||
B1: { currentVal: 7 },
|
||||
B2: { currentVal: 3 },
|
||||
},
|
||||
|
||||
C: {
|
||||
currentVal: 18,
|
||||
C1: { currentVal: 7 },
|
||||
C2: { currentVal: 11 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = tableRenderer.sortAndCacheData(groups, 'desc', true, false, 2);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
expect(result).toEqual([
|
||||
['A', 'A2'],
|
||||
['A', 'A1'],
|
||||
['A'],
|
||||
['C', 'C2'],
|
||||
['C', 'C1'],
|
||||
['C'],
|
||||
['B', 'B1'],
|
||||
['B', 'B2'],
|
||||
['B'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should sort hierarchical data in ascending order', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const groups = {
|
||||
A: {
|
||||
currentVal: 30,
|
||||
A1: { currentVal: 13 },
|
||||
A2: { currentVal: 17 },
|
||||
},
|
||||
B: {
|
||||
currentVal: 10,
|
||||
B1: { currentVal: 7 },
|
||||
B2: { currentVal: 3 },
|
||||
},
|
||||
|
||||
C: {
|
||||
currentVal: 18,
|
||||
C1: { currentVal: 7 },
|
||||
C2: { currentVal: 11 },
|
||||
},
|
||||
};
|
||||
|
||||
const result = tableRenderer.sortAndCacheData(groups, 'asc', true, false, 2);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
|
||||
expect(result).toEqual([
|
||||
['B', 'B2'],
|
||||
['B', 'B1'],
|
||||
['B'],
|
||||
['C', 'C1'],
|
||||
['C', 'C2'],
|
||||
['C'],
|
||||
['A', 'A1'],
|
||||
['A', 'A2'],
|
||||
['A'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should calculate groups from pivot data', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const mockAggregator = (value: number) => ({
|
||||
value: () => value,
|
||||
format: jest.fn(),
|
||||
isSubtotal: false,
|
||||
test('TableRenderer handles data with no cols dimension', () => {
|
||||
const props = buildDefaultProps({
|
||||
rows: ['color'],
|
||||
cols: [],
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const mockPivotData = {
|
||||
rowKeys: [['A'], ['B'], ['C']],
|
||||
getAggregator: jest
|
||||
.fn()
|
||||
.mockReturnValueOnce(mockAggregator(30))
|
||||
.mockReturnValueOnce(mockAggregator(10))
|
||||
.mockReturnValueOnce(mockAggregator(20)),
|
||||
};
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
const result = tableRenderer.getAggregatedData(
|
||||
mockPivotData as any,
|
||||
['col1'],
|
||||
false,
|
||||
);
|
||||
// Row headers should still render.
|
||||
expect(screen.getByText('blue')).toBeInTheDocument();
|
||||
expect(screen.getByText('red')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
A: { currentVal: 30 },
|
||||
B: { currentVal: 10 },
|
||||
C: { currentVal: 20 },
|
||||
test('TableRenderer renders with Sum aggregator', () => {
|
||||
const props = buildDefaultProps({
|
||||
aggregatorName: 'Sum',
|
||||
vals: ['value'],
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
const cellTexts = cells.map(cell => cell.textContent);
|
||||
|
||||
// Sum of value for blue+circle=10, blue+square=20, red+circle=30,
|
||||
// red+square=40. Check that at least some of these appear.
|
||||
expect(cellTexts.some(text => text?.includes('10'))).toBe(true);
|
||||
expect(cellTexts.some(text => text?.includes('40'))).toBe(true);
|
||||
});
|
||||
|
||||
test('should sort groups and convert to array in ascending order', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const result = tableRenderer.sortAndCacheData(
|
||||
mockGroups,
|
||||
'asc',
|
||||
true,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
['A', 'A2'],
|
||||
['A', 'A1'],
|
||||
['A'],
|
||||
['B', 'B2'],
|
||||
['B', 'B1'],
|
||||
['B'],
|
||||
['C', 'C2'],
|
||||
['C', 'C1'],
|
||||
['C'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should sort groups and convert to array in descending order', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const result = tableRenderer.sortAndCacheData(
|
||||
mockGroups,
|
||||
'desc',
|
||||
true,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
['C', 'C1'],
|
||||
['C', 'C2'],
|
||||
['C'],
|
||||
['B', 'B1'],
|
||||
['B', 'B2'],
|
||||
['B'],
|
||||
['A', 'A1'],
|
||||
['A', 'A2'],
|
||||
['A'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle rowPartialOnTop = true configuration', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const result = tableRenderer.sortAndCacheData(
|
||||
mockGroups,
|
||||
'asc',
|
||||
true,
|
||||
true,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
['A'],
|
||||
['A', 'A2'],
|
||||
['A', 'A1'],
|
||||
['B'],
|
||||
['B', 'B2'],
|
||||
['B', 'B1'],
|
||||
['C'],
|
||||
['C', 'C2'],
|
||||
['C', 'C1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle rowEnabled = false and rowPartialOnTop = false, sorting asc', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
|
||||
const result = tableRenderer.sortAndCacheData(
|
||||
mockGroups,
|
||||
'asc',
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
['A', 'A2'],
|
||||
['A', 'A1'],
|
||||
['B', 'B2'],
|
||||
['B', 'B1'],
|
||||
['C', 'C2'],
|
||||
['C', 'C1'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle rowEnabled = false and rowPartialOnTop = false , sorting desc', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
|
||||
const result = tableRenderer.sortAndCacheData(
|
||||
mockGroups,
|
||||
'desc',
|
||||
false,
|
||||
false,
|
||||
2,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
['C', 'C1'],
|
||||
['C', 'C2'],
|
||||
['B', 'B1'],
|
||||
['B', 'B2'],
|
||||
['A', 'A1'],
|
||||
['A', 'A2'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('create hierarchical structure with subtotal at bottom', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const rowData = {
|
||||
'A.A1': 10,
|
||||
'A.A2': 20,
|
||||
A: 30,
|
||||
'B.B1': 30,
|
||||
'B.B2': 40,
|
||||
B: 70,
|
||||
'C.C1': 50,
|
||||
'C.C2': 60,
|
||||
C: 110,
|
||||
};
|
||||
|
||||
const pivotData = createMockPivotData(rowData);
|
||||
const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], false);
|
||||
|
||||
expect(result).toEqual({
|
||||
A: {
|
||||
A1: { currentVal: 10 },
|
||||
A2: { currentVal: 20 },
|
||||
currentVal: 30,
|
||||
},
|
||||
B: {
|
||||
B1: { currentVal: 30 },
|
||||
B2: { currentVal: 40 },
|
||||
currentVal: 70,
|
||||
},
|
||||
C: {
|
||||
C1: { currentVal: 50 },
|
||||
C2: { currentVal: 60 },
|
||||
currentVal: 110,
|
||||
},
|
||||
test('TableRenderer applies namesMapping to header labels', () => {
|
||||
const props = buildDefaultProps({
|
||||
namesMapping: { blue: 'Blue Color', red: 'Red Color' },
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
expect(screen.getByText('Blue Color')).toBeInTheDocument();
|
||||
expect(screen.getByText('Red Color')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('create hierarchical structure with subtotal at top', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const rowData = {
|
||||
A: 30,
|
||||
'A.A1': 10,
|
||||
'A.A2': 20,
|
||||
B: 70,
|
||||
'B.B1': 30,
|
||||
'B.B2': 40,
|
||||
C: 110,
|
||||
'C.C1': 50,
|
||||
'C.C2': 60,
|
||||
};
|
||||
test('TableRenderer renders the row attribute label in the header', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const pivotData = createMockPivotData(rowData);
|
||||
const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], true);
|
||||
// The row attribute name "color" should appear as an axis label.
|
||||
const axisLabels = document.querySelectorAll('.pvtAxisLabel');
|
||||
const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent);
|
||||
expect(axisLabelTexts).toContain('color');
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
A: {
|
||||
A1: { currentVal: 10 },
|
||||
A2: { currentVal: 20 },
|
||||
currentVal: 30,
|
||||
},
|
||||
B: {
|
||||
B1: { currentVal: 30 },
|
||||
B2: { currentVal: 40 },
|
||||
currentVal: 70,
|
||||
},
|
||||
C: {
|
||||
C1: { currentVal: 50 },
|
||||
C2: { currentVal: 60 },
|
||||
currentVal: 110,
|
||||
},
|
||||
test('TableRenderer renders the column attribute label in the header', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
// The column attribute name "shape" should appear as an axis label.
|
||||
const axisLabels = document.querySelectorAll('.pvtAxisLabel');
|
||||
const axisLabelTexts = Array.from(axisLabels).map(el => el.textContent);
|
||||
expect(axisLabelTexts).toContain('shape');
|
||||
});
|
||||
|
||||
test('TableRenderer calls onContextMenu callback', () => {
|
||||
const onContextMenu = jest.fn();
|
||||
const props = buildDefaultProps({ onContextMenu });
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const cells = screen.getAllByRole('gridcell');
|
||||
expect(cells.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('TableRenderer renders with multiple row dimensions', () => {
|
||||
const multiRowData = [
|
||||
{ country: 'US', city: 'NYC', value: 10 },
|
||||
{ country: 'US', city: 'LA', value: 20 },
|
||||
{ country: 'UK', city: 'London', value: 30 },
|
||||
];
|
||||
|
||||
const props = buildDefaultProps({
|
||||
data: multiRowData,
|
||||
rows: ['country', 'city'],
|
||||
cols: [],
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('US')).toBeInTheDocument();
|
||||
expect(screen.getByText('UK')).toBeInTheDocument();
|
||||
expect(screen.getByText('NYC')).toBeInTheDocument();
|
||||
expect(screen.getByText('LA')).toBeInTheDocument();
|
||||
expect(screen.getByText('London')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('values from the 3rd level of the hierarchy with a subtotal at the bottom', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const rowData = {
|
||||
'A.A1.A11': 10,
|
||||
'A.A1.A12': 20,
|
||||
'A.A1': 30,
|
||||
'A.A2': 30,
|
||||
'A.A3': 50,
|
||||
A: 110,
|
||||
};
|
||||
test('TableRenderer renders with multiple column dimensions', () => {
|
||||
const multiColData = [
|
||||
{ year: '2023', quarter: 'Q1', metric: 5 },
|
||||
{ year: '2023', quarter: 'Q2', metric: 10 },
|
||||
{ year: '2024', quarter: 'Q1', metric: 15 },
|
||||
];
|
||||
|
||||
const pivotData = createMockPivotData(rowData);
|
||||
const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], false);
|
||||
|
||||
expect(result).toEqual({
|
||||
A: {
|
||||
A1: {
|
||||
A11: { currentVal: 10 },
|
||||
A12: { currentVal: 20 },
|
||||
currentVal: 30,
|
||||
},
|
||||
A2: { currentVal: 30 },
|
||||
A3: { currentVal: 50 },
|
||||
currentVal: 110,
|
||||
},
|
||||
const props = buildDefaultProps({
|
||||
data: multiColData,
|
||||
rows: [],
|
||||
cols: ['year', 'quarter'],
|
||||
});
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const table = screen.getByRole('grid');
|
||||
expect(table).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByText('2023')).toBeInTheDocument();
|
||||
expect(screen.getByText('2024')).toBeInTheDocument();
|
||||
// Q1 appears under both 2023 and 2024, so use getAllByText.
|
||||
expect(screen.getAllByText('Q1').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.getByText('Q2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('values from the 3rd level of the hierarchy with a subtotal at the top', () => {
|
||||
tableRenderer = new TableRenderer(mockProps);
|
||||
const rowData = {
|
||||
A: 110,
|
||||
'A.A1': 30,
|
||||
'A.A1.A11': 10,
|
||||
'A.A1.A12': 20,
|
||||
'A.A2': 30,
|
||||
'A.A3': 50,
|
||||
};
|
||||
test('TableRenderer renders value cells with the pvtVal class', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const pivotData = createMockPivotData(rowData);
|
||||
const result = tableRenderer.getAggregatedData(pivotData, ['Col1'], true);
|
||||
|
||||
expect(result).toEqual({
|
||||
A: {
|
||||
A1: {
|
||||
A11: { currentVal: 10 },
|
||||
A12: { currentVal: 20 },
|
||||
currentVal: 30,
|
||||
},
|
||||
A2: { currentVal: 30 },
|
||||
A3: { currentVal: 50 },
|
||||
currentVal: 110,
|
||||
},
|
||||
});
|
||||
const valueCells = document.querySelectorAll('.pvtVal');
|
||||
// 2 rows x 2 cols = 4 value cells
|
||||
expect(valueCells.length).toBe(4);
|
||||
});
|
||||
|
||||
test('TableRenderer renders correct number of thead and tbody sections', () => {
|
||||
const props = buildDefaultProps();
|
||||
renderWithTheme(<TableRenderer {...props} />);
|
||||
|
||||
const table = screen.getByRole('grid');
|
||||
|
||||
// The table should have thead and tbody elements.
|
||||
const theadEl = table.querySelector('thead');
|
||||
const tbodyEl = table.querySelector('tbody');
|
||||
expect(theadEl).toBeInTheDocument();
|
||||
expect(tbodyEl).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
import memoizeOne from 'memoize-one';
|
||||
import { DataRecord, SuperChart, VizType } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import TableChartPlugin, {
|
||||
TableChartProps,
|
||||
} from '@superset-ui/plugin-chart-table';
|
||||
@@ -144,6 +145,7 @@ function loadData(
|
||||
|
||||
export const Basic = ({ width, height }: { width: number; height: number }) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType={VizType.Table}
|
||||
datasource={{
|
||||
columnFormats: {},
|
||||
@@ -195,6 +197,7 @@ export const BigTable = ({
|
||||
<SuperChart
|
||||
chartType={VizType.Table}
|
||||
{...chartProps}
|
||||
theme={supersetTheme}
|
||||
width={width}
|
||||
height={height}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { SuperChart } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/ui';
|
||||
import { WordCloudChartPlugin } from '@superset-ui/plugin-chart-word-cloud';
|
||||
import { withResizableChartDemo } from '@storybook-shared';
|
||||
import data from './data';
|
||||
@@ -74,6 +75,7 @@ export const Basic = ({
|
||||
height: number;
|
||||
}) => (
|
||||
<SuperChart
|
||||
theme={supersetTheme}
|
||||
chartType="word-cloud2"
|
||||
width={width}
|
||||
height={height}
|
||||
|
||||
@@ -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,318 @@ 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>(Logger.getTimestamp());
|
||||
// Update on each render to accurately track render duration
|
||||
renderStartTimeRef.current = Logger.getTimestamp();
|
||||
|
||||
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;
|
||||
|
||||
@@ -387,7 +387,9 @@ test('renders chart during loading when suppressLoadingSpinner has valid data',
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
@@ -404,7 +406,9 @@ test('does not mark chart as refreshing when loading is not in progress', () =>
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
'false',
|
||||
@@ -420,7 +424,9 @@ test('does not mark chart as refreshing when spinner suppression is disabled', (
|
||||
queriesResponse: [{ data: [{ value: 1 }] }],
|
||||
};
|
||||
|
||||
const { getByTestId } = render(<ChartRenderer {...props} />);
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toHaveAttribute(
|
||||
'data-is-refreshing',
|
||||
'false',
|
||||
@@ -436,6 +442,8 @@ test('does not render chart during loading when last data has errors', () => {
|
||||
queriesResponse: [{ error: 'bad' }],
|
||||
};
|
||||
|
||||
const { queryByTestId } = render(<ChartRenderer {...props} />);
|
||||
const { queryByTestId } = render(
|
||||
<ChartRenderer {...(props as ChartRendererProps)} />,
|
||||
);
|
||||
expect(queryByTestId('mock-super-chart')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -36,7 +45,8 @@ import {
|
||||
DataRecordFilters,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core';
|
||||
import { t } from '@apache-superset/core/ui';
|
||||
import { t, SupersetTheme } from '@apache-superset/core/ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
import { ChartSource } from 'src/types/ChartSource';
|
||||
@@ -137,14 +147,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 +177,260 @@ 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 theme = useTheme() as SupersetTheme;
|
||||
|
||||
private hooks: ChartHooks;
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
formData.viz_type ?? propVizType,
|
||||
)?.suppressContextMenu;
|
||||
|
||||
private mutableQueriesResponse: QueryData[] | null | undefined;
|
||||
const [state, setState] = useState<ChartRendererState>({
|
||||
showContextMenu:
|
||||
source === ChartSource.Dashboard &&
|
||||
!suppressContextMenu &&
|
||||
isFeatureEnabled(FeatureFlag.DrillToDetail),
|
||||
inContextMenu: false,
|
||||
legendState: undefined,
|
||||
legendIndex: 0,
|
||||
});
|
||||
|
||||
private renderStartTime: number;
|
||||
const hasQueryResponseChangeRef = useRef(false);
|
||||
const renderStartTimeRef = useRef(0);
|
||||
const mutableQueriesResponseRef = useRef<QueryData[] | null | undefined>(
|
||||
cloneDeep(queriesResponse),
|
||||
);
|
||||
const contextMenuRef = createRef<ChartContextMenuRef>();
|
||||
|
||||
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;
|
||||
// Track previous props for shouldComponentUpdate logic
|
||||
const prevPropsRef = useRef<PrevPropsRef>({
|
||||
queriesResponse,
|
||||
datasource,
|
||||
annotationData,
|
||||
ownState,
|
||||
filterState,
|
||||
height,
|
||||
width,
|
||||
triggerRender,
|
||||
labelsColor,
|
||||
labelsColorMap,
|
||||
formData,
|
||||
cacheBusterProp,
|
||||
emitCrossFilters,
|
||||
postTransformProps,
|
||||
});
|
||||
|
||||
this.contextMenuRef = createRef<ChartContextMenuRef>();
|
||||
// Handler functions
|
||||
const handleAddFilter = useCallback(
|
||||
(col: string, vals: FilterValue[], merge = true, refresh = true): void => {
|
||||
addFilter?.(col, vals, merge, refresh);
|
||||
},
|
||||
[addFilter],
|
||||
);
|
||||
|
||||
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);
|
||||
const handleRenderSuccess = useCallback((): void => {
|
||||
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
|
||||
actions.chartRenderingSucceeded(chartId);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
// 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]);
|
||||
|
||||
// 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);
|
||||
}
|
||||
const handleRenderFailure = useCallback(
|
||||
(error: Error, info: { componentStack: string } | null): void => {
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
error.toString(),
|
||||
chartId,
|
||||
info ? info.componentStack : null,
|
||||
);
|
||||
|
||||
shouldComponentUpdate(
|
||||
nextProps: ChartRendererProps,
|
||||
nextState: ChartRendererState,
|
||||
): boolean {
|
||||
// only trigger render log when query is changed
|
||||
if (hasQueryResponseChangeRef.current) {
|
||||
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
||||
slice_id: chartId,
|
||||
has_err: true,
|
||||
error_details: error.toString(),
|
||||
start_offset: renderStartTimeRef.current,
|
||||
ts: new Date().getTime(),
|
||||
duration: Logger.getTimestamp() - renderStartTimeRef.current,
|
||||
});
|
||||
}
|
||||
},
|
||||
[actions, chartId],
|
||||
);
|
||||
|
||||
const handleSetControlValue = useCallback(
|
||||
(name: string, value: unknown): void => {
|
||||
if (setControlValue) {
|
||||
setControlValue(name, value);
|
||||
}
|
||||
},
|
||||
[setControlValue],
|
||||
);
|
||||
|
||||
const handleOnContextMenu = useCallback(
|
||||
(offsetX: number, offsetY: number, filters?: ContextMenuFilters): void => {
|
||||
contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
setState(prev => ({ ...prev, inContextMenu: true }));
|
||||
},
|
||||
[contextMenuRef],
|
||||
);
|
||||
|
||||
const handleContextMenuSelected = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
|
||||
const handleContextMenuClosed = useCallback((): void => {
|
||||
setState(prev => ({ ...prev, inContextMenu: false }));
|
||||
}, []);
|
||||
|
||||
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 +446,210 @@ 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}
|
||||
theme={theme}
|
||||
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;
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
SuperChart,
|
||||
ContextMenuFilters,
|
||||
} from '@superset-ui/core';
|
||||
import { css } from '@apache-superset/core/ui';
|
||||
import { css, SupersetTheme } from '@apache-superset/core/ui';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { Dataset } from '../types';
|
||||
|
||||
interface DrillByChartProps {
|
||||
@@ -45,6 +46,7 @@ export default function DrillByChart({
|
||||
onContextMenu,
|
||||
inContextMenu,
|
||||
}: DrillByChartProps) {
|
||||
const theme = useTheme() as SupersetTheme;
|
||||
const hooks = useMemo(() => ({ onContextMenu }), [onContextMenu]);
|
||||
|
||||
return (
|
||||
@@ -67,6 +69,7 @@ export default function DrillByChart({
|
||||
inContextMenu={inContextMenu}
|
||||
height="100%"
|
||||
width="100%"
|
||||
theme={theme}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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,294 @@ 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,
|
||||
pagination = false,
|
||||
filterTerm,
|
||||
filterFields,
|
||||
}: 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 +385,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 +398,7 @@ export default class CRUDCollection extends PureComponent<
|
||||
});
|
||||
|
||||
if (allowDeletes) {
|
||||
antdColumns.push({
|
||||
columns.push({
|
||||
key: '__actions',
|
||||
dataIndex: '__actions',
|
||||
sorter: false,
|
||||
@@ -398,7 +422,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 +431,101 @@ 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 displayData = useMemo(() => {
|
||||
if (filterTerm && filterFields?.length) {
|
||||
return collectionArray.filter(item =>
|
||||
filterFields.some(field =>
|
||||
String(item[field] ?? '')
|
||||
.toLowerCase()
|
||||
.includes(filterTerm.toLowerCase()),
|
||||
),
|
||||
);
|
||||
}
|
||||
return collectionArray;
|
||||
}, [collectionArray, filterTerm, filterFields]);
|
||||
|
||||
const displayData =
|
||||
filterTerm && filterFields?.length
|
||||
? this.state.collectionArray.filter(item =>
|
||||
filterFields.some(field =>
|
||||
String(item[field] ?? '')
|
||||
.toLowerCase()
|
||||
.includes(filterTerm.toLowerCase()),
|
||||
),
|
||||
)
|
||||
: this.state.collectionArray;
|
||||
const paginationConfig = useMemo((): false | TablePaginationConfig => {
|
||||
if (pagination === false || pagination === undefined) {
|
||||
return false;
|
||||
}
|
||||
return typeof pagination === 'object' ? pagination : {};
|
||||
}, [pagination]);
|
||||
|
||||
const tableColumns = this.buildTableColumns();
|
||||
const expandedRowKeys = Object.keys(this.state.expandedColumns).filter(
|
||||
id => this.state.expandedColumns[id],
|
||||
);
|
||||
const expandedRowKeys = useMemo(
|
||||
() => Object.keys(expandedColumns).filter(id => expandedColumns[id]),
|
||||
[expandedColumns],
|
||||
);
|
||||
|
||||
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={displayData}
|
||||
rowKey={(record: CollectionItem) => String(record.id)}
|
||||
sticky={stickyHeader}
|
||||
pagination={paginationConfig}
|
||||
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;
|
||||
function unload(event: BeforeUnloadEvent): string {
|
||||
const message = t('You have unsaved changes.');
|
||||
// Set returnValue on the actual event object to trigger the browser prompt
|
||||
event.returnValue = message;
|
||||
return message; // Gecko + Webkit, Safari, Chrome etc.
|
||||
}
|
||||
|
||||
// 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 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;
|
||||
|
||||
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);
|
||||
}
|
||||
// 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);
|
||||
|
||||
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);
|
||||
|
||||
// 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,145 @@ 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);
|
||||
onBeforeUnload(false); // Remove beforeunload listener on unmount
|
||||
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,277 @@ 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),
|
||||
);
|
||||
|
||||
userIdForFetch() {
|
||||
return this.state.showOnlyMyCharts ? this.props.userId : undefined;
|
||||
}
|
||||
// Refs to track latest values for cleanup effect
|
||||
const latestSlicesRef = useRef(slices);
|
||||
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
|
||||
const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
|
||||
getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
|
||||
);
|
||||
|
||||
componentDidMount() {
|
||||
this.slicesRequest = this.props.fetchSlices(
|
||||
this.userIdForFetch(),
|
||||
'',
|
||||
this.state.sortBy,
|
||||
);
|
||||
}
|
||||
// Keep refs updated with latest values
|
||||
useEffect(() => {
|
||||
latestSlicesRef.current = slices;
|
||||
}, [slices]);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
useEffect(() => {
|
||||
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
|
||||
}, [selectedSliceIdsSet]);
|
||||
|
||||
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
|
||||
// Use refs to get latest values on unmount
|
||||
const selectedSlices = pickBy(latestSlicesRef.current, (value: Slice) =>
|
||||
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
|
||||
);
|
||||
|
||||
updateSlices(selectedSlices);
|
||||
if (slicesRequestRef.current instanceof AbortController) {
|
||||
slicesRequestRef.current.abort();
|
||||
}
|
||||
},
|
||||
[updateSlices],
|
||||
);
|
||||
|
||||
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%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import {
|
||||
cleanup,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import FilterScopeSelector from './FilterScopeSelector';
|
||||
import type { DashboardLayout } from 'src/dashboard/types';
|
||||
|
||||
// --- Mock child components ---
|
||||
|
||||
jest.mock('./FilterFieldTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-field-tree">
|
||||
FilterFieldTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('./FilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: (props: Record<string, unknown>) => (
|
||||
<div data-test="filter-scope-tree">
|
||||
FilterScopeTree (checked={String(props.checked)})
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// --- Mock utility functions ---
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterFieldNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ALL_FILTERS_ROOT',
|
||||
label: 'All filters',
|
||||
children: [
|
||||
{
|
||||
value: 1,
|
||||
label: 'Filter A',
|
||||
children: [
|
||||
{ value: '1_column_b', label: 'Filter B' },
|
||||
{ value: '1_column_c', label: 'Filter C' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [
|
||||
{
|
||||
value: 'ROOT_ID',
|
||||
label: 'All charts',
|
||||
children: [{ value: 2, label: 'Chart A' }],
|
||||
},
|
||||
]),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeParentNodes', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ['ROOT_ID']),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/buildFilterScopeTreeEntry', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getKeyForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => '1_column_b'),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getSelectedChartIdForFilterScopeTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => 1),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getFilterScopeFromNodesTree', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({ scope: ['ROOT_ID'], immune: [] })),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/getRevertedFilterScope', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => ({})),
|
||||
}));
|
||||
|
||||
jest.mock('src/dashboard/util/activeDashboardFilters', () => ({
|
||||
getChartIdsInFilterScope: jest.fn(() => [2, 3]),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const mockDashboardFilters = {
|
||||
1: {
|
||||
chartId: 1,
|
||||
componentId: 'component-1',
|
||||
filterName: 'Filter A',
|
||||
datasourceId: 'ds-1',
|
||||
directPathToFilter: ['ROOT_ID', 'GRID', 'CHART_1'],
|
||||
isDateFilter: false,
|
||||
isInstantFilter: false,
|
||||
columns: { column_b: undefined, column_c: undefined },
|
||||
labels: { column_b: 'Filter B', column_c: 'Filter C' },
|
||||
scopes: {
|
||||
column_b: { immune: [], scope: ['ROOT_ID'] },
|
||||
column_c: { immune: [], scope: ['ROOT_ID'] },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockLayout: DashboardLayout = {
|
||||
ROOT_ID: { children: ['GRID'], id: 'ROOT_ID', type: 'ROOT' },
|
||||
GRID: {
|
||||
children: ['CHART_1', 'CHART_2'],
|
||||
id: 'GRID',
|
||||
type: 'GRID',
|
||||
parents: ['ROOT_ID'],
|
||||
},
|
||||
CHART_1: {
|
||||
meta: { chartId: 1, sliceName: 'Chart 1' },
|
||||
children: [],
|
||||
id: 'CHART_1',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
CHART_2: {
|
||||
meta: { chartId: 2, sliceName: 'Chart 2' },
|
||||
children: [],
|
||||
id: 'CHART_2',
|
||||
type: 'CHART',
|
||||
parents: ['ROOT_ID', 'GRID'],
|
||||
},
|
||||
} as unknown as DashboardLayout;
|
||||
|
||||
const defaultProps = {
|
||||
dashboardFilters: mockDashboardFilters,
|
||||
layout: mockLayout,
|
||||
updateDashboardFiltersScope: jest.fn(),
|
||||
setUnsavedChanges: jest.fn(),
|
||||
onCloseModal: jest.fn(),
|
||||
};
|
||||
|
||||
test('renders the header, filter field panel, and scope panel', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Configure filter scopes')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-field-tree')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('filter-scope-tree')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders the search input with correct placeholder', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
expect(searchInput).toHaveAttribute('type', 'text');
|
||||
});
|
||||
|
||||
test('renders Close and Save buttons when filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders only Close button and a warning when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getByText('There are no filters in this dashboard.'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Save' }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('does not render FilterFieldTree or FilterScopeTree when no filters exist', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} dashboardFilters={{}} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
expect(screen.queryByTestId('filter-field-tree')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('filter-scope-tree')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls onCloseModal when Close button is clicked', () => {
|
||||
const onCloseModal = jest.fn();
|
||||
render(
|
||||
<FilterScopeSelector {...defaultProps} onCloseModal={onCloseModal} />,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Close' }));
|
||||
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('calls updateDashboardFiltersScope, setUnsavedChanges, and onCloseModal when Save is clicked', () => {
|
||||
const updateDashboardFiltersScope = jest.fn();
|
||||
const setUnsavedChanges = jest.fn();
|
||||
const onCloseModal = jest.fn();
|
||||
|
||||
render(
|
||||
<FilterScopeSelector
|
||||
{...defaultProps}
|
||||
updateDashboardFiltersScope={updateDashboardFiltersScope}
|
||||
setUnsavedChanges={setUnsavedChanges}
|
||||
onCloseModal={onCloseModal}
|
||||
/>,
|
||||
{ useRedux: true },
|
||||
);
|
||||
|
||||
userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
expect(updateDashboardFiltersScope).toHaveBeenCalledTimes(1);
|
||||
expect(setUnsavedChanges).toHaveBeenCalledWith(true);
|
||||
expect(onCloseModal).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('renders the editing filters name section with "Editing 1 filter:" label', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
expect(screen.getByText('Editing 1 filter:')).toBeInTheDocument();
|
||||
// The active filter label should appear (column_b maps to "Filter B")
|
||||
expect(screen.getByText('Filter B')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('updates search text when typing in the search input', () => {
|
||||
render(<FilterScopeSelector {...defaultProps} />, { useRedux: true });
|
||||
|
||||
const searchInput = screen.getByPlaceholderText('Search...');
|
||||
userEvent.type(searchInput, 'Chart');
|
||||
|
||||
expect(searchInput).toHaveValue('Chart');
|
||||
});
|
||||
@@ -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,11 +16,12 @@
|
||||
* 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';
|
||||
import type { ResizeStartCallback, ResizeCallback } from 're-resizable';
|
||||
import { ErrorBoundary } from 'src/components';
|
||||
|
||||
import { t, css, styled } from '@apache-superset/core/ui';
|
||||
import { SafeMarkdown } from '@superset-ui/core/components';
|
||||
@@ -81,16 +82,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 +130,200 @@ 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 handleRenderError = useCallback(
|
||||
(error: Error, info: { componentStack: string } | null): void => {
|
||||
setHasError(true);
|
||||
if (editorMode === 'preview') {
|
||||
addDangerToast(
|
||||
t(
|
||||
'This markdown component has an error. Please revert your recent changes.',
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
[addDangerToast, editorMode],
|
||||
);
|
||||
|
||||
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 +333,122 @@ 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 (
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={this.props.htmlSanitization}
|
||||
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isFocused, editorMode } = this.state;
|
||||
|
||||
const {
|
||||
component,
|
||||
parentComponent,
|
||||
index,
|
||||
depth,
|
||||
availableColumnCount,
|
||||
columnWidth,
|
||||
onResize,
|
||||
onResizeStop,
|
||||
handleComponentDrop,
|
||||
editMode,
|
||||
} = this.props;
|
||||
|
||||
// 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 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}
|
||||
const renderPreviewMode = useMemo(
|
||||
() => (
|
||||
<ErrorBoundary
|
||||
key={hasError ? 'markdown-error' : 'markdown-ok'}
|
||||
onError={handleRenderError}
|
||||
showMessage={false}
|
||||
>
|
||||
{({ 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}
|
||||
<SafeMarkdown
|
||||
source={
|
||||
hasError
|
||||
? MARKDOWN_ERROR_MESSAGE
|
||||
: markdownSource || MARKDOWN_PLACE_HOLDER
|
||||
}
|
||||
htmlSanitization={htmlSanitization}
|
||||
htmlSchemaOverrides={htmlSchemaOverrides}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
),
|
||||
[
|
||||
hasError,
|
||||
markdownSource,
|
||||
htmlSanitization,
|
||||
htmlSchemaOverrides,
|
||||
handleRenderError,
|
||||
],
|
||||
);
|
||||
|
||||
// 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 isEditing = editorMode === 'edit';
|
||||
|
||||
const menuItems = useMemo(
|
||||
() => [
|
||||
<MarkdownModeDropdown
|
||||
key={`${component.id}-mode`}
|
||||
id={`${component.id}-mode`}
|
||||
value={editorMode}
|
||||
onChange={handleChangeEditorMode}
|
||||
/>,
|
||||
],
|
||||
[component.id, editorMode, handleChangeEditorMode],
|
||||
);
|
||||
|
||||
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 {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user