Compare commits

..

10 Commits

Author SHA1 Message Date
Joe Li
98f6d627ed Merge branch 'master' into fix-no-top-level-tab 2026-05-11 09:19:34 -07:00
Joe Li
ff323ba328 Merge branch 'master' into fix-no-top-level-tab 2026-05-07 10:02:44 -07:00
Joe Li
78fada7f43 Merge branch 'master' into fix-no-top-level-tab 2026-05-06 19:18:19 -07:00
Joe Li
b71c556560 fix(dashboard): derive min-height assertion from theme.sizeUnit
Use supersetTheme.sizeUnit * 4 instead of hard-coded '16px' so the
test stays in sync with the source rule and resilient to theme
token changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 10:10:19 -07:00
Joe Li
7d6513f946 Merge branch 'master' into fix-no-top-level-tab 2026-05-05 15:11:11 -07:00
Joe Li
d25c07586d Merge branch 'master' into fix-no-top-level-tab 2026-05-04 12:33:46 -07:00
Joe Li
93066ade52 fix(dashboard): assert concrete min-height value and centralize emotion-jest types
- Assert min-height is '16px' (theme.sizeUnit * 4) instead of /\S+/
  to catch zero-height regressions
- Move @emotion/jest type reference from per-file directive to
  src/types/emotion-jest.d.ts for global availability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-04 12:26:09 -07:00
Joe Li
3fe59024ef fix(dashboard): use toHaveStyleRule and getByTestId in droptarget test
Replace CSSOM scan with @emotion/jest toHaveStyleRule matcher using
the target option to verify the .empty-droptarget rule on StyledHeader.
Switch from container.querySelector to getByTestId for consistency
with other tests in this file.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-02 17:14:47 -07:00
Joe Li
0b442ef9ae fix(dashboard): address review feedback and fix test type errors
- Assert the actual .empty-droptarget element exists on the Droppable
  (addresses Copilot review feedback on PR #39423)
- Add @emotion/jest type reference to fix pre-existing toHaveStyleRule
  TS errors in this test file
- Prettier formatting fix

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 13:35:04 -07:00
Joe Li
5bb88b2b74 fix(dashboard): restore top-level tab drop target height for dashboards with content
PR #37891 moved the DashboardHeader out of the root Droppable, leaving
it with zero height when no top-level tabs exist. This made it impossible
to drag a Tabs component onto dashboards that already have content.

Add min-height to .empty-droptarget in StyledHeader, matching the pattern
used by DashboardGrid for its drop targets.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-30 09:49:14 -07:00
36 changed files with 2781 additions and 3144 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -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>&nbsp;
<code>chartType=&quot;{loadingChartType}&quot;</code> &mdash;
{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>&nbsp;
<code>chartType=&quot;{chartType}&quot;</code> &mdash;
{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;
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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: '';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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