mirror of
https://github.com/apache/superset.git
synced 2026-05-13 03:45:12 +00:00
Compare commits
10 Commits
fix-asyncs
...
fix-no-top
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f6d627ed | ||
|
|
ff323ba328 | ||
|
|
78fada7f43 | ||
|
|
b71c556560 | ||
|
|
7d6513f946 | ||
|
|
d25c07586d | ||
|
|
93066ade52 | ||
|
|
3fe59024ef | ||
|
|
0b442ef9ae | ||
|
|
5bb88b2b74 |
10
.github/workflows/superset-docs-deploy.yml
vendored
10
.github/workflows/superset-docs-deploy.yml
vendored
@@ -17,16 +17,6 @@ on:
|
||||
|
||||
workflow_dispatch: {}
|
||||
|
||||
# Serialize deploys: the action pushes to apache/superset-site without
|
||||
# rebasing, so concurrent runs race on the final push and the loser fails
|
||||
# with `! [rejected] asf-site -> asf-site (fetch first)`. Cancel any
|
||||
# in-progress run as soon as a newer one starts — the destination repo
|
||||
# isn't touched until the final push step, so canceling mid-build is safe,
|
||||
# and the freshest content always wins.
|
||||
concurrency:
|
||||
group: docs-deploy-asf-site
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
config:
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
@@ -38,11 +38,6 @@ import {
|
||||
import { normalizeThemeConfig, serializeThemeConfig } from './utils';
|
||||
|
||||
export class Theme {
|
||||
// Forward-compat: TS 6.0 enforces strictPropertyInitialization here;
|
||||
// both fields are assigned via setConfig() during construction, so we
|
||||
// use a definite-assignment assertion rather than hoisting the logic
|
||||
// out of setConfig().
|
||||
//
|
||||
// Assigned via setConfig() in the constructor; TypeScript 6.0's
|
||||
// strictPropertyInitialization can't trace that call chain, so we use
|
||||
// a definite-assignment assertion.
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
/* eslint react/sort-comp: 'off' */
|
||||
import { PureComponent, ReactNode } from 'react';
|
||||
import {
|
||||
SupersetClientInterface,
|
||||
RequestConfig,
|
||||
@@ -66,112 +67,103 @@ export type ChartDataProviderState = {
|
||||
error?: ProvidedProps['error'];
|
||||
};
|
||||
|
||||
function ChartDataProvider({
|
||||
children,
|
||||
client,
|
||||
formData,
|
||||
sliceId,
|
||||
loadDatasource,
|
||||
onError,
|
||||
onLoaded,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
}: ChartDataProviderProps): JSX.Element | null {
|
||||
const [state, setState] = useState<ChartDataProviderState>({
|
||||
status: 'uninitialized',
|
||||
});
|
||||
class ChartDataProvider extends PureComponent<
|
||||
ChartDataProviderProps,
|
||||
ChartDataProviderState
|
||||
> {
|
||||
readonly chartClient: ChartClient;
|
||||
|
||||
const chartClient = useMemo(() => new ChartClient({ client }), [client]);
|
||||
constructor(props: ChartDataProviderProps) {
|
||||
super(props);
|
||||
this.state = { status: 'uninitialized' };
|
||||
this.chartClient = new ChartClient({ client: props.client });
|
||||
}
|
||||
|
||||
const extractSliceIdAndFormData = useCallback(
|
||||
(): SliceIdAndOrFormData =>
|
||||
formData ? { formData } : { sliceId: sliceId as number },
|
||||
[formData, sliceId],
|
||||
);
|
||||
componentDidMount() {
|
||||
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);
|
||||
componentDidUpdate(prevProps: ChartDataProviderProps) {
|
||||
const { formData, sliceId } = this.props;
|
||||
if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
|
||||
this.handleFetchData();
|
||||
}
|
||||
}, [
|
||||
chartClient,
|
||||
extractSliceIdAndFormData,
|
||||
formDataRequestOptions,
|
||||
loadDatasource,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
handleReceiveData,
|
||||
handleError,
|
||||
]);
|
||||
}
|
||||
|
||||
// Fetch on mount and only refetch when formData or sliceId changes.
|
||||
// This preserves the original class component's componentDidUpdate
|
||||
// semantics (which compared only formData and sliceId). Other
|
||||
// fetch-related inputs referenced by handleFetchData (callbacks and
|
||||
// request option props) are intentionally excluded from the dependency
|
||||
// array, so the exhaustive-deps rule is suppressed here.
|
||||
useEffect(() => {
|
||||
handleFetchData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [formData, sliceId]);
|
||||
private extractSliceIdAndFormData() {
|
||||
const { formData, sliceId } = this.props;
|
||||
return formData ? { formData } : { sliceId: sliceId as number };
|
||||
}
|
||||
|
||||
const { status, payload, error } = state;
|
||||
private handleFetchData = () => {
|
||||
const {
|
||||
loadDatasource,
|
||||
formDataRequestOptions,
|
||||
datasourceRequestOptions,
|
||||
queryRequestOptions,
|
||||
} = this.props;
|
||||
|
||||
// Wrap the children result in a Fragment so the component's return type
|
||||
// stays `JSX.Element | null` (which TypeScript requires for JSX components)
|
||||
// while still letting consumers return any ReactNode (strings, fragments,
|
||||
// arrays, null, etc.) from the render prop.
|
||||
switch (status) {
|
||||
case 'loading':
|
||||
return <>{children({ loading: true })}</>;
|
||||
case 'loaded':
|
||||
return <>{children({ payload })}</>;
|
||||
case 'error':
|
||||
return <>{children({ error })}</>;
|
||||
case 'uninitialized':
|
||||
default:
|
||||
return null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,11 +21,8 @@ import {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
ComponentType,
|
||||
PureComponent,
|
||||
Fragment,
|
||||
memo,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -35,19 +32,23 @@ import {
|
||||
} from 'react-error-boundary';
|
||||
import { ParentSize } from '@visx/responsive';
|
||||
import { createSelector } from 'reselect';
|
||||
import { useTheme } from '@emotion/react';
|
||||
import { withTheme } from '@emotion/react';
|
||||
import { parseLength, Dimension } from '../../dimension';
|
||||
import getChartMetadataRegistry from '../registries/ChartMetadataRegistrySingleton';
|
||||
import SuperChartCore, {
|
||||
Props as SuperChartCoreProps,
|
||||
SuperChartCoreRef,
|
||||
} from './SuperChartCore';
|
||||
import SuperChartCore, { Props as SuperChartCoreProps } 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';
|
||||
import { supersetTheme, SupersetTheme } from '@apache-superset/core/theme';
|
||||
|
||||
const defaultProps = {
|
||||
FallbackComponent: DefaultFallbackComponent,
|
||||
height: 400 as string | number,
|
||||
width: '100%' as string | number,
|
||||
enableNoResults: true,
|
||||
};
|
||||
|
||||
export type FallbackPropsWithDimension = FallbackProps & Partial<Dimension>;
|
||||
|
||||
export type WrapperProps = Dimension & {
|
||||
@@ -55,9 +56,7 @@ export type WrapperProps = Dimension & {
|
||||
};
|
||||
|
||||
export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
Omit<ChartPropsConfig, 'width' | 'height' | 'theme'> & {
|
||||
/** Theme object (optional, falls back to ThemeProvider context) */
|
||||
theme?: SupersetTheme;
|
||||
Omit<ChartPropsConfig, 'width' | 'height'> & {
|
||||
/**
|
||||
* Set this to true to disable error boundary built-in in SuperChart
|
||||
* and let the error propagate to upper level
|
||||
@@ -103,269 +102,215 @@ export type Props = Omit<SuperChartCoreProps, 'chartProps'> &
|
||||
inContextMenu?: boolean;
|
||||
};
|
||||
|
||||
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 {
|
||||
type PropsWithDefault = Props & Readonly<typeof defaultProps>;
|
||||
|
||||
class SuperChart extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* SuperChart's core ref
|
||||
* SuperChart's core
|
||||
*/
|
||||
const coreRef = useRef<SuperChartCoreRef | null>(null);
|
||||
core?: SuperChartCore | null;
|
||||
|
||||
// Use theme from prop if provided, otherwise from context.
|
||||
// When no ThemeProvider is present, useTheme() returns an empty object,
|
||||
// so we fall back to the default supersetTheme to avoid passing an invalid theme downstream.
|
||||
const themeFromContext = useTheme() as Partial<SupersetTheme>;
|
||||
const theme =
|
||||
themeProp ??
|
||||
(Object.keys(themeFromContext).length > 0
|
||||
? (themeFromContext as SupersetTheme)
|
||||
: supersetTheme);
|
||||
private createChartProps = ChartProps.createSelector();
|
||||
|
||||
const createChartProps = useMemo(() => ChartProps.createSelector(), []);
|
||||
|
||||
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,
|
||||
};
|
||||
|
||||
// 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 };
|
||||
},
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const setRef = useCallback((core: SuperChartCoreRef | null) => {
|
||||
coreRef.current = core;
|
||||
}, []);
|
||||
|
||||
const getQueryCount = useCallback(
|
||||
() => getChartMetadataRegistry().get(chartType)?.queryObjectCount ?? 1,
|
||||
[chartType],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(chartWidth: number, chartHeight: number) => {
|
||||
const chartProps = createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height: chartHeight,
|
||||
width: chartWidth,
|
||||
theme,
|
||||
});
|
||||
|
||||
// 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>
|
||||
);
|
||||
},
|
||||
private parseDimension = createSelector(
|
||||
[
|
||||
createChartProps,
|
||||
rest,
|
||||
queriesData,
|
||||
theme,
|
||||
Wrapper,
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
enableNoResults,
|
||||
getQueryCount,
|
||||
noResults,
|
||||
({ 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,
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
private setRef = (core: SuperChartCore | null) => {
|
||||
this.core = core;
|
||||
};
|
||||
|
||||
private getQueryCount = () =>
|
||||
getChartMetadataRegistry().get(this.props.chartType)?.queryObjectCount ?? 1;
|
||||
|
||||
renderChart(width: number, height: number) {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
setRef,
|
||||
chartType,
|
||||
preTransformProps,
|
||||
overrideTransformProps,
|
||||
postTransformProps,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
],
|
||||
);
|
||||
disableErrorBoundary,
|
||||
FallbackComponent,
|
||||
onErrorBoundary,
|
||||
Wrapper,
|
||||
queriesData,
|
||||
enableNoResults,
|
||||
noResults,
|
||||
theme,
|
||||
...rest
|
||||
} = this.props as PropsWithDefault;
|
||||
|
||||
const { heightInfo, widthInfo, BoundingBox } = parseDimension({
|
||||
width,
|
||||
height,
|
||||
});
|
||||
const chartProps = this.createChartProps({
|
||||
...rest,
|
||||
queriesData,
|
||||
height,
|
||||
width,
|
||||
theme,
|
||||
});
|
||||
|
||||
// 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>
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
return renderChart(widthInfo.value, heightInfo.value);
|
||||
render() {
|
||||
const { heightInfo, widthInfo, BoundingBox } = this.parseDimension(
|
||||
this.props as PropsWithDefault,
|
||||
);
|
||||
|
||||
// If any of the dimension is dynamic, get parent's dimension
|
||||
if (widthInfo.isDynamic || heightInfo.isDynamic) {
|
||||
const { debounceTime } = this.props;
|
||||
|
||||
return (
|
||||
<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);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap in memo to preserve the shallow-prop-comparison behavior
|
||||
// of the original PureComponent implementation.
|
||||
export default memo(SuperChart);
|
||||
export default withTheme(SuperChart);
|
||||
|
||||
@@ -17,13 +17,8 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useImperativeHandle,
|
||||
useMemo,
|
||||
useRef,
|
||||
} from 'react';
|
||||
/* eslint-disable react/jsx-sort-default-props */
|
||||
import { PureComponent } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { createSelector } from 'reselect';
|
||||
import getChartComponentRegistry from '../registries/ChartComponentRegistrySingleton';
|
||||
@@ -44,6 +39,16 @@ 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 };
|
||||
}
|
||||
@@ -73,231 +78,174 @@ export type Props = {
|
||||
onRenderFailure?: HandlerFunction;
|
||||
};
|
||||
|
||||
export interface SuperChartCoreRef {
|
||||
container: HTMLElement | null;
|
||||
}
|
||||
export default class SuperChartCore extends PureComponent<Props, {}> {
|
||||
/**
|
||||
* The HTML element that wraps all chart content
|
||||
*/
|
||||
container?: HTMLElement | null;
|
||||
|
||||
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,
|
||||
}: {
|
||||
/**
|
||||
* memoized function so it will not recompute and return previous value
|
||||
* unless one of
|
||||
* - preTransformProps
|
||||
* - chartProps
|
||||
* is changed.
|
||||
*/
|
||||
preSelector = createSelector(
|
||||
[
|
||||
(input: {
|
||||
chartProps: ChartProps;
|
||||
preTransformProps?: PreTransformProps;
|
||||
transformProps?: TransformProps;
|
||||
}) => 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;
|
||||
postTransformProps?: PostTransformProps;
|
||||
}) =>
|
||||
postSelector({
|
||||
chartProps: transformSelector({
|
||||
chartProps: preSelector({
|
||||
chartProps: inputChartProps,
|
||||
preTransformProps: pre,
|
||||
}),
|
||||
transformProps,
|
||||
}),
|
||||
postTransformProps: post,
|
||||
}),
|
||||
[preSelector, transformSelector, postSelector],
|
||||
);
|
||||
}) => input.chartProps,
|
||||
input => input.postTransformProps,
|
||||
],
|
||||
(transformedChartProps, post = IDENTITY) => post(transformedChartProps),
|
||||
);
|
||||
|
||||
const renderLoading = useCallback(
|
||||
(loadingProps: LoadingProps, loadingChartType: string) => {
|
||||
const { error } = loadingProps;
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="alert alert-warning" role="alert">
|
||||
<strong>{t('ERROR')}</strong>
|
||||
<code>chartType="{loadingChartType}"</code> —
|
||||
{error.toString()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const renderChart = useCallback(
|
||||
(loaded: LoadedModules, props: RenderProps) => {
|
||||
const { Chart, transformProps } = loaded;
|
||||
const {
|
||||
chartProps: renderChartProps,
|
||||
preTransformProps: pre,
|
||||
postTransformProps: post,
|
||||
} = 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;
|
||||
/**
|
||||
* 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),
|
||||
},
|
||||
),
|
||||
[renderLoading, renderChart],
|
||||
);
|
||||
loading: (loadingProps: LoadingProps) =>
|
||||
this.renderLoading(loadingProps, chartType),
|
||||
render: this.renderChart,
|
||||
});
|
||||
|
||||
const setRef = useCallback((container: HTMLElement | null) => {
|
||||
containerRef.current = container;
|
||||
}, []);
|
||||
// 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,
|
||||
})}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private renderLoading = (loadingProps: LoadingProps, chartType: 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>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
private setRef = (container: HTMLElement | null) => {
|
||||
this.container = container;
|
||||
};
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
className,
|
||||
preTransformProps,
|
||||
postTransformProps,
|
||||
chartProps = BLANK_CHART_PROPS,
|
||||
onRenderSuccess,
|
||||
onRenderFailure,
|
||||
} = this.props;
|
||||
|
||||
// Create LoadableRenderer and start preloading
|
||||
// the lazy-loaded Chart components
|
||||
const Renderer = createLoadableRendererSelector({
|
||||
chartType,
|
||||
overrideTransformProps,
|
||||
});
|
||||
const Renderer = this.createLoadableRenderer(this.props);
|
||||
|
||||
// Do not render if chartProps is set to null.
|
||||
// but the pre-loading has been started in createLoadableRendererSelector
|
||||
// but the pre-loading has been started in this.createLoadableRenderer
|
||||
// to prepare for rendering once chartProps becomes available.
|
||||
if (chartProps === null) {
|
||||
return null;
|
||||
@@ -315,7 +263,7 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
}
|
||||
|
||||
return (
|
||||
<div {...containerProps} ref={setRef}>
|
||||
<div {...containerProps} ref={this.setRef}>
|
||||
<Renderer
|
||||
preTransformProps={preTransformProps}
|
||||
postTransformProps={postTransformProps}
|
||||
@@ -325,7 +273,5 @@ const SuperChartCore = forwardRef<SuperChartCoreRef, Props>(
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default SuperChartCore;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -897,86 +897,6 @@ test('fires onChange when pasting a selection', async () => {
|
||||
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
test('replaces cached options with search results instead of merging', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
expect(options[0]).toHaveTextContent('Search Match');
|
||||
});
|
||||
|
||||
test('shows all options when filterOption is false', async () => {
|
||||
const loadOptions = jest.fn(async () => ({
|
||||
data: OPTIONS.slice(0, 10),
|
||||
totalCount: 20,
|
||||
}));
|
||||
|
||||
render(
|
||||
<AsyncSelect
|
||||
{...defaultProps}
|
||||
options={loadOptions}
|
||||
filterOption={false}
|
||||
/>,
|
||||
);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('zzz_no_match');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
|
||||
const options = await findAllSelectOptions();
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('restores base options when search is cleared', async () => {
|
||||
const page0Data = Array.from({ length: 10 }, (_, i) => ({
|
||||
label: `Option ${i}`,
|
||||
value: i,
|
||||
}));
|
||||
const searchData = [{ label: 'Search Match', value: 100 }];
|
||||
const loadOptions = jest.fn(async (search: string) => {
|
||||
if (search === '') {
|
||||
return { data: page0Data, totalCount: 100 };
|
||||
}
|
||||
return { data: searchData, totalCount: 1 };
|
||||
});
|
||||
|
||||
render(<AsyncSelect {...defaultProps} options={loadOptions} />);
|
||||
await open();
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(1));
|
||||
|
||||
await type('search');
|
||||
await waitFor(() => expect(loadOptions).toHaveBeenCalledTimes(2));
|
||||
let options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(1);
|
||||
|
||||
await type('{backspace}{backspace}{backspace}{backspace}{backspace}{backspace}');
|
||||
await waitFor(async () => {
|
||||
options = await findAllSelectOptions();
|
||||
expect(options).toHaveLength(10);
|
||||
});
|
||||
});
|
||||
|
||||
test('does not duplicate options when using numeric values', async () => {
|
||||
render(
|
||||
<AsyncSelect
|
||||
|
||||
@@ -160,8 +160,6 @@ const AsyncSelect = forwardRef(
|
||||
const [allValuesLoaded, setAllValuesLoaded] = useState(false);
|
||||
const selectValueRef = useRef(selectValue);
|
||||
const fetchedQueries = useRef(new Map<string, number>());
|
||||
const initialOptionsRef = useRef<SelectOptionsType>(EMPTY_OPTIONS);
|
||||
const wasSearchingRef = useRef(false);
|
||||
const mappedMode = isSingleMode ? undefined : 'multiple';
|
||||
const allowFetch = !fetchOnlyOnSearch || inputValue;
|
||||
const [maxTagCount, setMaxTagCount] = useState(
|
||||
@@ -337,22 +335,13 @@ const AsyncSelect = forwardRef(
|
||||
const fetchOptions = options as SelectOptionsPagePromise;
|
||||
fetchOptions(search, page, pageSize)
|
||||
.then(({ data, totalCount }: SelectOptionsTypePage) => {
|
||||
let resultData: SelectOptionsType;
|
||||
if (search && page === 0) {
|
||||
resultData = data.slice().sort(sortComparatorForNoSearch);
|
||||
setSelectOptions(resultData);
|
||||
} else {
|
||||
resultData = mergeData(data);
|
||||
if (!search) {
|
||||
initialOptionsRef.current = resultData;
|
||||
}
|
||||
}
|
||||
const mergedData = mergeData(data);
|
||||
fetchedQueries.current.set(key, totalCount);
|
||||
setTotalCount(totalCount);
|
||||
if (
|
||||
!fetchOnlyOnSearch &&
|
||||
search === '' &&
|
||||
resultData.length >= totalCount
|
||||
mergedData.length >= totalCount
|
||||
) {
|
||||
setAllValuesLoaded(true);
|
||||
}
|
||||
@@ -369,7 +358,6 @@ const AsyncSelect = forwardRef(
|
||||
internalOnError,
|
||||
options,
|
||||
pageSize,
|
||||
sortComparatorForNoSearch,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -530,26 +518,12 @@ const AsyncSelect = forwardRef(
|
||||
if (loadingEnabled && allowFetch) {
|
||||
// trigger fetch every time inputValue changes
|
||||
if (inputValue) {
|
||||
wasSearchingRef.current = true;
|
||||
debouncedFetchPage(inputValue, 0);
|
||||
} else {
|
||||
if (wasSearchingRef.current && initialOptionsRef.current.length > 0) {
|
||||
setSelectOptions(
|
||||
[...initialOptionsRef.current].sort(sortComparatorForNoSearch),
|
||||
);
|
||||
}
|
||||
wasSearchingRef.current = false;
|
||||
fetchPage('', 0);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
loadingEnabled,
|
||||
fetchPage,
|
||||
allowFetch,
|
||||
inputValue,
|
||||
debouncedFetchPage,
|
||||
sortComparatorForNoSearch,
|
||||
]);
|
||||
}, [loadingEnabled, fetchPage, allowFetch, inputValue, debouncedFetchPage]);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading !== undefined && loading !== isLoading) {
|
||||
|
||||
@@ -211,10 +211,6 @@ export const handleFilterOptionHelper = (
|
||||
return filterOption(search, option);
|
||||
}
|
||||
|
||||
if (filterOption === false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (filterOption) {
|
||||
const searchValue = search.trim().toLowerCase();
|
||||
if (optionFilterProps?.length) {
|
||||
|
||||
@@ -24,7 +24,6 @@ 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/theme';
|
||||
import { WrapperProps } from '../../../src/chart/components/SuperChart';
|
||||
|
||||
import {
|
||||
@@ -119,7 +118,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -140,7 +138,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
FallbackComponent={CustomFallbackComponent}
|
||||
/>,
|
||||
);
|
||||
@@ -157,7 +154,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={handleError}
|
||||
/>,
|
||||
);
|
||||
@@ -182,7 +178,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
onErrorBoundary={inactiveErrorHandler}
|
||||
/>
|
||||
</ErrorBoundary>,
|
||||
@@ -210,7 +205,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -291,7 +285,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -339,7 +332,6 @@ describe('SuperChart', () => {
|
||||
queriesData={DEFAULT_QUERIES_DATA}
|
||||
width={101}
|
||||
height={118}
|
||||
theme={supersetTheme}
|
||||
formData={{ abc: 1 }}
|
||||
/>,
|
||||
);
|
||||
@@ -355,12 +347,7 @@ describe('SuperChart', () => {
|
||||
describe('supports NoResultsComponent', () => {
|
||||
test('renders NoResultsComponent when queriesData is missing', () => {
|
||||
render(
|
||||
<SuperChart
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
<SuperChart chartType={ChartKeys.DILIGENT} width="200" height="200" />,
|
||||
);
|
||||
|
||||
expect(screen.getByText('No Results')).toBeInTheDocument();
|
||||
@@ -373,7 +360,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[{ data: null }]}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -401,7 +387,6 @@ describe('SuperChart', () => {
|
||||
queriesData={[DEFAULT_QUERY_DATA]}
|
||||
width={100}
|
||||
height={100}
|
||||
theme={supersetTheme}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -426,7 +411,6 @@ describe('SuperChart', () => {
|
||||
debounceTime={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
theme={supersetTheme}
|
||||
Wrapper={MyWrapper}
|
||||
/>
|
||||
</div>,
|
||||
@@ -491,7 +475,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -517,7 +500,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: null }]}
|
||||
enableNoResults
|
||||
/>,
|
||||
@@ -545,7 +527,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
noResults={<CustomNoResults />}
|
||||
@@ -575,7 +556,6 @@ describe('SuperChart', () => {
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
width="200"
|
||||
height="200"
|
||||
theme={supersetTheme}
|
||||
queriesData={[{ data: [] }]}
|
||||
enableNoResults
|
||||
onErrorBoundary={onErrorBoundary}
|
||||
|
||||
@@ -227,28 +227,15 @@ describe('SuperChartCore', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('processChartProps behavior', () => {
|
||||
test('applies identity pre/post transforms so chartProps reach overrideTransformProps unchanged', async () => {
|
||||
// When pre/post transform props are not specified, identity functions are used,
|
||||
// so the original chartProps should reach overrideTransformProps unchanged.
|
||||
// overrideTransformProps is used here as a probe to read the final chartProps;
|
||||
// it's not part of what's being tested for identity behavior.
|
||||
const chartProps2 = new ChartProps({
|
||||
queriesData: [{ message: 'identity-test' }],
|
||||
theme: supersetTheme,
|
||||
describe('.processChartProps()', () => {
|
||||
test('use identity functions for unspecified transforms', () => {
|
||||
const chart = new SuperChartCore({
|
||||
chartType: ChartKeys.DILIGENT,
|
||||
});
|
||||
|
||||
render(
|
||||
<SuperChartCore
|
||||
chartType={ChartKeys.DILIGENT}
|
||||
chartProps={chartProps2}
|
||||
overrideTransformProps={props => props.queriesData[0]}
|
||||
/>,
|
||||
const chartProps2 = new ChartProps();
|
||||
expect(chart.processChartProps({ chartProps: chartProps2 })).toBe(
|
||||
chartProps2,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('identity-test')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -56,7 +56,6 @@ jest.mock('@superset-ui/chart-controls', () => ({
|
||||
}));
|
||||
|
||||
jest.mock('@superset-ui/core', () => ({
|
||||
BRAND_COLOR: '#00A699',
|
||||
GenericDataType: { Temporal: 2, String: 1 },
|
||||
extractTimegrain: jest.fn(() => 'P1D'),
|
||||
getMetricLabel: jest.fn(metric => metric),
|
||||
@@ -281,30 +280,4 @@ describe('BigNumberWithTrendline transformProps', () => {
|
||||
expect(result.bigNumber).toBe(360);
|
||||
expect(result.subheader).toBe('50.0% WoW');
|
||||
});
|
||||
|
||||
test('should not crash and should return undefined mainColor when colorPicker is null', () => {
|
||||
const chartProps = {
|
||||
width: 400,
|
||||
height: 300,
|
||||
queriesData: [
|
||||
{
|
||||
data: [
|
||||
{ __timestamp: 1, value: 100 },
|
||||
] as unknown as BigNumberDatum[],
|
||||
colnames: ['__timestamp', 'value'],
|
||||
coltypes: ['TEMPORAL', 'NUMERIC'],
|
||||
},
|
||||
],
|
||||
formData: { ...baseFormData, colorPicker: null },
|
||||
rawFormData: baseRawFormData,
|
||||
hooks: baseHooks,
|
||||
datasource: baseDatasource,
|
||||
theme: { colors: { grayscale: { light5: '#eee' } } },
|
||||
};
|
||||
|
||||
const result = transformProps(
|
||||
chartProps as unknown as BigNumberWithTrendlineChartProps,
|
||||
);
|
||||
expect(result.mainColor).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import {
|
||||
BRAND_COLOR,
|
||||
extractTimegrain,
|
||||
getNumberFormatter,
|
||||
NumberFormats,
|
||||
@@ -141,9 +140,8 @@ export default function transformProps(
|
||||
const compareLag = Number(compareLag_) || 0;
|
||||
let formattedSubheader = subheader;
|
||||
|
||||
const mainColor = colorPicker
|
||||
? `rgb(${colorPicker.r}, ${colorPicker.g}, ${colorPicker.b})`
|
||||
: undefined;
|
||||
const { r, g, b } = colorPicker;
|
||||
const mainColor = `rgb(${r}, ${g}, ${b})`;
|
||||
|
||||
const xAxisLabel = getXAxisLabel(rawFormData) as string;
|
||||
let trendLineData: TimeSeriesDatum[] | undefined;
|
||||
@@ -292,12 +290,12 @@ export default function transformProps(
|
||||
symbol: 'circle',
|
||||
symbolSize: 10,
|
||||
showSymbol: false,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
offset: 0,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
color: mainColor,
|
||||
},
|
||||
{
|
||||
offset: 1,
|
||||
|
||||
@@ -17,14 +17,16 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { memo } from 'react';
|
||||
import { PureComponent } from 'react';
|
||||
import { TableRenderer } from './TableRenderers';
|
||||
import type { ComponentProps } from 'react';
|
||||
|
||||
type PivotTableProps = ComponentProps<typeof TableRenderer>;
|
||||
|
||||
function PivotTable(props: PivotTableProps) {
|
||||
return <TableRenderer {...props} />;
|
||||
class PivotTable extends PureComponent<PivotTableProps> {
|
||||
render() {
|
||||
return <TableRenderer {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default memo(PivotTable);
|
||||
export default PivotTable;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -30,7 +30,7 @@ import fetchMock from 'fetch-mock';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { createDatasource } from 'src/SqlLab/actions/sqlLab';
|
||||
import { user, testQuery, mockdatasets } from 'src/SqlLab/fixtures';
|
||||
import { FeatureFlag, SupersetClient } from '@superset-ui/core';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
|
||||
const mockedProps = {
|
||||
visible: true,
|
||||
@@ -354,131 +354,6 @@ describe('SaveDatasetModal', () => {
|
||||
});
|
||||
});
|
||||
|
||||
const setupOverwriteFlow = async () => {
|
||||
// Select the "Overwrite existing" radio
|
||||
await userEvent.click(
|
||||
screen.getByRole('radio', { name: /overwrite existing/i }),
|
||||
);
|
||||
// Open the select to load existing-dataset options
|
||||
await userEvent.click(
|
||||
screen.getByRole('combobox', { name: /existing dataset/i }),
|
||||
);
|
||||
// Advance timers to flush debounced fetches in AsyncSelect
|
||||
await act(async () => {
|
||||
jest.runAllTimers();
|
||||
});
|
||||
// Wait for the loading indicator to clear
|
||||
await waitFor(() => {
|
||||
const loading = screen.queryByText('Loading...');
|
||||
expect(loading === null || !loading.checkVisibility()).toBe(true);
|
||||
});
|
||||
// Pick an existing dataset (use the listbox item, not the input mirror)
|
||||
const options = await screen.findAllByText('coolest table 0');
|
||||
await userEvent.click(options[1]);
|
||||
// First overwrite click → confirmation screen
|
||||
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
|
||||
// Wait for the confirmation screen to render
|
||||
await screen.findByText(/are you sure you want to overwrite this dataset/i);
|
||||
// Second overwrite click → triggers the PUT
|
||||
await userEvent.click(screen.getByRole('button', { name: /overwrite/i }));
|
||||
};
|
||||
|
||||
test('sends template_params when overwriting a dataset with include template parameters checked', async () => {
|
||||
// @ts-expect-error
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12, _filters: 'foo' }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Check the "Include Template Parameters" checkbox
|
||||
await userEvent.click(screen.getByRole('checkbox'));
|
||||
|
||||
await setupOverwriteFlow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
putSpy.mock.calls.some(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
)!;
|
||||
const [req] = datasetPutCall;
|
||||
expect(req.endpoint).toContain('override_columns=true');
|
||||
const body = JSON.parse(req.body as string);
|
||||
// _filters should be stripped, but my_param should be preserved
|
||||
expect(body.template_params).toEqual(JSON.stringify({ my_param: 12 }));
|
||||
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('does not send template_params when overwriting a dataset with include template parameters unchecked', async () => {
|
||||
// @ts-expect-error
|
||||
global.featureFlags = {
|
||||
[FeatureFlag.EnableTemplateProcessing]: true,
|
||||
};
|
||||
|
||||
const putSpy = jest
|
||||
.spyOn(SupersetClient, 'put')
|
||||
.mockResolvedValue({ json: { result: { id: 0 } } } as any);
|
||||
|
||||
const dummyDispatch = jest.fn().mockResolvedValue({});
|
||||
useDispatchMock.mockReturnValue(dummyDispatch);
|
||||
useSelectorMock.mockReturnValue({ ...user });
|
||||
|
||||
const propsWithTemplateParam = {
|
||||
...mockedProps,
|
||||
datasource: {
|
||||
...testQuery,
|
||||
templateParams: JSON.stringify({ my_param: 12 }),
|
||||
},
|
||||
};
|
||||
render(<SaveDatasetModal {...propsWithTemplateParam} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
// Do NOT check the "Include Template Parameters" checkbox
|
||||
await setupOverwriteFlow();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
putSpy.mock.calls.some(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const datasetPutCall = putSpy.mock.calls.find(([req]) =>
|
||||
req.endpoint?.includes('api/v1/dataset/'),
|
||||
)!;
|
||||
const [req] = datasetPutCall;
|
||||
const body = JSON.parse(req.body as string);
|
||||
expect(body.template_params).toBeUndefined();
|
||||
|
||||
putSpy.mockRestore();
|
||||
});
|
||||
|
||||
test('clears dataset cache when creating new dataset', async () => {
|
||||
const clearDatasetCache = jest.spyOn(
|
||||
require('src/utils/cachedSupersetGet'),
|
||||
|
||||
@@ -149,25 +149,14 @@ const Styles = styled.div`
|
||||
}
|
||||
`}
|
||||
`;
|
||||
type UpdateDatasetPayload = {
|
||||
dbId: number;
|
||||
datasetId: number;
|
||||
sql: string;
|
||||
columns: Array<Record<string, any>>;
|
||||
owners: number[];
|
||||
overrideColumns: boolean;
|
||||
templateParams?: string;
|
||||
};
|
||||
|
||||
const updateDataset = async ({
|
||||
dbId,
|
||||
datasetId,
|
||||
sql,
|
||||
columns,
|
||||
owners,
|
||||
overrideColumns,
|
||||
templateParams,
|
||||
}: UpdateDatasetPayload) => {
|
||||
const updateDataset = async (
|
||||
dbId: number,
|
||||
datasetId: number,
|
||||
sql: string,
|
||||
columns: Array<Record<string, any>>,
|
||||
owners: [number],
|
||||
overrideColumns: boolean,
|
||||
) => {
|
||||
const endpoint = `api/v1/dataset/${datasetId}?override_columns=${overrideColumns}`;
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
const body = JSON.stringify({
|
||||
@@ -175,7 +164,6 @@ const updateDataset = async ({
|
||||
columns,
|
||||
owners,
|
||||
database_id: dbId,
|
||||
...(templateParams !== undefined && { template_params: templateParams }),
|
||||
});
|
||||
|
||||
const data: JsonResponse = await SupersetClient.put({
|
||||
@@ -191,26 +179,6 @@ const updateDataset = async ({
|
||||
|
||||
const UNTITLED = t('Untitled Dataset');
|
||||
|
||||
// The filters param is only used to test jinja templates.
|
||||
// Remove the special filters entry from the templateParams
|
||||
// before saving the dataset.
|
||||
const sanitizeTemplateParams = (
|
||||
templateParams: string | object | null | undefined,
|
||||
): string | undefined => {
|
||||
if (typeof templateParams !== 'string') {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(templateParams) as Record<string, unknown>;
|
||||
// Remove the special _filters entry — it is only used to test jinja templates.
|
||||
const { _filters: _ignored, ...clean } = parsed;
|
||||
return JSON.stringify(clean);
|
||||
} catch (e) {
|
||||
// malformed templateParams, do not include it
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
export const SaveDatasetModal = ({
|
||||
visible,
|
||||
onHide,
|
||||
@@ -264,27 +232,22 @@ export const SaveDatasetModal = ({
|
||||
}
|
||||
setLoading(true);
|
||||
|
||||
const templateParams = includeTemplateParameters
|
||||
? sanitizeTemplateParams(datasource?.templateParams)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const [, key] = await Promise.all([
|
||||
updateDataset({
|
||||
dbId: datasource?.dbId,
|
||||
datasetId: datasetToOverwrite?.datasetid,
|
||||
sql: datasource?.sql,
|
||||
columns: datasource?.columns?.map(
|
||||
updateDataset(
|
||||
datasource?.dbId,
|
||||
datasetToOverwrite?.datasetid,
|
||||
datasource?.sql,
|
||||
datasource?.columns?.map(
|
||||
(d: { column_name: string; type: string; is_dttm: boolean }) => ({
|
||||
column_name: d.column_name,
|
||||
type: d.type,
|
||||
is_dttm: d.is_dttm,
|
||||
}),
|
||||
),
|
||||
owners: datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
|
||||
overrideColumns: true,
|
||||
templateParams,
|
||||
}),
|
||||
datasetToOverwrite?.owners?.map((o: DatasetOwner) => o.id),
|
||||
true,
|
||||
),
|
||||
postFormData(datasetToOverwrite.datasetid, 'table', {
|
||||
...formDataWithDefaults,
|
||||
datasource: `${datasetToOverwrite.datasetid}__table`,
|
||||
@@ -356,9 +319,27 @@ export const SaveDatasetModal = ({
|
||||
setLoading(true);
|
||||
const selectedColumns = datasource?.columns ?? [];
|
||||
|
||||
const templateParams = includeTemplateParameters
|
||||
? sanitizeTemplateParams(datasource?.templateParams)
|
||||
: undefined;
|
||||
// The filters param is only used to test jinja templates.
|
||||
// Remove the special filters entry from the templateParams
|
||||
// before saving the dataset.
|
||||
let templateParams;
|
||||
if (
|
||||
typeof datasource?.templateParams === 'string' &&
|
||||
includeTemplateParameters
|
||||
) {
|
||||
try {
|
||||
const p = JSON.parse(datasource.templateParams);
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
if (p._filters) {
|
||||
/* eslint-disable-next-line no-underscore-dangle */
|
||||
delete p._filters;
|
||||
}
|
||||
templateParams = JSON.stringify(p);
|
||||
} catch (e) {
|
||||
// malformed templateParams, do not include it
|
||||
templateParams = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
dispatch(
|
||||
createDatasource({
|
||||
|
||||
@@ -18,13 +18,13 @@
|
||||
*/
|
||||
import {
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useEffect,
|
||||
useState,
|
||||
useRef,
|
||||
type ChangeEvent,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { useSelector, useDispatch, shallowEqual } from 'react-redux';
|
||||
import { styled, css, useTheme } from '@apache-superset/core/theme';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
@@ -314,7 +314,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
}, [sortedTreeData, sortedTables]);
|
||||
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const deferredSearchTerm = useDeferredValue(searchTerm);
|
||||
const debouncedSearchTerm = useDebounceValue(searchTerm);
|
||||
const handleSearchChange = useCallback(
|
||||
({ target }: ChangeEvent<HTMLInputElement>) => setSearchTerm(target.value),
|
||||
[],
|
||||
@@ -372,9 +372,9 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
|
||||
// Check if any nodes match the search term
|
||||
const hasMatchingNodes = useMemo(() => {
|
||||
if (!deferredSearchTerm) return true;
|
||||
if (!debouncedSearchTerm) return true;
|
||||
|
||||
const lowerTerm = deferredSearchTerm.toLowerCase();
|
||||
const lowerTerm = debouncedSearchTerm.toLowerCase();
|
||||
|
||||
const checkNode = (node: TreeNodeData): boolean => {
|
||||
if (node.type === 'empty') return false;
|
||||
@@ -386,7 +386,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
};
|
||||
|
||||
return displayTreeData.some(node => checkNode(node));
|
||||
}, [deferredSearchTerm, displayTreeData]);
|
||||
}, [debouncedSearchTerm, displayTreeData]);
|
||||
|
||||
// Node renderer for react-arborist
|
||||
const renderNode = useCallback(
|
||||
@@ -395,7 +395,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
{...props}
|
||||
manuallyOpenedNodes={manuallyOpenedNodes}
|
||||
loadingNodes={loadingNodes}
|
||||
searchTerm={deferredSearchTerm}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
catalog={catalog}
|
||||
pinnedTableKeys={pinnedTableKeys}
|
||||
pinnedSchemas={pinnedSchemas}
|
||||
@@ -425,7 +425,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
toggleSortColumns,
|
||||
loadingNodes,
|
||||
manuallyOpenedNodes,
|
||||
deferredSearchTerm,
|
||||
debouncedSearchTerm,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -484,7 +484,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
|
||||
if (deferredSearchTerm && !hasMatchingNodes) {
|
||||
if (debouncedSearchTerm && !hasMatchingNodes) {
|
||||
return (
|
||||
<Empty
|
||||
description={t('No matching results found')}
|
||||
@@ -501,7 +501,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
height={height || 500}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
indent={16}
|
||||
searchTerm={deferredSearchTerm}
|
||||
searchTerm={debouncedSearchTerm}
|
||||
searchMatch={searchMatch}
|
||||
disableDrag
|
||||
disableDrop
|
||||
@@ -527,7 +527,7 @@ const TableExploreTree: React.FC<Props> = ({ queryEditorId }) => {
|
||||
// react-arborist marks all schemas as open (isOpen=true) even before any
|
||||
// user interaction. Using treeRef in that case would treat every first
|
||||
// click as a close action, so fall back to manuallyOpenedNodes instead.
|
||||
const wasOpen = deferredSearchTerm
|
||||
const wasOpen = debouncedSearchTerm
|
||||
? (treeRef.current?.get(id)?.isOpen ??
|
||||
manuallyOpenedNodes[id] ??
|
||||
false)
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
screen,
|
||||
} from 'spec/helpers/testing-library';
|
||||
import { FeatureFlag } from '@superset-ui/core';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import {
|
||||
OPEN_FILTER_BAR_WIDTH,
|
||||
CLOSED_FILTER_BAR_WIDTH,
|
||||
@@ -487,6 +488,47 @@ test('should render ParentSize wrapper with height 100% for tabs', async () => {
|
||||
expect(tabPanels.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('should apply min-height to the top-level tab drop target so tabs can be dropped on dashboards with content', () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
jest.fn(),
|
||||
]);
|
||||
(fetchFaveStar as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
(setActiveTab as jest.Mock).mockReturnValue({ type: 'mock-action' });
|
||||
|
||||
const { getByTestId } = render(<DashboardBuilder />, {
|
||||
useRedux: true,
|
||||
store: storeWithState({
|
||||
...mockState,
|
||||
dashboardLayout: undoableDashboardLayout,
|
||||
dashboardState: { ...mockState.dashboardState, editMode: true },
|
||||
}),
|
||||
useDnd: true,
|
||||
useTheme: true,
|
||||
});
|
||||
|
||||
const headerWrapper = getByTestId('dashboard-header-wrapper');
|
||||
|
||||
// The Droppable inside the header should have the empty-droptarget class
|
||||
// when there are no top-level tabs and edit mode is active. Without this
|
||||
// class (and its associated min-height CSS rule), the drop target has zero
|
||||
// height and users cannot drag tabs onto dashboards that already have
|
||||
// content.
|
||||
const droptarget = headerWrapper.querySelector('.empty-droptarget');
|
||||
expect(droptarget).toBeInTheDocument();
|
||||
|
||||
// Verify the StyledHeader CSS defines a non-zero min-height for
|
||||
// .empty-droptarget, derived from theme.sizeUnit * 4 to stay in sync
|
||||
// with the source rule in DashboardBuilder.tsx.
|
||||
expect(headerWrapper).toHaveStyleRule(
|
||||
'min-height',
|
||||
`${supersetTheme.sizeUnit * 4}px`,
|
||||
{
|
||||
target: '.empty-droptarget',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('should maintain layout when switching between tabs', async () => {
|
||||
(useStoredSidebarWidth as jest.Mock).mockImplementation(() => [
|
||||
100,
|
||||
|
||||
@@ -100,6 +100,10 @@ const StyledHeader = styled.div<{ filterBarWidth: number }>`
|
||||
z-index: 99;
|
||||
max-width: calc(100vw - ${filterBarWidth}px);
|
||||
|
||||
.empty-droptarget {
|
||||
min-height: ${theme.sizeUnit * 4}px;
|
||||
}
|
||||
|
||||
.empty-droptarget:before {
|
||||
position: absolute;
|
||||
content: '';
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
*/
|
||||
import 'src/public-path';
|
||||
|
||||
import { lazy, StrictMode, Suspense, useEffect } from 'react';
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { BrowserRouter as Router, Route } from 'react-router-dom';
|
||||
import { Global } from '@emotion/react';
|
||||
@@ -66,21 +66,20 @@ const LazyDashboardPage = lazy(
|
||||
),
|
||||
);
|
||||
|
||||
const EmbeddedLazyDashboardPage = () => {
|
||||
const EmbededLazyDashboardPage = () => {
|
||||
const uiConfig = useUiConfig();
|
||||
const emitDataMasks = uiConfig?.emitDataMasks;
|
||||
|
||||
// Emit data mask changes to the parent window. Subscribing inside an effect
|
||||
// (rather than during render) ensures the unsubscribe runs on unmount,
|
||||
// including StrictMode's dev-mode double-mount cycle.
|
||||
useEffect(() => {
|
||||
if (!emitDataMasks) return undefined;
|
||||
// Emit data mask changes to the parent window
|
||||
if (uiConfig?.emitDataMasks) {
|
||||
log('setting up Switchboard event emitter');
|
||||
|
||||
let previousDataMask = store.getState().dataMask;
|
||||
|
||||
return store.subscribe(() => {
|
||||
const currentDataMask = store.getState().dataMask;
|
||||
store.subscribe(() => {
|
||||
const currentState = store.getState();
|
||||
const currentDataMask = currentState.dataMask;
|
||||
|
||||
// Only emit if the dataMask has changed
|
||||
if (previousDataMask !== currentDataMask) {
|
||||
Switchboard.emit('observeDataMask', {
|
||||
...currentDataMask,
|
||||
@@ -89,7 +88,7 @@ const EmbeddedLazyDashboardPage = () => {
|
||||
previousDataMask = currentDataMask;
|
||||
}
|
||||
});
|
||||
}, [emitDataMasks]);
|
||||
}
|
||||
|
||||
return <LazyDashboardPage idOrSlug={bootstrapData.embedded!.dashboard_id} />;
|
||||
};
|
||||
@@ -108,7 +107,7 @@ const EmbeddedRoute = () => (
|
||||
/>
|
||||
<Suspense fallback={<Loading />}>
|
||||
<ErrorBoundary>
|
||||
<EmbeddedLazyDashboardPage />
|
||||
<EmbededLazyDashboardPage />
|
||||
</ErrorBoundary>
|
||||
<ToastContainer position="top" />
|
||||
</Suspense>
|
||||
@@ -197,11 +196,7 @@ function start() {
|
||||
if (!root) {
|
||||
root = createRoot(appMountPoint);
|
||||
}
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<EmbeddedApp />
|
||||
</StrictMode>,
|
||||
);
|
||||
root.render(<EmbeddedApp />);
|
||||
},
|
||||
err => {
|
||||
// something is most likely wrong with the guest token; reset the guard
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useContext, useDeferredValue, useMemo, useState } from 'react';
|
||||
import { useContext, useMemo, useState } from 'react';
|
||||
import { t } from '@apache-superset/core/translation';
|
||||
import { DatasourceType, Metric, QueryFormData } from '@superset-ui/core';
|
||||
import { Alert } from '@apache-superset/core/components';
|
||||
@@ -26,11 +26,12 @@ import { ControlConfig } from '@superset-ui/chart-controls';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { matchSorter, rankings } from 'match-sorter';
|
||||
import { Input } from '@superset-ui/core/components';
|
||||
import { Constants, Input } from '@superset-ui/core/components';
|
||||
import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
|
||||
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
|
||||
import { ExploreActions } from 'src/explore/actions/exploreActions';
|
||||
import Control from 'src/explore/components/Control';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import { DndItemType } from '../DndItemType';
|
||||
import { DatasourceFolder, DatasourcePanelColumn, DndItemValue } from './types';
|
||||
import { DropzoneContext } from '../ExploreContainer';
|
||||
@@ -159,7 +160,7 @@ export default function DataSourcePanel({
|
||||
|
||||
const [showSaveDatasetModal, setShowSaveDatasetModal] = useState(false);
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const searchKeyword = useDeferredValue(inputValue);
|
||||
const searchKeyword = useDebounceValue(inputValue, Constants.FAST_DEBOUNCE);
|
||||
|
||||
const filteredColumns = useMemo(() => {
|
||||
if (!searchKeyword) {
|
||||
|
||||
@@ -45,6 +45,10 @@ jest.mock('src/core/editors', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('src/hooks/useDebounceValue', () => ({
|
||||
useDebounceValue: (value: string) => value,
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
name: 'echartOptions',
|
||||
label: 'EChart Options',
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useDeferredValue, useMemo } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
import ControlHeader, {
|
||||
ControlHeaderProps,
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
EChartOptionsParseError,
|
||||
} from '@superset-ui/plugin-chart-echarts';
|
||||
import { EditorHost } from 'src/core/editors';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
|
||||
const Container = styled.div`
|
||||
border: 1px solid ${({ theme }) => theme.colorBorder};
|
||||
@@ -49,10 +50,10 @@ export default function JSEditorControl({
|
||||
onChange,
|
||||
value,
|
||||
}: ControlHeaderProps & ControlComponentProps<string>) {
|
||||
const deferredValue = useDeferredValue(value);
|
||||
const debouncedValue = useDebounceValue(value);
|
||||
const error = useMemo(() => {
|
||||
try {
|
||||
safeParseEChartOptions(deferredValue ?? '');
|
||||
safeParseEChartOptions(debouncedValue ?? '');
|
||||
return null;
|
||||
} catch (err) {
|
||||
if (err instanceof EChartOptionsParseError) {
|
||||
@@ -60,7 +61,7 @@ export default function JSEditorControl({
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}, [deferredValue]);
|
||||
}, [debouncedValue]);
|
||||
const headerProps = {
|
||||
name,
|
||||
label: label ?? name,
|
||||
|
||||
@@ -19,13 +19,13 @@
|
||||
import React, {
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useDeferredValue,
|
||||
useMemo,
|
||||
useState,
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
} from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||
import {
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
@@ -213,7 +213,10 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const dispatch = useDispatch();
|
||||
const [isDropdownVisible, setIsDropdownVisible] = useState(false);
|
||||
const [dashboardSearchTerm, setDashboardSearchTerm] = useState('');
|
||||
const deferredDashboardSearchTerm = useDeferredValue(dashboardSearchTerm);
|
||||
const debouncedDashboardSearchTerm = useDebounceValue(
|
||||
dashboardSearchTerm,
|
||||
300,
|
||||
);
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
@@ -301,7 +304,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const dashboardMenuItems = useDashboardsMenuItems({
|
||||
chartId: slice?.slice_id,
|
||||
dashboards,
|
||||
searchTerm: deferredDashboardSearchTerm,
|
||||
searchTerm: debouncedDashboardSearchTerm,
|
||||
});
|
||||
|
||||
const showDashboardSearch = (dashboards?.length ?? 0) > SEARCH_THRESHOLD;
|
||||
@@ -1051,7 +1054,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
dashboards,
|
||||
dashboardMenuItems,
|
||||
dashboardSearchTerm,
|
||||
deferredDashboardSearchTerm,
|
||||
debouncedDashboardSearchTerm,
|
||||
datasource,
|
||||
dispatch,
|
||||
exportCSV,
|
||||
|
||||
@@ -251,7 +251,7 @@ describe('useTables hook', () => {
|
||||
fetchMock.get(`glob:*/api/v1/database/${expectDbId}/schemas/*`, {
|
||||
result: fakeSchemaApiResult,
|
||||
});
|
||||
const { result } = renderHook(
|
||||
const { result, waitFor } = renderHook(
|
||||
() =>
|
||||
useTables({
|
||||
dbId: expectDbId,
|
||||
|
||||
20
superset-frontend/src/types/emotion-jest.d.ts
vendored
Normal file
20
superset-frontend/src/types/emotion-jest.d.ts
vendored
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/// <reference types="@emotion/jest" />
|
||||
@@ -18,7 +18,6 @@
|
||||
*/
|
||||
import 'src/public-path';
|
||||
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { logging } from '@apache-superset/core/utils';
|
||||
import initPreamble from 'src/preamble';
|
||||
@@ -32,11 +31,7 @@ if (appMountPoint) {
|
||||
await initPreamble();
|
||||
} finally {
|
||||
const { default: App } = await import(/* webpackMode: "eager" */ './App');
|
||||
root.render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
root.render(<App />);
|
||||
}
|
||||
})().catch(err => {
|
||||
logging.error('Unhandled error during app initialization', err);
|
||||
|
||||
@@ -20,7 +20,6 @@ import 'src/public-path';
|
||||
|
||||
// Menu App. Used in views that do not already include the Menu component in the layout.
|
||||
// eg, backend rendered views
|
||||
import { StrictMode } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
@@ -46,26 +45,24 @@ const emotionCache = createCache({
|
||||
});
|
||||
|
||||
const app = (
|
||||
<StrictMode>
|
||||
<CacheProvider value={emotionCache}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<QueryParamProvider
|
||||
adapter={ReactRouter5Adapter}
|
||||
options={{
|
||||
searchStringToObject: querystring.parse,
|
||||
objectToSearchString: (object: Record<string, any>) =>
|
||||
querystring.stringify(object, { encode: false }),
|
||||
}}
|
||||
>
|
||||
<Menu data={menu} />
|
||||
</QueryParamProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
</StrictMode>
|
||||
<CacheProvider value={emotionCache}>
|
||||
<ThemeProvider theme={theme}>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<QueryParamProvider
|
||||
adapter={ReactRouter5Adapter}
|
||||
options={{
|
||||
searchStringToObject: querystring.parse,
|
||||
objectToSearchString: (object: Record<string, any>) =>
|
||||
querystring.stringify(object, { encode: false }),
|
||||
}}
|
||||
>
|
||||
<Menu data={menu} />
|
||||
</QueryParamProvider>
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</ThemeProvider>
|
||||
</CacheProvider>
|
||||
);
|
||||
|
||||
const menuMountPoint = document.getElementById('app-menu');
|
||||
|
||||
@@ -22,11 +22,6 @@ import {
|
||||
createListenerMiddleware,
|
||||
StoreEnhancer,
|
||||
} from '@reduxjs/toolkit';
|
||||
import {
|
||||
useDispatch,
|
||||
useSelector,
|
||||
type TypedUseSelectorHook,
|
||||
} from 'react-redux';
|
||||
import thunk from 'redux-thunk';
|
||||
import { api } from 'src/hooks/apiResources/queryApi';
|
||||
import messageToastReducer from 'src/components/MessageToasts/reducers';
|
||||
@@ -182,12 +177,3 @@ export function setupStore({
|
||||
|
||||
export const store = setupStore();
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
|
||||
// Typed Redux hooks. Prefer these over the raw `useDispatch` / `useSelector`
|
||||
// from react-redux: `useAppDispatch` understands the store's middleware (so
|
||||
// thunks resolve correctly), and `useAppSelector` infers `RootState` without
|
||||
// callers having to annotate every selector. Required ahead of the
|
||||
// react-redux v8+ bump, which tightens dispatch typing — see #39927.
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
export const useAppDispatch: () => AppDispatch = useDispatch;
|
||||
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;
|
||||
|
||||
@@ -49,9 +49,8 @@ from superset.datasets.schemas import ImportV1DatasetSchema
|
||||
from superset.extensions import feature_flag_manager
|
||||
from superset.migrations.shared.native_filters import migrate_dashboard
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard, dashboard_slices
|
||||
from superset.models.dashboard import dashboard_slices
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
from superset.queries.saved_queries.schemas import ImportV1SavedQuerySchema
|
||||
from superset.utils.decorators import on_error, transaction
|
||||
|
||||
@@ -90,9 +89,6 @@ class ImportAssetsCommand(BaseCommand):
|
||||
)
|
||||
self._configs: dict[str, Any] = {}
|
||||
self.sparse = kwargs.get("sparse", False)
|
||||
# Defaults to ``True`` for backwards compatibility: historically this
|
||||
# command always overwrote existing assets.
|
||||
self.overwrite: bool = kwargs.get("overwrite", True)
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
@staticmethod
|
||||
@@ -100,7 +96,6 @@ class ImportAssetsCommand(BaseCommand):
|
||||
configs: dict[str, Any],
|
||||
sparse: bool = False,
|
||||
contents: Optional[dict[str, Any]] = None,
|
||||
overwrite: bool = True,
|
||||
) -> None:
|
||||
contents = {} if contents is None else contents
|
||||
# import databases first
|
||||
@@ -121,20 +116,20 @@ class ImportAssetsCommand(BaseCommand):
|
||||
|
||||
for file_name, config in configs.items():
|
||||
if file_name.startswith("databases/"):
|
||||
database = import_database(config, overwrite=overwrite)
|
||||
database = import_database(config, overwrite=True)
|
||||
database_ids[str(database.uuid)] = database.id
|
||||
|
||||
# import saved queries
|
||||
for file_name, config in configs.items():
|
||||
if file_name.startswith("queries/"):
|
||||
config["db_id"] = database_ids[config["database_uuid"]]
|
||||
import_saved_query(config, overwrite=overwrite)
|
||||
import_saved_query(config, overwrite=True)
|
||||
|
||||
# import datasets
|
||||
for file_name, config in configs.items():
|
||||
if file_name.startswith("datasets/"):
|
||||
config["database_id"] = database_ids[config["database_uuid"]]
|
||||
dataset = import_dataset(config, overwrite=overwrite)
|
||||
dataset = import_dataset(config, overwrite=True)
|
||||
dataset_info[str(dataset.uuid)] = {
|
||||
"datasource_id": dataset.id,
|
||||
"datasource_type": dataset.datasource_type,
|
||||
@@ -147,7 +142,7 @@ class ImportAssetsCommand(BaseCommand):
|
||||
if file_name.startswith("charts/"):
|
||||
dataset_dict = dataset_info[config["dataset_uuid"]]
|
||||
config = update_chart_config_dataset(config, dataset_dict)
|
||||
chart = import_chart(config, overwrite=overwrite)
|
||||
chart = import_chart(config, overwrite=True)
|
||||
charts.append(chart)
|
||||
chart_ids[str(chart.uuid)] = chart.id
|
||||
|
||||
@@ -162,7 +157,7 @@ class ImportAssetsCommand(BaseCommand):
|
||||
for file_name, config in configs.items():
|
||||
if file_name.startswith("dashboards/"):
|
||||
config = update_id_refs(config, chart_ids, dataset_info)
|
||||
dashboard = import_dashboard(config, overwrite=overwrite)
|
||||
dashboard = import_dashboard(config, overwrite=True)
|
||||
|
||||
# set ref in the dashboard_slices table
|
||||
dashboard_chart_ids: list[dict[str, int]] = []
|
||||
@@ -211,73 +206,7 @@ class ImportAssetsCommand(BaseCommand):
|
||||
)
|
||||
def run(self) -> None:
|
||||
self.validate()
|
||||
self._import(self._configs, self.sparse, self.contents, self.overwrite)
|
||||
|
||||
# Maps asset file prefixes to the model class used to look up UUIDs for
|
||||
# the "already exists" validation check when ``overwrite`` is ``False``.
|
||||
_MODEL_BY_PREFIX: dict[str, Any] = {
|
||||
"databases/": Database,
|
||||
"datasets/": SqlaTable,
|
||||
"charts/": Slice,
|
||||
"dashboards/": Dashboard,
|
||||
"queries/": SavedQuery,
|
||||
}
|
||||
|
||||
def _bundle_entries_by_prefix(self) -> dict[str, list[tuple[str, str]]]:
|
||||
"""Group ``(file_name, uuid)`` pairs from the bundle by asset prefix."""
|
||||
bundle_by_prefix: dict[str, list[tuple[str, str]]] = {
|
||||
prefix: [] for prefix in self._MODEL_BY_PREFIX
|
||||
}
|
||||
for file_name, config in self._configs.items():
|
||||
uuid = config.get("uuid")
|
||||
if not uuid:
|
||||
continue
|
||||
for prefix in bundle_by_prefix:
|
||||
if file_name.startswith(prefix):
|
||||
bundle_by_prefix[prefix].append((file_name, str(uuid)))
|
||||
break
|
||||
return bundle_by_prefix
|
||||
|
||||
def _prevent_overwrite_existing_assets(
|
||||
self, exceptions: list[ValidationError]
|
||||
) -> None:
|
||||
"""
|
||||
When ``overwrite`` is ``False``, raise a clear validation error for any
|
||||
asset in the bundle whose UUID already exists in the database.
|
||||
|
||||
Only the UUIDs present in the import bundle are queried (per prefix),
|
||||
so the cost scales with the bundle size rather than with the total
|
||||
number of stored assets.
|
||||
"""
|
||||
if self.overwrite:
|
||||
return
|
||||
|
||||
for prefix, entries in self._bundle_entries_by_prefix().items():
|
||||
if not entries:
|
||||
continue
|
||||
model_cls = self._MODEL_BY_PREFIX[prefix]
|
||||
incoming_uuids = [uuid for _, uuid in entries]
|
||||
existing_uuids = {
|
||||
str(uuid)
|
||||
for (uuid,) in db.session.query(model_cls.uuid)
|
||||
.filter(model_cls.uuid.in_(incoming_uuids))
|
||||
.all()
|
||||
}
|
||||
if not existing_uuids:
|
||||
continue
|
||||
model_name = model_cls.__name__
|
||||
for file_name, uuid in entries:
|
||||
if uuid in existing_uuids:
|
||||
exceptions.append(
|
||||
ValidationError(
|
||||
{
|
||||
file_name: (
|
||||
f"{model_name} already exists "
|
||||
"and `overwrite=true` was not passed"
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
self._import(self._configs, self.sparse, self.contents)
|
||||
|
||||
def validate(self) -> None:
|
||||
exceptions: list[ValidationError] = []
|
||||
@@ -300,7 +229,6 @@ class ImportAssetsCommand(BaseCommand):
|
||||
self.ssh_tunnel_priv_key_passwords,
|
||||
self.encrypted_extra_secrets,
|
||||
)
|
||||
self._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
if exceptions:
|
||||
raise CommandInvalidError(
|
||||
|
||||
@@ -30,7 +30,6 @@ from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.commands.importers.v1.utils import get_contents_from_bundle
|
||||
from superset.extensions import event_logger
|
||||
from superset.utils import json
|
||||
from superset.utils.core import parse_boolean_string
|
||||
from superset.views.base_api import BaseSupersetApi, requires_form_data, statsd_metrics
|
||||
|
||||
|
||||
@@ -158,12 +157,6 @@ class ImportExportRestApi(BaseSupersetApi):
|
||||
sparse:
|
||||
description: allow sparse update of resources
|
||||
type: boolean
|
||||
overwrite:
|
||||
description: >-
|
||||
overwrite existing assets? Defaults to ``true`` for
|
||||
backwards compatibility. When ``false``, the import
|
||||
fails if any of the assets already exist.
|
||||
type: boolean
|
||||
responses:
|
||||
200:
|
||||
description: Assets import result
|
||||
@@ -195,9 +188,6 @@ class ImportExportRestApi(BaseSupersetApi):
|
||||
if not contents:
|
||||
raise NoValidFilesFoundError()
|
||||
sparse = request.form.get("sparse") == "true"
|
||||
# Defaults to True for backwards compatibility: historically this
|
||||
# endpoint always overwrote existing assets.
|
||||
overwrite = parse_boolean_string(request.form.get("overwrite", "true"))
|
||||
|
||||
passwords = (
|
||||
json.loads(request.form["passwords"])
|
||||
@@ -228,7 +218,6 @@ class ImportExportRestApi(BaseSupersetApi):
|
||||
command = ImportAssetsCommand(
|
||||
contents,
|
||||
sparse=sparse,
|
||||
overwrite=overwrite,
|
||||
passwords=passwords,
|
||||
ssh_tunnel_passwords=ssh_tunnel_passwords,
|
||||
ssh_tunnel_private_keys=ssh_tunnel_private_keys,
|
||||
|
||||
@@ -2477,9 +2477,7 @@ class ExploreMixin: # pylint: disable=too-many-public-methods
|
||||
if rls_filters:
|
||||
qry = qry.where(and_(*rls_filters))
|
||||
|
||||
with self.database.get_sqla_engine(
|
||||
catalog=self.catalog, schema=self.schema
|
||||
) as engine:
|
||||
with self.database.get_sqla_engine() as engine:
|
||||
sql = str(qry.compile(engine, compile_kwargs={"literal_binds": True}))
|
||||
sql = self._apply_cte(sql, cte)
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import copy
|
||||
from typing import Any, cast
|
||||
|
||||
import yaml
|
||||
from marshmallow.exceptions import ValidationError
|
||||
from pytest_mock import MockerFixture
|
||||
from sqlalchemy.orm.session import Session
|
||||
from sqlalchemy.sql import select
|
||||
@@ -33,18 +32,6 @@ from tests.unit_tests.fixtures.assets_configs import (
|
||||
datasets_config,
|
||||
)
|
||||
|
||||
saved_queries_config: dict[str, Any] = {
|
||||
"queries/examples/my_query.yaml": {
|
||||
"schema": "main",
|
||||
"label": "My saved query",
|
||||
"description": None,
|
||||
"sql": "SELECT 1",
|
||||
"uuid": "e3e4f1f0-5c9d-4a4c-a4e4-0000000000aa",
|
||||
"version": "1.0.0",
|
||||
"database_uuid": "a2dc77af-e654-49bb-b321-40f6b559a1ee",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_import_new_assets(mocker: MockerFixture, session: Session) -> None:
|
||||
"""
|
||||
@@ -240,309 +227,6 @@ def test_import_assets_skips_tags_when_feature_disabled(
|
||||
assert db.session.query(TaggedObject).count() == 0
|
||||
|
||||
|
||||
def test_import_overwrite_defaults_to_true(session: Session) -> None:
|
||||
"""
|
||||
``ImportAssetsCommand.overwrite`` defaults to ``True`` for backwards
|
||||
compatibility — historically the command always overwrote existing assets.
|
||||
"""
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
|
||||
command = ImportAssetsCommand({})
|
||||
assert command.overwrite is True
|
||||
|
||||
explicit_false = ImportAssetsCommand({}, overwrite=False)
|
||||
assert explicit_false.overwrite is False
|
||||
|
||||
|
||||
def test_import_threads_overwrite_flag(mocker: MockerFixture, session: Session) -> None:
|
||||
"""
|
||||
``overwrite`` must be threaded through to ``import_database``,
|
||||
``import_saved_query``, ``import_dataset``, ``import_chart`` and
|
||||
``import_dashboard``. Previously these were hard-coded to ``overwrite=True``
|
||||
which caused the API flag to be ignored.
|
||||
"""
|
||||
from superset import security_manager
|
||||
from superset.commands.importers.v1 import assets as assets_module
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
|
||||
mocked_db = mocker.patch.object(assets_module, "import_database")
|
||||
mocked_db.return_value.uuid = "a2dc77af-e654-49bb-b321-40f6b559a1ee"
|
||||
mocked_db.return_value.id = 1
|
||||
mocked_ds = mocker.patch.object(assets_module, "import_dataset")
|
||||
mocked_ds.return_value.uuid = "53d47c0c-c03d-47f0-b9ac-81225f808283"
|
||||
mocked_ds.return_value.id = 1
|
||||
mocked_ds.return_value.datasource_type = "table"
|
||||
mocked_ds.return_value.table_name = "video_game_sales"
|
||||
mocked_chart = mocker.patch.object(assets_module, "import_chart")
|
||||
mocked_chart.return_value.viz_type = "table"
|
||||
mocked_dash = mocker.patch.object(assets_module, "import_dashboard")
|
||||
mocker.patch.object(assets_module, "find_chart_uuids", return_value=[])
|
||||
mocker.patch.object(assets_module, "update_id_refs", side_effect=lambda c, *_: c)
|
||||
mocker.patch.object(assets_module, "migrate_dashboard")
|
||||
mocker.patch("superset.db.session.execute")
|
||||
|
||||
configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
|
||||
ImportAssetsCommand._import(configs, overwrite=False)
|
||||
|
||||
assert mocked_db.called
|
||||
for call in mocked_db.call_args_list:
|
||||
assert call.kwargs["overwrite"] is False
|
||||
for call in mocked_ds.call_args_list:
|
||||
assert call.kwargs["overwrite"] is False
|
||||
for call in mocked_chart.call_args_list:
|
||||
assert call.kwargs["overwrite"] is False
|
||||
for call in mocked_dash.call_args_list:
|
||||
assert call.kwargs["overwrite"] is False
|
||||
|
||||
|
||||
def test_prevent_overwrite_flags_existing_assets(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
With ``overwrite=False``, ``_prevent_overwrite_existing_assets`` must
|
||||
surface a clear ``ValidationError`` for each asset whose UUID already
|
||||
exists in the database.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
# seed the database with the fixture assets
|
||||
seed_configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
ImportAssetsCommand._import(seed_configs)
|
||||
|
||||
command = ImportAssetsCommand({}, overwrite=False)
|
||||
command._configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
# one exception for each of the seeded assets (db + datasets + charts + dashboards)
|
||||
expected_count = (
|
||||
len(databases_config)
|
||||
+ len(datasets_config)
|
||||
+ len(charts_config_1)
|
||||
+ len(dashboards_config_1)
|
||||
)
|
||||
assert len(exceptions) == expected_count
|
||||
for exc in exceptions:
|
||||
assert isinstance(exc, ValidationError)
|
||||
[(_, message)] = exc.messages.items()
|
||||
assert "already exists" in message
|
||||
assert "`overwrite=true` was not passed" in message
|
||||
|
||||
|
||||
def test_prevent_overwrite_allows_new_assets(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
With ``overwrite=False`` and no conflicting UUIDs in the database, the
|
||||
validation step must not raise.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
command = ImportAssetsCommand({}, overwrite=False)
|
||||
command._configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
assert exceptions == []
|
||||
|
||||
|
||||
def test_prevent_overwrite_noop_when_overwrite_true(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
With ``overwrite=True`` (the default) the "already exists" validation must
|
||||
be a no-op even when assets exist in the database — this preserves the
|
||||
historical behavior.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
seed_configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
ImportAssetsCommand._import(seed_configs)
|
||||
|
||||
command = ImportAssetsCommand({}) # overwrite defaults to True
|
||||
command._configs = copy.deepcopy(seed_configs)
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
assert exceptions == []
|
||||
|
||||
|
||||
def test_prevent_overwrite_flags_existing_saved_queries(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
Saved queries (``queries/`` prefix) must also be covered by the
|
||||
"already exists" validation when ``overwrite=False`` — otherwise
|
||||
``import_saved_query`` silently returns existing rows and the endpoint
|
||||
would appear to succeed despite the conflict.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
SavedQuery.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
# seed a saved query with a UUID that matches the fixture below
|
||||
saved_query_uuid = next(iter(saved_queries_config.values()))["uuid"]
|
||||
db.session.add(SavedQuery(uuid=saved_query_uuid, label="seeded"))
|
||||
db.session.flush()
|
||||
|
||||
command = ImportAssetsCommand({}, overwrite=False)
|
||||
command._configs = copy.deepcopy(saved_queries_config)
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
assert len(exceptions) == 1
|
||||
[(file_name, message)] = exceptions[0].messages.items()
|
||||
assert file_name.startswith("queries/")
|
||||
assert "SavedQuery already exists" in message
|
||||
|
||||
|
||||
def test_prevent_overwrite_partial_conflict(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
When only some of the incoming assets already exist, validation must flag
|
||||
exactly the conflicting ones and leave brand-new assets untouched.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.models.slice import Slice
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
# seed only databases + datasets; charts and dashboards stay new
|
||||
ImportAssetsCommand._import(
|
||||
{
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
}
|
||||
)
|
||||
|
||||
command = ImportAssetsCommand({}, overwrite=False)
|
||||
command._configs = {
|
||||
**copy.deepcopy(databases_config),
|
||||
**copy.deepcopy(datasets_config),
|
||||
**copy.deepcopy(charts_config_1),
|
||||
**copy.deepcopy(dashboards_config_1),
|
||||
}
|
||||
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
flagged_files = {next(iter(exc.messages)) for exc in exceptions}
|
||||
assert flagged_files == set(databases_config) | set(datasets_config)
|
||||
|
||||
|
||||
def test_prevent_overwrite_queries_only_bundle_uuids(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
"""
|
||||
The validation must scope its UUID lookup to the UUIDs present in the
|
||||
import bundle (one ``WHERE uuid IN (...)`` query per prefix that has
|
||||
incoming entries) and skip prefixes with no entries entirely. Otherwise
|
||||
every import with ``overwrite=false`` would scan all asset tables in
|
||||
full, regardless of bundle size.
|
||||
"""
|
||||
from superset import db, security_manager
|
||||
from superset.commands.importers.v1.assets import ImportAssetsCommand
|
||||
from superset.connectors.sqla.models import SqlaTable
|
||||
from superset.models.core import Database
|
||||
from superset.models.dashboard import Dashboard
|
||||
from superset.models.slice import Slice
|
||||
from superset.models.sql_lab import SavedQuery
|
||||
|
||||
mocker.patch.object(security_manager, "can_access", return_value=True)
|
||||
engine = db.session.get_bind()
|
||||
Slice.metadata.create_all(engine) # pylint: disable=no-member
|
||||
SavedQuery.metadata.create_all(engine) # pylint: disable=no-member
|
||||
|
||||
# bundle only contains a database — no datasets/charts/dashboards/queries
|
||||
bundle = copy.deepcopy(databases_config)
|
||||
|
||||
spy = mocker.spy(db.session, "query")
|
||||
|
||||
command = ImportAssetsCommand({}, overwrite=False)
|
||||
command._configs = bundle
|
||||
exceptions: list[ValidationError] = []
|
||||
command._prevent_overwrite_existing_assets(exceptions)
|
||||
|
||||
# exactly one UUID query — for the only prefix with bundle entries — and
|
||||
# it targets the Database UUID column. Empty-bundle prefixes (datasets/
|
||||
# charts/dashboards/queries) must not be queried at all, otherwise this
|
||||
# validation degrades to a full-table scan per asset type.
|
||||
queried_columns = [
|
||||
call.args[0]
|
||||
for call in spy.call_args_list
|
||||
if call.args and getattr(call.args[0], "key", None) == "uuid"
|
||||
]
|
||||
assert len(queried_columns) == 1
|
||||
assert queried_columns[0].class_ is Database
|
||||
|
||||
queried_models = {col.class_ for col in queried_columns}
|
||||
for model_cls in (SqlaTable, Slice, Dashboard, SavedQuery):
|
||||
assert model_cls not in queried_models
|
||||
|
||||
# no row matches in an empty table, so no validation errors are raised
|
||||
assert exceptions == []
|
||||
|
||||
|
||||
def test_import_removes_dashboard_charts(
|
||||
mocker: MockerFixture, session: Session
|
||||
) -> None:
|
||||
|
||||
@@ -48,9 +48,7 @@ def test_export_assets(
|
||||
mocked_export_result = [
|
||||
(
|
||||
"metadata.yaml",
|
||||
lambda: (
|
||||
"version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
), # noqa: E501
|
||||
lambda: "version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n", # noqa: E501
|
||||
),
|
||||
("databases/example.yaml", lambda: "<DATABASE CONTENTS>"),
|
||||
]
|
||||
@@ -111,7 +109,6 @@ def test_import_assets(
|
||||
ImportAssetsCommand.assert_called_with(
|
||||
mocked_contents,
|
||||
sparse=False,
|
||||
overwrite=True,
|
||||
passwords=passwords,
|
||||
ssh_tunnel_passwords=None,
|
||||
ssh_tunnel_private_keys=None,
|
||||
@@ -163,7 +160,6 @@ def test_import_assets_with_encrypted_extra_secrets(
|
||||
ImportAssetsCommand.assert_called_with(
|
||||
mocked_contents,
|
||||
sparse=False,
|
||||
overwrite=True,
|
||||
passwords=None,
|
||||
ssh_tunnel_passwords=None,
|
||||
ssh_tunnel_private_keys=None,
|
||||
@@ -172,54 +168,6 @@ def test_import_assets_with_encrypted_extra_secrets(
|
||||
)
|
||||
|
||||
|
||||
def test_import_assets_overwrite_false(
|
||||
mocker: MockerFixture,
|
||||
client: Any,
|
||||
full_api_access: None,
|
||||
) -> None:
|
||||
"""
|
||||
Passing ``overwrite=false`` on the form must be forwarded to
|
||||
``ImportAssetsCommand``. Previously the flag was ignored and assets were
|
||||
always overwritten.
|
||||
"""
|
||||
mocked_contents = {
|
||||
"metadata.yaml": (
|
||||
"version: 1.0.0\ntype: assets\ntimestamp: '2022-01-01T00:00:00+00:00'\n"
|
||||
),
|
||||
"databases/example.yaml": "<DATABASE CONTENTS>",
|
||||
}
|
||||
|
||||
ImportAssetsCommand = mocker.patch("superset.importexport.api.ImportAssetsCommand") # noqa: N806
|
||||
|
||||
root = Path("assets_export")
|
||||
buf = BytesIO()
|
||||
with ZipFile(buf, "w") as bundle:
|
||||
for path, contents in mocked_contents.items():
|
||||
with bundle.open(str(root / path), "w") as fp:
|
||||
fp.write(contents.encode())
|
||||
buf.seek(0)
|
||||
|
||||
form_data = {
|
||||
"bundle": (buf, "assets_export.zip"),
|
||||
"overwrite": "false",
|
||||
}
|
||||
response = client.post(
|
||||
"/api/v1/assets/import/", data=form_data, content_type="multipart/form-data"
|
||||
)
|
||||
assert response.status_code == 200
|
||||
|
||||
ImportAssetsCommand.assert_called_with(
|
||||
mocked_contents,
|
||||
sparse=False,
|
||||
overwrite=False,
|
||||
passwords=None,
|
||||
ssh_tunnel_passwords=None,
|
||||
ssh_tunnel_private_keys=None,
|
||||
ssh_tunnel_priv_key_passwords=None,
|
||||
encrypted_extra_secrets=None,
|
||||
)
|
||||
|
||||
|
||||
def test_import_assets_not_zip(
|
||||
mocker: MockerFixture,
|
||||
client: Any,
|
||||
|
||||
@@ -100,125 +100,6 @@ def test_values_for_column(database: Database) -> None:
|
||||
assert table.values_for_column("a") == [1, None]
|
||||
|
||||
|
||||
def test_values_for_column_passes_catalog_and_schema(
|
||||
mocker: MockerFixture,
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test that `values_for_column` forwards the dataset's catalog and schema
|
||||
to `database.get_sqla_engine()` so that engine params are adjusted with
|
||||
the correct schema context (e.g. for MySQL, Snowflake, Presto).
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(session.get_bind())
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
||||
|
||||
connection = engine.raw_connection()
|
||||
connection.execute("CREATE TABLE t (a INTEGER, b TEXT)")
|
||||
connection.execute("INSERT INTO t VALUES (1, 'Alice')")
|
||||
connection.commit()
|
||||
|
||||
# Track the catalog/schema values passed to get_sqla_engine
|
||||
captured_kwargs: dict[str, str | None] = {}
|
||||
|
||||
@contextmanager
|
||||
def mock_get_sqla_engine(catalog=None, schema=None, **kwargs):
|
||||
captured_kwargs["catalog"] = catalog
|
||||
captured_kwargs["schema"] = schema
|
||||
yield engine
|
||||
|
||||
mocker.patch.object(
|
||||
database,
|
||||
"get_sqla_engine",
|
||||
new=mock_get_sqla_engine,
|
||||
)
|
||||
|
||||
table = SqlaTable(
|
||||
database=database,
|
||||
catalog="my_catalog",
|
||||
schema="my_schema",
|
||||
table_name="t",
|
||||
columns=[TableColumn(column_name="a")],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pandas.read_sql_query",
|
||||
return_value=pd.DataFrame({"column_values": [1]}),
|
||||
):
|
||||
table.values_for_column("a")
|
||||
|
||||
assert captured_kwargs["catalog"] == "my_catalog"
|
||||
assert captured_kwargs["schema"] == "my_schema"
|
||||
|
||||
|
||||
def test_values_for_column_passes_none_catalog_and_schema(
|
||||
mocker: MockerFixture,
|
||||
session: Session,
|
||||
) -> None:
|
||||
"""
|
||||
Test that `values_for_column` forwards None catalog/schema when the
|
||||
dataset has no catalog or schema set.
|
||||
"""
|
||||
import pandas as pd
|
||||
|
||||
from superset.connectors.sqla.models import SqlaTable, TableColumn
|
||||
from superset.models.core import Database
|
||||
|
||||
SqlaTable.metadata.create_all(session.get_bind())
|
||||
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
database = Database(database_name="db", sqlalchemy_uri="sqlite://")
|
||||
|
||||
connection = engine.raw_connection()
|
||||
connection.execute("CREATE TABLE t (a INTEGER, b TEXT)")
|
||||
connection.execute("INSERT INTO t VALUES (1, 'Alice')")
|
||||
connection.commit()
|
||||
|
||||
captured_kwargs: dict[str, str | None] = {}
|
||||
|
||||
@contextmanager
|
||||
def mock_get_sqla_engine(catalog=None, schema=None, **kwargs):
|
||||
captured_kwargs["catalog"] = catalog
|
||||
captured_kwargs["schema"] = schema
|
||||
yield engine
|
||||
|
||||
mocker.patch.object(
|
||||
database,
|
||||
"get_sqla_engine",
|
||||
new=mock_get_sqla_engine,
|
||||
)
|
||||
|
||||
table = SqlaTable(
|
||||
database=database,
|
||||
schema=None,
|
||||
table_name="t",
|
||||
columns=[TableColumn(column_name="a")],
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pandas.read_sql_query",
|
||||
return_value=pd.DataFrame({"column_values": [1]}),
|
||||
):
|
||||
table.values_for_column("a")
|
||||
|
||||
assert captured_kwargs["catalog"] is None
|
||||
assert captured_kwargs["schema"] is None
|
||||
|
||||
|
||||
def test_values_for_column_with_rls(database: Database) -> None:
|
||||
"""
|
||||
Test the `values_for_column` method with RLS enabled.
|
||||
|
||||
Reference in New Issue
Block a user