mirror of
https://github.com/apache/superset.git
synced 2026-07-08 15:55:33 +00:00
Compare commits
47 Commits
chore/ci/s
...
chore/ts-m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2a566d72f | ||
|
|
7deff1c791 | ||
|
|
a387df5651 | ||
|
|
d494f44720 | ||
|
|
8c7a967857 | ||
|
|
f34286454e | ||
|
|
c3c73415f8 | ||
|
|
989a4ad6a6 | ||
|
|
b7b5739645 | ||
|
|
653a635d1f | ||
|
|
42d1536c80 | ||
|
|
3e4f9e7fbb | ||
|
|
3fed820f3f | ||
|
|
c27bf8da53 | ||
|
|
5fb917e07f | ||
|
|
f6cbc58407 | ||
|
|
0ec29bdd67 | ||
|
|
4f1da90bc0 | ||
|
|
14092b5609 | ||
|
|
7d0d97bae7 | ||
|
|
8ff65607e6 | ||
|
|
121e4960a3 | ||
|
|
1b406e2134 | ||
|
|
9d082798c7 | ||
|
|
8ba307b9d0 | ||
|
|
b03b68c342 | ||
|
|
d705549bc4 | ||
|
|
5f023db487 | ||
|
|
b35866a863 | ||
|
|
b3d99a2811 | ||
|
|
dc009447de | ||
|
|
aebca03533 | ||
|
|
c2c50a2afc | ||
|
|
b0b45cca04 | ||
|
|
5048433eab | ||
|
|
eca23a1277 | ||
|
|
0afbc3ea3d | ||
|
|
8ccf4dfb75 | ||
|
|
9c3759a65d | ||
|
|
c6da8acbc7 | ||
|
|
0a3babf41a | ||
|
|
ff3b98e388 | ||
|
|
bef90c6283 | ||
|
|
0da6adefa3 | ||
|
|
2a39dcfe16 | ||
|
|
599e46ee21 | ||
|
|
574afe41c8 |
@@ -36,7 +36,13 @@ module.exports = {
|
|||||||
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
|
'^@apache-superset/core/(.*)$': '<rootDir>/packages/superset-core/src/$1',
|
||||||
},
|
},
|
||||||
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
|
testEnvironment: '<rootDir>/spec/helpers/jsDomWithFetchAPI.ts',
|
||||||
modulePathIgnorePatterns: ['<rootDir>/packages/generator-superset'],
|
modulePathIgnorePatterns: [
|
||||||
|
'<rootDir>/packages/generator-superset',
|
||||||
|
'<rootDir>/packages/.*/esm',
|
||||||
|
'<rootDir>/packages/.*/lib',
|
||||||
|
'<rootDir>/plugins/.*/esm',
|
||||||
|
'<rootDir>/plugins/.*/lib',
|
||||||
|
],
|
||||||
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
|
setupFilesAfterEnv: ['<rootDir>/spec/helpers/setup.ts'],
|
||||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||||
testEnvironmentOptions: {
|
testEnvironmentOptions: {
|
||||||
|
|||||||
@@ -20,7 +20,6 @@
|
|||||||
import { t } from '@apache-superset/core';
|
import { t } from '@apache-superset/core';
|
||||||
import { SupersetTheme } from '@apache-superset/core/ui';
|
import { SupersetTheme } from '@apache-superset/core/ui';
|
||||||
import { FallbackPropsWithDimension } from './SuperChart';
|
import { FallbackPropsWithDimension } from './SuperChart';
|
||||||
import { getErrorMessage } from 'react-error-boundary';
|
|
||||||
|
|
||||||
export type Props = Partial<FallbackPropsWithDimension>;
|
export type Props = Partial<FallbackPropsWithDimension>;
|
||||||
|
|
||||||
@@ -39,7 +38,13 @@ export default function FallbackComponent({ error, height, width }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<b>{t('Oops! An error occurred!')}</b>
|
<b>{t('Oops! An error occurred!')}</b>
|
||||||
</div>
|
</div>
|
||||||
<code>{error ? getErrorMessage(error) : 'Unknown Error'}</code>
|
<code>
|
||||||
|
{error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: error
|
||||||
|
? String(error)
|
||||||
|
: 'Unknown Error'}
|
||||||
|
</code>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -298,7 +298,7 @@ const ResultSet = ({
|
|||||||
const force = false;
|
const force = false;
|
||||||
const includeAppRoot = openInNewWindow;
|
const includeAppRoot = openInNewWindow;
|
||||||
const url = mountExploreUrl(
|
const url = mountExploreUrl(
|
||||||
null,
|
'base',
|
||||||
{
|
{
|
||||||
[URL_PARAMS.formDataKey.name]: key,
|
[URL_PARAMS.formDataKey.name]: key,
|
||||||
},
|
},
|
||||||
@@ -428,7 +428,10 @@ const ResultSet = ({
|
|||||||
)}
|
)}
|
||||||
{canExportData && (
|
{canExportData && (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
text={prepareCopyToClipboardTabularData(data, columns)}
|
text={prepareCopyToClipboardTabularData(
|
||||||
|
data,
|
||||||
|
columns.map(c => c.column_name),
|
||||||
|
)}
|
||||||
wrapped={false}
|
wrapped={false}
|
||||||
copyNode={
|
copyNode={
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ export const SaveDatasetModal = ({
|
|||||||
]);
|
]);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|
||||||
const url = mountExploreUrl(null, {
|
const url = mountExploreUrl('base', {
|
||||||
[URL_PARAMS.formDataKey.name]: key,
|
[URL_PARAMS.formDataKey.name]: key,
|
||||||
});
|
});
|
||||||
createWindow(url);
|
createWindow(url);
|
||||||
@@ -364,7 +364,7 @@ export const SaveDatasetModal = ({
|
|||||||
})
|
})
|
||||||
.then((key: string) => {
|
.then((key: string) => {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
const url = mountExploreUrl(null, {
|
const url = mountExploreUrl('base', {
|
||||||
[URL_PARAMS.formDataKey.name]: key,
|
[URL_PARAMS.formDataKey.name]: key,
|
||||||
});
|
});
|
||||||
createWindow(url);
|
createWindow(url);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
QueryFormData,
|
QueryFormData,
|
||||||
SqlaFormData,
|
SqlaFormData,
|
||||||
ClientErrorObject,
|
ClientErrorObject,
|
||||||
|
DataRecordFilters,
|
||||||
type JsonObject,
|
type JsonObject,
|
||||||
type AgGridChartState,
|
type AgGridChartState,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
@@ -51,13 +52,13 @@ export interface ChartProps {
|
|||||||
chartId: number;
|
chartId: number;
|
||||||
datasource?: Datasource;
|
datasource?: Datasource;
|
||||||
dashboardId?: number;
|
dashboardId?: number;
|
||||||
initialValues?: object;
|
initialValues?: DataRecordFilters;
|
||||||
formData: QueryFormData;
|
formData: QueryFormData;
|
||||||
labelColors?: string;
|
labelColors?: string;
|
||||||
sharedLabelColors?: string;
|
sharedLabelColors?: string;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
setControlValue: Function;
|
setControlValue: (name: string, value: unknown) => void;
|
||||||
timeout?: number;
|
timeout?: number;
|
||||||
vizType: string;
|
vizType: string;
|
||||||
triggerRender?: boolean;
|
triggerRender?: boolean;
|
||||||
@@ -76,7 +77,7 @@ export interface ChartProps {
|
|||||||
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
||||||
onFilterMenuClose?: (chartId: number, column: string) => void;
|
onFilterMenuClose?: (chartId: number, column: string) => void;
|
||||||
ownState?: JsonObject;
|
ownState?: JsonObject;
|
||||||
postTransformProps?: Function;
|
postTransformProps?: (props: JsonObject) => JsonObject;
|
||||||
datasetsStatus?: 'loading' | 'error' | 'complete';
|
datasetsStatus?: 'loading' | 'error' | 'complete';
|
||||||
isInView?: boolean;
|
isInView?: boolean;
|
||||||
emitCrossFilters?: boolean;
|
emitCrossFilters?: boolean;
|
||||||
@@ -100,6 +101,7 @@ export type Actions = {
|
|||||||
chartId: number,
|
chartId: number,
|
||||||
arg2: string | null,
|
arg2: string | null,
|
||||||
): Dispatch;
|
): Dispatch;
|
||||||
|
chartRenderingSucceeded(chartId: number): Dispatch;
|
||||||
postChartFormData(
|
postChartFormData(
|
||||||
formData: SqlaFormData,
|
formData: SqlaFormData,
|
||||||
arg1: boolean,
|
arg1: boolean,
|
||||||
@@ -300,7 +302,11 @@ class Chart extends PureComponent<ChartProps, {}> {
|
|||||||
isCurrentUserBot() ? (
|
isCurrentUserBot() ? (
|
||||||
<ChartRenderer
|
<ChartRenderer
|
||||||
{...this.props}
|
{...this.props}
|
||||||
source={this.props.dashboardId ? 'dashboard' : 'explore'}
|
source={
|
||||||
|
this.props.dashboardId
|
||||||
|
? ChartSource.Dashboard
|
||||||
|
: ChartSource.Explore
|
||||||
|
}
|
||||||
data-test={this.props.vizType}
|
data-test={this.props.vizType}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -21,13 +21,27 @@ import {
|
|||||||
ChartMetadata,
|
ChartMetadata,
|
||||||
getChartMetadataRegistry,
|
getChartMetadataRegistry,
|
||||||
VizType,
|
VizType,
|
||||||
|
JsonObject,
|
||||||
|
FeatureFlagMap,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import ChartRenderer from 'src/components/Chart/ChartRenderer';
|
import ChartRenderer, {
|
||||||
|
ChartRendererProps,
|
||||||
|
} from 'src/components/Chart/ChartRenderer';
|
||||||
import { ChartSource } from 'src/types/ChartSource';
|
import { ChartSource } from 'src/types/ChartSource';
|
||||||
|
import type { Dispatch } from 'redux';
|
||||||
|
|
||||||
|
interface MockSuperChartProps {
|
||||||
|
postTransformProps?: (props: JsonObject) => JsonObject;
|
||||||
|
formData?: JsonObject;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
jest.mock('@superset-ui/core', () => ({
|
jest.mock('@superset-ui/core', () => ({
|
||||||
...jest.requireActual('@superset-ui/core'),
|
...jest.requireActual('@superset-ui/core'),
|
||||||
SuperChart: ({ postTransformProps = x => x, ...props }) => (
|
SuperChart: ({
|
||||||
|
postTransformProps = (x: JsonObject) => x,
|
||||||
|
...props
|
||||||
|
}: MockSuperChartProps) => (
|
||||||
<div data-test="mock-super-chart">
|
<div data-test="mock-super-chart">
|
||||||
{JSON.stringify(postTransformProps(props).formData)}
|
{JSON.stringify(postTransformProps(props).formData)}
|
||||||
</div>
|
</div>
|
||||||
@@ -39,42 +53,83 @@ jest.mock(
|
|||||||
() => () => <div data-test="mock-chart-context-menu" />,
|
() => () => <div data-test="mock-chart-context-menu" />,
|
||||||
);
|
);
|
||||||
|
|
||||||
const requiredProps = {
|
interface MockActions {
|
||||||
chartId: 1,
|
chartRenderingSucceeded: (chartId: number) => Dispatch;
|
||||||
datasource: {},
|
chartRenderingFailed: (
|
||||||
formData: { testControl: 'foo' },
|
error: string,
|
||||||
latestQueryFormData: {
|
chartId: number,
|
||||||
testControl: 'bar',
|
componentStack: string | null,
|
||||||
},
|
) => Dispatch;
|
||||||
vizType: VizType.Table,
|
logEvent: (eventName: string, payload: JsonObject) => Dispatch;
|
||||||
source: ChartSource.Dashboard,
|
}
|
||||||
|
|
||||||
|
const mockActions: MockActions = {
|
||||||
|
chartRenderingSucceeded: jest.fn() as unknown as (
|
||||||
|
chartId: number,
|
||||||
|
) => Dispatch,
|
||||||
|
chartRenderingFailed: jest.fn() as unknown as (
|
||||||
|
error: string,
|
||||||
|
chartId: number,
|
||||||
|
componentStack: string | null,
|
||||||
|
) => Dispatch,
|
||||||
|
logEvent: jest.fn() as unknown as (
|
||||||
|
eventName: string,
|
||||||
|
payload: JsonObject,
|
||||||
|
) => Dispatch,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const requiredProps: Partial<ChartRendererProps> = {
|
||||||
|
chartId: 1,
|
||||||
|
datasource: {} as ChartRendererProps['datasource'],
|
||||||
|
formData: {
|
||||||
|
testControl: 'foo',
|
||||||
|
} as unknown as ChartRendererProps['formData'],
|
||||||
|
latestQueryFormData: {
|
||||||
|
testControl: 'bar',
|
||||||
|
} as unknown as ChartRendererProps['latestQueryFormData'],
|
||||||
|
vizType: VizType.Table,
|
||||||
|
source: ChartSource.Dashboard,
|
||||||
|
actions: mockActions as ChartRendererProps['actions'],
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
featureFlags: FeatureFlagMap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
window.featureFlags = { DRILL_TO_DETAIL: true };
|
window.featureFlags = { DRILL_TO_DETAIL: true } as FeatureFlagMap;
|
||||||
});
|
});
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
window.featureFlags = {};
|
window.featureFlags = {} as FeatureFlagMap;
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render SuperChart', () => {
|
test('should render SuperChart', () => {
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<ChartRenderer {...requiredProps} chartIsStale={false} />,
|
<ChartRenderer
|
||||||
|
{...(requiredProps as ChartRendererProps)}
|
||||||
|
chartIsStale={false}
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use latestQueryFormData instead of formData when chartIsStale is true', () => {
|
test('should use latestQueryFormData instead of formData when chartIsStale is true', () => {
|
||||||
const { getByTestId } = render(
|
const { getByTestId } = render(
|
||||||
<ChartRenderer {...requiredProps} chartIsStale />,
|
<ChartRenderer {...(requiredProps as ChartRendererProps)} chartIsStale />,
|
||||||
);
|
);
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
JSON.stringify({ testControl: 'bar' }),
|
JSON.stringify({
|
||||||
|
testControl: 'bar',
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render chart context menu', () => {
|
test('should render chart context menu', () => {
|
||||||
const { getByTestId } = render(<ChartRenderer {...requiredProps} />);
|
const { getByTestId } = render(
|
||||||
|
<ChartRenderer {...(requiredProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
expect(getByTestId('mock-chart-context-menu')).toBeInTheDocument();
|
expect(getByTestId('mock-chart-context-menu')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,78 +144,92 @@ test('should not render chart context menu if the context menu is suppressed for
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const { queryByTestId } = render(
|
const { queryByTestId } = render(
|
||||||
<ChartRenderer {...requiredProps} vizType="chart_without_context_menu" />,
|
<ChartRenderer
|
||||||
|
{...(requiredProps as ChartRendererProps)}
|
||||||
|
vizType="chart_without_context_menu"
|
||||||
|
/>,
|
||||||
);
|
);
|
||||||
expect(queryByTestId('mock-chart-context-menu')).not.toBeInTheDocument();
|
expect(queryByTestId('mock-chart-context-menu')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should detect changes in matrixify properties', () => {
|
test('should detect changes in matrixify properties', () => {
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
formData: {
|
formData: {
|
||||||
...requiredProps.formData,
|
...requiredProps.formData,
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
||||||
matrixify_dimension_y: { dimension: 'category', values: ['Tech'] },
|
matrixify_dimension_y: { dimension: 'category', values: ['Tech'] },
|
||||||
matrixify_charts_per_row: 3,
|
matrixify_charts_per_row: 3,
|
||||||
matrixify_show_row_labels: true,
|
matrixify_show_row_labels: true,
|
||||||
},
|
},
|
||||||
queriesResponse: [{ data: 'initial' }],
|
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-unused-vars
|
render(<ChartRenderer {...(initialProps as ChartRendererProps)} />);
|
||||||
const wrapper = render(<ChartRenderer {...initialProps} />);
|
|
||||||
|
|
||||||
// Since we can't directly test shouldComponentUpdate, we verify the component
|
// Since we can't directly test shouldComponentUpdate, we verify the component
|
||||||
// correctly identifies matrixify-related properties by checking the implementation
|
// correctly identifies matrixify-related properties by checking the implementation
|
||||||
expect(initialProps.formData.matrixify_enable_vertical_layout).toBe(true);
|
expect(
|
||||||
expect(initialProps.formData.matrixify_dimension_x).toEqual({
|
(initialProps.formData as JsonObject).matrixify_enable_vertical_layout,
|
||||||
|
).toBe(true);
|
||||||
|
expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({
|
||||||
dimension: 'country',
|
dimension: 'country',
|
||||||
values: ['USA'],
|
values: ['USA'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should detect changes in postTransformProps', () => {
|
test('should detect changes in postTransformProps', () => {
|
||||||
const postTransformProps = jest.fn(x => x);
|
const postTransformProps = jest.fn((x: JsonObject) => x);
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
queriesResponse: [{ data: 'initial' }],
|
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
const { rerender } = render(<ChartRenderer {...initialProps} />);
|
const { rerender } = render(
|
||||||
const updatedProps = {
|
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
|
const updatedProps: Partial<ChartRendererProps> = {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
postTransformProps,
|
postTransformProps,
|
||||||
};
|
};
|
||||||
expect(postTransformProps).toHaveBeenCalledTimes(0);
|
expect(postTransformProps).toHaveBeenCalledTimes(0);
|
||||||
rerender(<ChartRenderer {...updatedProps} />);
|
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||||
expect(postTransformProps).toHaveBeenCalledTimes(1);
|
expect(postTransformProps).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should identify matrixify property changes correctly', () => {
|
test('should identify matrixify property changes correctly', () => {
|
||||||
// Test that formData with different matrixify properties triggers updates
|
// Test that formData with different matrixify properties triggers updates
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
||||||
matrixify_charts_per_row: 3,
|
matrixify_charts_per_row: 3,
|
||||||
},
|
},
|
||||||
queriesResponse: [{ data: 'current' }],
|
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
const { rerender, getByTestId } = render(
|
||||||
|
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
JSON.stringify(initialProps.formData),
|
JSON.stringify(initialProps.formData),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Update with changed matrixify_dimension_x values
|
// Update with changed matrixify_dimension_x values
|
||||||
const updatedProps = {
|
const updatedProps: Partial<ChartRendererProps> = {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: {
|
matrixify_dimension_x: {
|
||||||
dimension: 'country',
|
dimension: 'country',
|
||||||
@@ -170,7 +239,7 @@ test('should identify matrixify property changes correctly', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(<ChartRenderer {...updatedProps} />);
|
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||||
|
|
||||||
// Verify the component re-rendered with new props
|
// Verify the component re-rendered with new props
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
@@ -179,31 +248,37 @@ test('should identify matrixify property changes correctly', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should handle matrixify-related form data changes', () => {
|
test('should handle matrixify-related form data changes', () => {
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
regular_control: 'value1',
|
regular_control: 'value1',
|
||||||
},
|
},
|
||||||
queriesResponse: [{ data: 'current' }],
|
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
const { rerender, getByTestId } = render(
|
||||||
|
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
JSON.stringify(initialProps.formData),
|
JSON.stringify(initialProps.formData),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Enable matrixify
|
// Enable matrixify
|
||||||
const updatedProps = {
|
const updatedProps: Partial<ChartRendererProps> = {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true, // This is a significant change
|
matrixify_enable_vertical_layout: true, // This is a significant change
|
||||||
regular_control: 'value1',
|
regular_control: 'value1',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(<ChartRenderer {...updatedProps} />);
|
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||||
|
|
||||||
// Verify the component re-rendered with matrixify enabled
|
// Verify the component re-rendered with matrixify enabled
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
@@ -212,32 +287,38 @@ test('should handle matrixify-related form data changes', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should detect matrixify property addition', () => {
|
test('should detect matrixify property addition', () => {
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
// No matrixify_dimension_x initially
|
// No matrixify_dimension_x initially
|
||||||
},
|
},
|
||||||
queriesResponse: [{ data: 'current' }],
|
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
const { rerender, getByTestId } = render(
|
||||||
|
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
JSON.stringify(initialProps.formData),
|
JSON.stringify(initialProps.formData),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add matrixify_dimension_x
|
// Add matrixify_dimension_x
|
||||||
const updatedProps = {
|
const updatedProps: Partial<ChartRendererProps> = {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, // Added
|
matrixify_dimension_x: { dimension: 'country', values: ['USA'] }, // Added
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(<ChartRenderer {...updatedProps} />);
|
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||||
|
|
||||||
// Verify the component re-rendered with the new property
|
// Verify the component re-rendered with the new property
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
@@ -246,9 +327,11 @@ test('should detect matrixify property addition', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should detect nested matrixify property changes', () => {
|
test('should detect nested matrixify property changes', () => {
|
||||||
const initialProps = {
|
const initialProps: Partial<ChartRendererProps> = {
|
||||||
...requiredProps,
|
...requiredProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: {
|
matrixify_dimension_x: {
|
||||||
dimension: 'country',
|
dimension: 'country',
|
||||||
@@ -256,20 +339,24 @@ test('should detect nested matrixify property changes', () => {
|
|||||||
topN: { metric: 'sales', value: 10 },
|
topN: { metric: 'sales', value: 10 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
queriesResponse: [{ data: 'current' }],
|
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||||
chartStatus: 'success',
|
chartStatus: 'success',
|
||||||
};
|
};
|
||||||
|
|
||||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
const { rerender, getByTestId } = render(
|
||||||
|
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||||
|
);
|
||||||
|
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
JSON.stringify(initialProps.formData),
|
JSON.stringify(initialProps.formData),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Change nested topN value
|
// Change nested topN value
|
||||||
const updatedProps = {
|
const updatedProps: Partial<ChartRendererProps> = {
|
||||||
...initialProps,
|
...initialProps,
|
||||||
formData: {
|
formData: {
|
||||||
|
datasource: '',
|
||||||
|
viz_type: VizType.Table,
|
||||||
matrixify_enable_vertical_layout: true,
|
matrixify_enable_vertical_layout: true,
|
||||||
matrixify_dimension_x: {
|
matrixify_dimension_x: {
|
||||||
dimension: 'country',
|
dimension: 'country',
|
||||||
@@ -279,7 +366,7 @@ test('should detect nested matrixify property changes', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
rerender(<ChartRenderer {...updatedProps} />);
|
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||||
|
|
||||||
// Verify the component re-rendered with the nested change
|
// Verify the component re-rendered with the nested change
|
||||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||||
@@ -17,8 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
||||||
import PropTypes from 'prop-types';
|
import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
|
||||||
import { createRef, Component } from 'react';
|
|
||||||
import {
|
import {
|
||||||
SuperChart,
|
SuperChart,
|
||||||
Behavior,
|
Behavior,
|
||||||
@@ -26,46 +25,147 @@ import {
|
|||||||
VizType,
|
VizType,
|
||||||
isFeatureEnabled,
|
isFeatureEnabled,
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
|
QueryFormData,
|
||||||
|
AnnotationData,
|
||||||
|
DataMask,
|
||||||
|
QueryData,
|
||||||
|
JsonObject,
|
||||||
|
LatestQueryFormData,
|
||||||
|
AgGridChartState,
|
||||||
|
ContextMenuFilters,
|
||||||
|
DataRecordFilters,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { logging } from '@apache-superset/core';
|
import { logging } from '@apache-superset/core';
|
||||||
import { t } from '@apache-superset/core/ui';
|
import { t } from '@apache-superset/core/ui';
|
||||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||||
import { EmptyState } from '@superset-ui/core/components';
|
import { EmptyState } from '@superset-ui/core/components';
|
||||||
import { ChartSource } from 'src/types/ChartSource';
|
import { ChartSource } from 'src/types/ChartSource';
|
||||||
import ChartContextMenu from './ChartContextMenu/ChartContextMenu';
|
import type { Datasource, ChartStatus } from 'src/explore/types';
|
||||||
|
import type { Dispatch } from 'redux';
|
||||||
|
import ChartContextMenu, {
|
||||||
|
ChartContextMenuRef,
|
||||||
|
} from './ChartContextMenu/ChartContextMenu';
|
||||||
|
|
||||||
const propTypes = {
|
// Types for filter values
|
||||||
annotationData: PropTypes.object,
|
type FilterValue = string | number | boolean | null | undefined;
|
||||||
actions: PropTypes.object,
|
|
||||||
chartId: PropTypes.number.isRequired,
|
// LegendState type based on ECharts
|
||||||
datasource: PropTypes.object,
|
interface LegendState {
|
||||||
initialValues: PropTypes.object,
|
[name: string]: boolean;
|
||||||
formData: PropTypes.object.isRequired,
|
}
|
||||||
latestQueryFormData: PropTypes.object,
|
|
||||||
labelsColor: PropTypes.object,
|
// Webpack globals declaration
|
||||||
labelsColorMap: PropTypes.object,
|
declare const __webpack_require__:
|
||||||
height: PropTypes.number,
|
| {
|
||||||
width: PropTypes.number,
|
h?: () => string;
|
||||||
setControlValue: PropTypes.func,
|
}
|
||||||
vizType: PropTypes.string.isRequired,
|
| undefined;
|
||||||
triggerRender: PropTypes.bool,
|
|
||||||
// state
|
// Types for chart actions
|
||||||
chartAlert: PropTypes.string,
|
interface ChartActions {
|
||||||
chartStatus: PropTypes.string,
|
chartRenderingSucceeded: (chartId: number) => Dispatch;
|
||||||
queriesResponse: PropTypes.arrayOf(PropTypes.object),
|
chartRenderingFailed: (
|
||||||
triggerQuery: PropTypes.bool,
|
error: string,
|
||||||
chartIsStale: PropTypes.bool,
|
chartId: number,
|
||||||
// dashboard callbacks
|
componentStack: string | null,
|
||||||
addFilter: PropTypes.func,
|
) => Dispatch;
|
||||||
setDataMask: PropTypes.func,
|
logEvent: (
|
||||||
onFilterMenuOpen: PropTypes.func,
|
eventName: string,
|
||||||
onFilterMenuClose: PropTypes.func,
|
payload: {
|
||||||
ownState: PropTypes.object,
|
slice_id: number;
|
||||||
postTransformProps: PropTypes.func,
|
viz_type?: string;
|
||||||
source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]),
|
start_offset: number;
|
||||||
emitCrossFilters: PropTypes.bool,
|
ts: number;
|
||||||
onChartStateChange: PropTypes.func,
|
duration: number;
|
||||||
};
|
has_err?: boolean;
|
||||||
|
error_details?: string;
|
||||||
|
},
|
||||||
|
) => Dispatch;
|
||||||
|
updateDataMask?: (chartId: number, dataMask: DataMask) => Dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types for own state
|
||||||
|
interface OwnState {
|
||||||
|
searchText?: string;
|
||||||
|
agGridFilterModel?: Record<string, unknown>;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types for filter state
|
||||||
|
interface FilterState {
|
||||||
|
value?: FilterValue[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Props interface
|
||||||
|
export interface ChartRendererProps {
|
||||||
|
annotationData?: AnnotationData;
|
||||||
|
actions: ChartActions;
|
||||||
|
chartId: number;
|
||||||
|
datasource?: Datasource;
|
||||||
|
initialValues?: DataRecordFilters;
|
||||||
|
formData: QueryFormData;
|
||||||
|
latestQueryFormData?: LatestQueryFormData;
|
||||||
|
labelsColor?: Record<string, string>;
|
||||||
|
labelsColorMap?: Record<string, string>;
|
||||||
|
height?: number;
|
||||||
|
width?: number;
|
||||||
|
setControlValue?: (name: string, value: unknown) => void;
|
||||||
|
vizType: string;
|
||||||
|
triggerRender?: boolean;
|
||||||
|
chartAlert?: string;
|
||||||
|
chartStatus?: ChartStatus | null;
|
||||||
|
queriesResponse?: QueryData[] | null;
|
||||||
|
triggerQuery?: boolean;
|
||||||
|
chartIsStale?: boolean;
|
||||||
|
addFilter?: (
|
||||||
|
col: string,
|
||||||
|
vals: FilterValue[],
|
||||||
|
merge?: boolean,
|
||||||
|
refresh?: boolean,
|
||||||
|
) => void;
|
||||||
|
setDataMask?: (dataMask: DataMask) => void;
|
||||||
|
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
||||||
|
onFilterMenuClose?: (chartId: number, column: string) => void;
|
||||||
|
ownState?: OwnState;
|
||||||
|
filterState?: FilterState;
|
||||||
|
postTransformProps?: (props: JsonObject) => JsonObject;
|
||||||
|
source?: ChartSource;
|
||||||
|
emitCrossFilters?: boolean;
|
||||||
|
cacheBusterProp?: string;
|
||||||
|
onChartStateChange?: (chartState: AgGridChartState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// State interface
|
||||||
|
interface ChartRendererState {
|
||||||
|
showContextMenu: boolean;
|
||||||
|
inContextMenu: boolean;
|
||||||
|
legendState: LegendState | undefined;
|
||||||
|
legendIndex: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hooks interface
|
||||||
|
interface ChartHooks {
|
||||||
|
onAddFilter: (
|
||||||
|
col: string,
|
||||||
|
vals: FilterValue[],
|
||||||
|
merge?: boolean,
|
||||||
|
refresh?: boolean,
|
||||||
|
) => void;
|
||||||
|
onContextMenu?: (
|
||||||
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
filters?: ContextMenuFilters,
|
||||||
|
) => void;
|
||||||
|
onError: (error: Error, info: { componentStack: string } | null) => void;
|
||||||
|
setControlValue: (name: string, value: unknown) => void;
|
||||||
|
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
||||||
|
onFilterMenuClose?: (chartId: number, column: string) => void;
|
||||||
|
onLegendStateChanged: (legendState: LegendState) => void;
|
||||||
|
setDataMask: (dataMask: DataMask) => void;
|
||||||
|
onLegendScroll: (legendIndex: number) => void;
|
||||||
|
onChartStateChange?: (chartState: AgGridChartState) => void;
|
||||||
|
}
|
||||||
|
|
||||||
const BLANK = {};
|
const BLANK = {};
|
||||||
|
|
||||||
@@ -74,17 +174,29 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
|||||||
|
|
||||||
const behaviors = [Behavior.InteractiveChart];
|
const behaviors = [Behavior.InteractiveChart];
|
||||||
|
|
||||||
const defaultProps = {
|
const defaultProps: Partial<ChartRendererProps> = {
|
||||||
addFilter: () => BLANK,
|
addFilter: () => BLANK,
|
||||||
onFilterMenuOpen: () => BLANK,
|
onFilterMenuOpen: () => BLANK,
|
||||||
onFilterMenuClose: () => BLANK,
|
onFilterMenuClose: () => BLANK,
|
||||||
initialValues: BLANK,
|
initialValues: BLANK,
|
||||||
setControlValue() {},
|
setControlValue: () => {},
|
||||||
triggerRender: false,
|
triggerRender: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
class ChartRenderer extends Component {
|
class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
|
||||||
constructor(props) {
|
static defaultProps = defaultProps;
|
||||||
|
|
||||||
|
private hasQueryResponseChange: boolean;
|
||||||
|
|
||||||
|
private contextMenuRef: RefObject<ChartContextMenuRef>;
|
||||||
|
|
||||||
|
private hooks: ChartHooks;
|
||||||
|
|
||||||
|
private mutableQueriesResponse: QueryData[] | null | undefined;
|
||||||
|
|
||||||
|
private renderStartTime: number;
|
||||||
|
|
||||||
|
constructor(props: ChartRendererProps) {
|
||||||
super(props);
|
super(props);
|
||||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||||
props.formData.viz_type ?? props.vizType,
|
props.formData.viz_type ?? props.vizType,
|
||||||
@@ -99,8 +211,9 @@ class ChartRenderer extends Component {
|
|||||||
legendIndex: 0,
|
legendIndex: 0,
|
||||||
};
|
};
|
||||||
this.hasQueryResponseChange = false;
|
this.hasQueryResponseChange = false;
|
||||||
|
this.renderStartTime = 0;
|
||||||
|
|
||||||
this.contextMenuRef = createRef();
|
this.contextMenuRef = createRef<ChartContextMenuRef>();
|
||||||
|
|
||||||
this.handleAddFilter = this.handleAddFilter.bind(this);
|
this.handleAddFilter = this.handleAddFilter.bind(this);
|
||||||
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
||||||
@@ -123,8 +236,8 @@ class ChartRenderer extends Component {
|
|||||||
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
||||||
onFilterMenuClose: this.props.onFilterMenuClose,
|
onFilterMenuClose: this.props.onFilterMenuClose,
|
||||||
onLegendStateChanged: this.handleLegendStateChanged,
|
onLegendStateChanged: this.handleLegendStateChanged,
|
||||||
setDataMask: dataMask => {
|
setDataMask: (dataMask: DataMask) => {
|
||||||
this.props.actions?.updateDataMask(this.props.chartId, dataMask);
|
this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
|
||||||
},
|
},
|
||||||
onLegendScroll: this.handleLegendScroll,
|
onLegendScroll: this.handleLegendScroll,
|
||||||
onChartStateChange: this.props.onChartStateChange,
|
onChartStateChange: this.props.onChartStateChange,
|
||||||
@@ -136,10 +249,13 @@ class ChartRenderer extends Component {
|
|||||||
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldComponentUpdate(nextProps, nextState) {
|
shouldComponentUpdate(
|
||||||
|
nextProps: ChartRendererProps,
|
||||||
|
nextState: ChartRendererState,
|
||||||
|
): boolean {
|
||||||
const resultsReady =
|
const resultsReady =
|
||||||
nextProps.queriesResponse &&
|
nextProps.queriesResponse &&
|
||||||
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
|
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
|
||||||
!nextProps.queriesResponse?.[0]?.error;
|
!nextProps.queriesResponse?.[0]?.error;
|
||||||
|
|
||||||
if (resultsReady) {
|
if (resultsReady) {
|
||||||
@@ -154,22 +270,27 @@ class ChartRenderer extends Component {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if any matrixify-related properties have changed
|
// Check if any matrixify-related properties have changed
|
||||||
const hasMatrixifyChanges = () => {
|
const hasMatrixifyChanges = (): boolean => {
|
||||||
|
const nextFormData = nextProps.formData as JsonObject;
|
||||||
|
const currentFormData = this.props.formData as JsonObject;
|
||||||
const isMatrixifyEnabled =
|
const isMatrixifyEnabled =
|
||||||
nextProps.formData.matrixify_enable_vertical_layout === true ||
|
nextFormData.matrixify_enable_vertical_layout === true ||
|
||||||
nextProps.formData.matrixify_enable_horizontal_layout === true;
|
nextFormData.matrixify_enable_horizontal_layout === true;
|
||||||
if (!isMatrixifyEnabled) return false;
|
if (!isMatrixifyEnabled) return false;
|
||||||
|
|
||||||
// Check all matrixify-related properties
|
// Check all matrixify-related properties
|
||||||
const matrixifyKeys = Object.keys(nextProps.formData).filter(key =>
|
const matrixifyKeys = Object.keys(nextFormData).filter(key =>
|
||||||
key.startsWith('matrixify_'),
|
key.startsWith('matrixify_'),
|
||||||
);
|
);
|
||||||
|
|
||||||
return matrixifyKeys.some(
|
return matrixifyKeys.some(
|
||||||
key => !isEqual(nextProps.formData[key], this.props.formData[key]),
|
key => !isEqual(nextFormData[key], currentFormData[key]),
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const nextFormData = nextProps.formData as JsonObject;
|
||||||
|
const currentFormData = this.props.formData as JsonObject;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
this.hasQueryResponseChange ||
|
this.hasQueryResponseChange ||
|
||||||
!isEqual(nextProps.datasource, this.props.datasource) ||
|
!isEqual(nextProps.datasource, this.props.datasource) ||
|
||||||
@@ -178,13 +299,12 @@ class ChartRenderer extends Component {
|
|||||||
nextProps.filterState !== this.props.filterState ||
|
nextProps.filterState !== this.props.filterState ||
|
||||||
nextProps.height !== this.props.height ||
|
nextProps.height !== this.props.height ||
|
||||||
nextProps.width !== this.props.width ||
|
nextProps.width !== this.props.width ||
|
||||||
nextProps.triggerRender ||
|
nextProps.triggerRender === true ||
|
||||||
nextProps.labelsColor !== this.props.labelsColor ||
|
nextProps.labelsColor !== this.props.labelsColor ||
|
||||||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
||||||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
|
nextFormData.color_scheme !== currentFormData.color_scheme ||
|
||||||
nextProps.formData.stack !== this.props.formData.stack ||
|
nextFormData.stack !== currentFormData.stack ||
|
||||||
nextProps.formData.subcategories !==
|
nextFormData.subcategories !== currentFormData.subcategories ||
|
||||||
this.props.formData.subcategories ||
|
|
||||||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
||||||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
|
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
|
||||||
nextProps.postTransformProps !== this.props.postTransformProps ||
|
nextProps.postTransformProps !== this.props.postTransformProps ||
|
||||||
@@ -194,13 +314,18 @@ class ChartRenderer extends Component {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
handleAddFilter(col, vals, merge = true, refresh = true) {
|
handleAddFilter(
|
||||||
this.props.addFilter(col, vals, merge, refresh);
|
col: string,
|
||||||
|
vals: FilterValue[],
|
||||||
|
merge = true,
|
||||||
|
refresh = true,
|
||||||
|
): void {
|
||||||
|
this.props.addFilter?.(col, vals, merge, refresh);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRenderSuccess() {
|
handleRenderSuccess(): void {
|
||||||
const { actions, chartStatus, chartId, vizType } = this.props;
|
const { actions, chartStatus, chartId, vizType } = this.props;
|
||||||
if (['loading', 'rendered'].indexOf(chartStatus) < 0) {
|
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
|
||||||
actions.chartRenderingSucceeded(chartId);
|
actions.chartRenderingSucceeded(chartId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,7 +342,10 @@ class ChartRenderer extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleRenderFailure(error, info) {
|
handleRenderFailure(
|
||||||
|
error: Error,
|
||||||
|
info: { componentStack: string } | null,
|
||||||
|
): void {
|
||||||
const { actions, chartId } = this.props;
|
const { actions, chartId } = this.props;
|
||||||
logging.warn(error);
|
logging.warn(error);
|
||||||
actions.chartRenderingFailed(
|
actions.chartRenderingFailed(
|
||||||
@@ -239,44 +367,48 @@ class ChartRenderer extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleSetControlValue(...args) {
|
handleSetControlValue(name: string, value: unknown): void {
|
||||||
const { setControlValue } = this.props;
|
const { setControlValue } = this.props;
|
||||||
if (setControlValue) {
|
if (setControlValue) {
|
||||||
setControlValue(...args);
|
setControlValue(name, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleOnContextMenu(offsetX, offsetY, filters) {
|
handleOnContextMenu(
|
||||||
this.contextMenuRef.current.open(offsetX, offsetY, filters);
|
offsetX: number,
|
||||||
|
offsetY: number,
|
||||||
|
filters?: ContextMenuFilters,
|
||||||
|
): void {
|
||||||
|
this.contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||||
this.setState({ inContextMenu: true });
|
this.setState({ inContextMenu: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContextMenuSelected() {
|
handleContextMenuSelected(): void {
|
||||||
this.setState({ inContextMenu: false });
|
this.setState({ inContextMenu: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContextMenuClosed() {
|
handleContextMenuClosed(): void {
|
||||||
this.setState({ inContextMenu: false });
|
this.setState({ inContextMenu: false });
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLegendStateChanged(legendState) {
|
handleLegendStateChanged(legendState: LegendState): void {
|
||||||
this.setState({ legendState });
|
this.setState({ legendState });
|
||||||
}
|
}
|
||||||
|
|
||||||
// When viz plugins don't handle `contextmenu` event, fallback handler
|
// When viz plugins don't handle `contextmenu` event, fallback handler
|
||||||
// calls `handleOnContextMenu` with no `filters` param.
|
// calls `handleOnContextMenu` with no `filters` param.
|
||||||
onContextMenuFallback(event) {
|
onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
|
||||||
if (!this.state.inContextMenu) {
|
if (!this.state.inContextMenu) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.handleOnContextMenu(event.clientX, event.clientY);
|
this.handleOnContextMenu(event.clientX, event.clientY);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleLegendScroll(legendIndex) {
|
handleLegendScroll(legendIndex: number): void {
|
||||||
this.setState({ legendIndex });
|
this.setState({ legendIndex });
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render(): ReactNode {
|
||||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||||
|
|
||||||
// Skip chart rendering
|
// Skip chart rendering
|
||||||
@@ -326,7 +458,7 @@ class ChartRenderer extends Component {
|
|||||||
}`
|
}`
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
let noResultsComponent;
|
let noResultsComponent: ReactNode;
|
||||||
const noResultTitle = t('No results were returned for this query');
|
const noResultTitle = t('No results were returned for this query');
|
||||||
const noResultDescription =
|
const noResultDescription =
|
||||||
this.props.source === ChartSource.Explore
|
this.props.source === ChartSource.Explore
|
||||||
@@ -335,7 +467,10 @@ class ChartRenderer extends Component {
|
|||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
const noResultImage = 'chart.svg';
|
const noResultImage = 'chart.svg';
|
||||||
if (width > BIG_NO_RESULT_MIN_WIDTH && height > BIG_NO_RESULT_MIN_HEIGHT) {
|
if (
|
||||||
|
(width ?? 0) > BIG_NO_RESULT_MIN_WIDTH &&
|
||||||
|
(height ?? 0) > BIG_NO_RESULT_MIN_HEIGHT
|
||||||
|
) {
|
||||||
noResultsComponent = (
|
noResultsComponent = (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
size="large"
|
size="large"
|
||||||
@@ -353,7 +488,7 @@ class ChartRenderer extends Component {
|
|||||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||||
const drillToDetailProps = getChartMetadataRegistry()
|
const drillToDetailProps = getChartMetadataRegistry()
|
||||||
.get(formData.viz_type)
|
.get(vizType)
|
||||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||||
? { inContextMenu: this.state.inContextMenu }
|
? { inContextMenu: this.state.inContextMenu }
|
||||||
: {};
|
: {};
|
||||||
@@ -365,8 +500,9 @@ class ChartRenderer extends Component {
|
|||||||
ownState?.agGridFilterModel &&
|
ownState?.agGridFilterModel &&
|
||||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||||
|
|
||||||
|
const currentFormDataExtended = currentFormData as JsonObject;
|
||||||
const bypassNoResult = !(
|
const bypassNoResult = !(
|
||||||
formData?.server_pagination &&
|
currentFormDataExtended?.server_pagination &&
|
||||||
(hasSearchText || hasAgGridFilters)
|
(hasSearchText || hasAgGridFilters)
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -376,7 +512,7 @@ class ChartRenderer extends Component {
|
|||||||
<ChartContextMenu
|
<ChartContextMenu
|
||||||
ref={this.contextMenuRef}
|
ref={this.contextMenuRef}
|
||||||
id={chartId}
|
id={chartId}
|
||||||
formData={currentFormData}
|
formData={currentFormData as QueryFormData}
|
||||||
onSelection={this.handleContextMenuSelected}
|
onSelection={this.handleContextMenuSelected}
|
||||||
onClose={this.handleContextMenuClosed}
|
onClose={this.handleContextMenuClosed}
|
||||||
/>
|
/>
|
||||||
@@ -400,9 +536,10 @@ class ChartRenderer extends Component {
|
|||||||
formData={currentFormData}
|
formData={currentFormData}
|
||||||
ownState={ownState}
|
ownState={ownState}
|
||||||
filterState={filterState}
|
filterState={filterState}
|
||||||
hooks={this.hooks}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
hooks={this.hooks as any}
|
||||||
behaviors={behaviors}
|
behaviors={behaviors}
|
||||||
queriesData={this.mutableQueriesResponse}
|
queriesData={this.mutableQueriesResponse ?? undefined}
|
||||||
onRenderSuccess={this.handleRenderSuccess}
|
onRenderSuccess={this.handleRenderSuccess}
|
||||||
onRenderFailure={this.handleRenderFailure}
|
onRenderFailure={this.handleRenderFailure}
|
||||||
noResults={noResultsComponent}
|
noResults={noResultsComponent}
|
||||||
@@ -419,7 +556,4 @@ class ChartRenderer extends Component {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ChartRenderer.propTypes = propTypes;
|
|
||||||
ChartRenderer.defaultProps = defaultProps;
|
|
||||||
|
|
||||||
export default ChartRenderer;
|
export default ChartRenderer;
|
||||||
@@ -29,6 +29,7 @@ import { useSelector } from 'react-redux';
|
|||||||
import { t } from '@apache-superset/core';
|
import { t } from '@apache-superset/core';
|
||||||
import {
|
import {
|
||||||
BinaryQueryObjectFilterClause,
|
BinaryQueryObjectFilterClause,
|
||||||
|
DatasourceType,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
JsonObject,
|
JsonObject,
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
@@ -237,8 +238,8 @@ export default function DrillDetailPane({
|
|||||||
const jsonPayload = getDrillPayload(formData, filters) ?? {};
|
const jsonPayload = getDrillPayload(formData, filters) ?? {};
|
||||||
const cachePageLimit = Math.ceil(SAMPLES_ROW_LIMIT / PAGE_SIZE);
|
const cachePageLimit = Math.ceil(SAMPLES_ROW_LIMIT / PAGE_SIZE);
|
||||||
getDatasourceSamples(
|
getDatasourceSamples(
|
||||||
datasourceType,
|
datasourceType as DatasourceType,
|
||||||
datasourceId,
|
Number(datasourceId),
|
||||||
false,
|
false,
|
||||||
jsonPayload,
|
jsonPayload,
|
||||||
PAGE_SIZE,
|
PAGE_SIZE,
|
||||||
|
|||||||
@@ -1,652 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
/* eslint no-param-reassign: ["error", { "props": false }] */
|
|
||||||
import {
|
|
||||||
FeatureFlag,
|
|
||||||
isDefined,
|
|
||||||
SupersetClient,
|
|
||||||
isFeatureEnabled,
|
|
||||||
getClientErrorObject,
|
|
||||||
} from '@superset-ui/core';
|
|
||||||
import { t } from '@apache-superset/core/ui';
|
|
||||||
import { getControlsState } from 'src/explore/store';
|
|
||||||
import {
|
|
||||||
getAnnotationJsonUrl,
|
|
||||||
getExploreUrl,
|
|
||||||
getLegacyEndpointType,
|
|
||||||
buildV1ChartDataPayload,
|
|
||||||
getQuerySettings,
|
|
||||||
getChartDataUri,
|
|
||||||
} from 'src/explore/exploreUtils';
|
|
||||||
import { addDangerToast } from 'src/components/MessageToasts/actions';
|
|
||||||
import { logEvent } from 'src/logger/actions';
|
|
||||||
import { Logger, LOG_ACTIONS_LOAD_CHART } from 'src/logger/LogUtils';
|
|
||||||
import { allowCrossDomain as domainShardingEnabled } from 'src/utils/hostNamesConfig';
|
|
||||||
import { updateDataMask } from 'src/dataMask/actions';
|
|
||||||
import { waitForAsyncData } from 'src/middleware/asyncEvent';
|
|
||||||
import { ensureAppRoot } from 'src/utils/pathUtils';
|
|
||||||
import { safeStringify } from 'src/utils/safeStringify';
|
|
||||||
import { extendedDayjs } from '@superset-ui/core/utils/dates';
|
|
||||||
|
|
||||||
export const CHART_UPDATE_STARTED = 'CHART_UPDATE_STARTED';
|
|
||||||
export function chartUpdateStarted(queryController, latestQueryFormData, key) {
|
|
||||||
return {
|
|
||||||
type: CHART_UPDATE_STARTED,
|
|
||||||
queryController,
|
|
||||||
latestQueryFormData,
|
|
||||||
key,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHART_UPDATE_SUCCEEDED = 'CHART_UPDATE_SUCCEEDED';
|
|
||||||
export function chartUpdateSucceeded(queriesResponse, key) {
|
|
||||||
return { type: CHART_UPDATE_SUCCEEDED, queriesResponse, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHART_UPDATE_STOPPED = 'CHART_UPDATE_STOPPED';
|
|
||||||
export function chartUpdateStopped(key) {
|
|
||||||
return { type: CHART_UPDATE_STOPPED, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHART_UPDATE_FAILED = 'CHART_UPDATE_FAILED';
|
|
||||||
export function chartUpdateFailed(queriesResponse, key) {
|
|
||||||
return { type: CHART_UPDATE_FAILED, queriesResponse, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHART_RENDERING_FAILED = 'CHART_RENDERING_FAILED';
|
|
||||||
export function chartRenderingFailed(error, key, stackTrace) {
|
|
||||||
return { type: CHART_RENDERING_FAILED, error, key, stackTrace };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const CHART_RENDERING_SUCCEEDED = 'CHART_RENDERING_SUCCEEDED';
|
|
||||||
export function chartRenderingSucceeded(key) {
|
|
||||||
return { type: CHART_RENDERING_SUCCEEDED, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const REMOVE_CHART = 'REMOVE_CHART';
|
|
||||||
export function removeChart(key) {
|
|
||||||
return { type: REMOVE_CHART, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ANNOTATION_QUERY_SUCCESS = 'ANNOTATION_QUERY_SUCCESS';
|
|
||||||
export function annotationQuerySuccess(annotation, queryResponse, key) {
|
|
||||||
return { type: ANNOTATION_QUERY_SUCCESS, annotation, queryResponse, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ANNOTATION_QUERY_STARTED = 'ANNOTATION_QUERY_STARTED';
|
|
||||||
export function annotationQueryStarted(annotation, queryController, key) {
|
|
||||||
return { type: ANNOTATION_QUERY_STARTED, annotation, queryController, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ANNOTATION_QUERY_FAILED = 'ANNOTATION_QUERY_FAILED';
|
|
||||||
export function annotationQueryFailed(annotation, queryResponse, key) {
|
|
||||||
return { type: ANNOTATION_QUERY_FAILED, annotation, queryResponse, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DYNAMIC_PLUGIN_CONTROLS_READY = 'DYNAMIC_PLUGIN_CONTROLS_READY';
|
|
||||||
export const dynamicPluginControlsReady = () => (dispatch, getState) => {
|
|
||||||
const state = getState();
|
|
||||||
const controlsState = getControlsState(
|
|
||||||
state.explore,
|
|
||||||
state.explore.form_data,
|
|
||||||
);
|
|
||||||
dispatch({
|
|
||||||
type: DYNAMIC_PLUGIN_CONTROLS_READY,
|
|
||||||
key: controlsState.slice_id.value,
|
|
||||||
controlsState,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const legacyChartDataRequest = async (
|
|
||||||
formData,
|
|
||||||
resultFormat,
|
|
||||||
resultType,
|
|
||||||
force,
|
|
||||||
method = 'POST',
|
|
||||||
requestParams = {},
|
|
||||||
parseMethod,
|
|
||||||
) => {
|
|
||||||
const endpointType = getLegacyEndpointType({ resultFormat, resultType });
|
|
||||||
const allowDomainSharding =
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
domainShardingEnabled && requestParams?.dashboard_id;
|
|
||||||
const url = getExploreUrl({
|
|
||||||
formData,
|
|
||||||
endpointType,
|
|
||||||
force,
|
|
||||||
allowDomainSharding,
|
|
||||||
method,
|
|
||||||
requestParams: requestParams.dashboard_id
|
|
||||||
? { dashboard_id: requestParams.dashboard_id }
|
|
||||||
: {},
|
|
||||||
});
|
|
||||||
const querySettings = {
|
|
||||||
...requestParams,
|
|
||||||
url,
|
|
||||||
postPayload: { form_data: formData },
|
|
||||||
parseMethod,
|
|
||||||
};
|
|
||||||
|
|
||||||
return SupersetClient.post(querySettings).then(({ json, response }) =>
|
|
||||||
// Make the legacy endpoint return a payload that corresponds to the
|
|
||||||
// V1 chart data endpoint response signature.
|
|
||||||
({
|
|
||||||
response,
|
|
||||||
json: { result: [json] },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const v1ChartDataRequest = async (
|
|
||||||
formData,
|
|
||||||
resultFormat,
|
|
||||||
resultType,
|
|
||||||
force,
|
|
||||||
requestParams,
|
|
||||||
setDataMask,
|
|
||||||
ownState,
|
|
||||||
parseMethod,
|
|
||||||
) => {
|
|
||||||
const payload = await buildV1ChartDataPayload({
|
|
||||||
formData,
|
|
||||||
resultType,
|
|
||||||
resultFormat,
|
|
||||||
force,
|
|
||||||
setDataMask,
|
|
||||||
ownState,
|
|
||||||
});
|
|
||||||
|
|
||||||
// The dashboard id is added to query params for tracking purposes
|
|
||||||
const { slice_id: sliceId } = formData;
|
|
||||||
const { dashboard_id: dashboardId } = requestParams;
|
|
||||||
|
|
||||||
const qs = {};
|
|
||||||
if (sliceId !== undefined) qs.form_data = `{"slice_id":${sliceId}}`;
|
|
||||||
if (dashboardId !== undefined) qs.dashboard_id = dashboardId;
|
|
||||||
if (force) qs.force = force;
|
|
||||||
|
|
||||||
const allowDomainSharding =
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
domainShardingEnabled && requestParams?.dashboard_id;
|
|
||||||
const url = getChartDataUri({
|
|
||||||
path: '/api/v1/chart/data',
|
|
||||||
qs,
|
|
||||||
allowDomainSharding,
|
|
||||||
}).toString();
|
|
||||||
|
|
||||||
const querySettings = {
|
|
||||||
...requestParams,
|
|
||||||
url,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
parseMethod,
|
|
||||||
};
|
|
||||||
|
|
||||||
return SupersetClient.post(querySettings);
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function getChartDataRequest({
|
|
||||||
formData,
|
|
||||||
setDataMask = () => {},
|
|
||||||
resultFormat = 'json',
|
|
||||||
resultType = 'full',
|
|
||||||
force = false,
|
|
||||||
method = 'POST',
|
|
||||||
requestParams = {},
|
|
||||||
ownState = {},
|
|
||||||
}) {
|
|
||||||
let querySettings = {
|
|
||||||
...requestParams,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (domainShardingEnabled) {
|
|
||||||
querySettings = {
|
|
||||||
...querySettings,
|
|
||||||
mode: 'cors',
|
|
||||||
credentials: 'include',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const [useLegacyApi, parseMethod] = getQuerySettings(formData);
|
|
||||||
if (useLegacyApi) {
|
|
||||||
return legacyChartDataRequest(
|
|
||||||
formData,
|
|
||||||
resultFormat,
|
|
||||||
resultType,
|
|
||||||
force,
|
|
||||||
method,
|
|
||||||
querySettings,
|
|
||||||
parseMethod,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return v1ChartDataRequest(
|
|
||||||
formData,
|
|
||||||
resultFormat,
|
|
||||||
resultType,
|
|
||||||
force,
|
|
||||||
querySettings,
|
|
||||||
setDataMask,
|
|
||||||
ownState,
|
|
||||||
parseMethod,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function runAnnotationQuery({
|
|
||||||
annotation,
|
|
||||||
timeout,
|
|
||||||
formData,
|
|
||||||
key,
|
|
||||||
isDashboardRequest = false,
|
|
||||||
force = false,
|
|
||||||
}) {
|
|
||||||
return async function (dispatch, getState) {
|
|
||||||
const { charts, common } = getState();
|
|
||||||
const sliceKey = key || Object.keys(charts)[0];
|
|
||||||
const queryTimeout = timeout || common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
|
||||||
|
|
||||||
// make a copy of formData, not modifying original formData
|
|
||||||
const fd = {
|
|
||||||
...(formData || charts[sliceKey].latestQueryFormData),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!annotation.sourceType) {
|
|
||||||
return Promise.resolve();
|
|
||||||
}
|
|
||||||
|
|
||||||
// In the original formData the `granularity` attribute represents the time grain (eg
|
|
||||||
// `P1D`), but in the request payload it corresponds to the name of the column where
|
|
||||||
// the time grain should be applied (eg, `Date`), so we need to move things around.
|
|
||||||
fd.time_grain_sqla = fd.time_grain_sqla || fd.granularity;
|
|
||||||
fd.granularity = fd.granularity_sqla;
|
|
||||||
|
|
||||||
const overridesKeys = Object.keys(annotation.overrides);
|
|
||||||
if (overridesKeys.includes('since') || overridesKeys.includes('until')) {
|
|
||||||
annotation.overrides = {
|
|
||||||
...annotation.overrides,
|
|
||||||
time_range: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const sliceFormData = Object.keys(annotation.overrides).reduce(
|
|
||||||
(d, k) => ({
|
|
||||||
...d,
|
|
||||||
[k]: annotation.overrides[k] || fd[k],
|
|
||||||
}),
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!isDashboardRequest && fd) {
|
|
||||||
const hasExtraFilters = fd.extra_filters && fd.extra_filters.length > 0;
|
|
||||||
sliceFormData.extra_filters = hasExtraFilters
|
|
||||||
? fd.extra_filters
|
|
||||||
: undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = getAnnotationJsonUrl(annotation.value, force);
|
|
||||||
const controller = new AbortController();
|
|
||||||
const { signal } = controller;
|
|
||||||
|
|
||||||
dispatch(annotationQueryStarted(annotation, controller, sliceKey));
|
|
||||||
|
|
||||||
const annotationIndex = fd?.annotation_layers?.findIndex(
|
|
||||||
it => it.name === annotation.name,
|
|
||||||
);
|
|
||||||
if (annotationIndex >= 0) {
|
|
||||||
fd.annotation_layers[annotationIndex].overrides = sliceFormData;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = await buildV1ChartDataPayload({
|
|
||||||
formData: fd,
|
|
||||||
force,
|
|
||||||
resultFormat: 'json',
|
|
||||||
resultType: 'full',
|
|
||||||
});
|
|
||||||
|
|
||||||
return SupersetClient.post({
|
|
||||||
url,
|
|
||||||
signal,
|
|
||||||
timeout: queryTimeout * 1000,
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
jsonPayload: payload,
|
|
||||||
})
|
|
||||||
.then(({ json }) => {
|
|
||||||
const data = json?.result?.[0]?.annotation_data?.[annotation.name];
|
|
||||||
return dispatch(annotationQuerySuccess(annotation, { data }, sliceKey));
|
|
||||||
})
|
|
||||||
.catch(response =>
|
|
||||||
getClientErrorObject(response).then(err => {
|
|
||||||
if (err.statusText === 'timeout') {
|
|
||||||
dispatch(
|
|
||||||
annotationQueryFailed(
|
|
||||||
annotation,
|
|
||||||
{ error: 'Query timeout' },
|
|
||||||
sliceKey,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if ((err.error || '').toLowerCase().includes('no data')) {
|
|
||||||
dispatch(annotationQuerySuccess(annotation, err, sliceKey));
|
|
||||||
} else if (err.statusText !== 'abort') {
|
|
||||||
dispatch(annotationQueryFailed(annotation, err, sliceKey));
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const TRIGGER_QUERY = 'TRIGGER_QUERY';
|
|
||||||
export function triggerQuery(value = true, key) {
|
|
||||||
return { type: TRIGGER_QUERY, value, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
// this action is used for forced re-render without fetch data
|
|
||||||
export const RENDER_TRIGGERED = 'RENDER_TRIGGERED';
|
|
||||||
export function renderTriggered(value, key) {
|
|
||||||
return { type: RENDER_TRIGGERED, value, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const UPDATE_QUERY_FORM_DATA = 'UPDATE_QUERY_FORM_DATA';
|
|
||||||
export function updateQueryFormData(value, key) {
|
|
||||||
return { type: UPDATE_QUERY_FORM_DATA, value, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
// in the sql lab -> explore flow, user can inline edit chart title,
|
|
||||||
// then the chart will be assigned a new slice_id
|
|
||||||
export const UPDATE_CHART_ID = 'UPDATE_CHART_ID';
|
|
||||||
export function updateChartId(newId, key = 0) {
|
|
||||||
return { type: UPDATE_CHART_ID, newId, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ADD_CHART = 'ADD_CHART';
|
|
||||||
export function addChart(chart, key) {
|
|
||||||
return { type: ADD_CHART, chart, key };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function handleChartDataResponse(response, json, useLegacyApi) {
|
|
||||||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
|
||||||
// deal with getChartDataRequest transforming the response data
|
|
||||||
const result = 'result' in json ? json.result : json;
|
|
||||||
switch (response.status) {
|
|
||||||
case 200:
|
|
||||||
// Query results returned synchronously, meaning query was already cached.
|
|
||||||
return Promise.resolve(result);
|
|
||||||
case 202:
|
|
||||||
// Query is running asynchronously and we must await the results
|
|
||||||
if (useLegacyApi) {
|
|
||||||
return waitForAsyncData(result[0]);
|
|
||||||
}
|
|
||||||
return waitForAsyncData(result);
|
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
`Received unexpected response status (${response.status}) while fetching chart data`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return json.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function exploreJSON(
|
|
||||||
formData,
|
|
||||||
force = false,
|
|
||||||
timeout,
|
|
||||||
key,
|
|
||||||
dashboardId,
|
|
||||||
ownState,
|
|
||||||
) {
|
|
||||||
return async (dispatch, getState) => {
|
|
||||||
const state = getState();
|
|
||||||
const logStart = Logger.getTimestamp();
|
|
||||||
const controller = new AbortController();
|
|
||||||
const prevController = state.charts?.[key]?.queryController;
|
|
||||||
const queryTimeout =
|
|
||||||
timeout || state.common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
|
||||||
|
|
||||||
const requestParams = {
|
|
||||||
signal: controller.signal,
|
|
||||||
timeout: queryTimeout * 1000,
|
|
||||||
};
|
|
||||||
if (dashboardId) requestParams.dashboard_id = dashboardId;
|
|
||||||
|
|
||||||
const setDataMask = dataMask => {
|
|
||||||
dispatch(updateDataMask(formData.slice_id, dataMask));
|
|
||||||
};
|
|
||||||
dispatch(chartUpdateStarted(controller, formData, key));
|
|
||||||
/**
|
|
||||||
* Abort in-flight requests after the new controller has been stored in
|
|
||||||
* state. Delaying ensures we do not mutate the Redux state between
|
|
||||||
* dispatches while still cancelling the previous request promptly.
|
|
||||||
*/
|
|
||||||
if (prevController) {
|
|
||||||
setTimeout(() => prevController.abort(), 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
const chartDataRequest = getChartDataRequest({
|
|
||||||
setDataMask,
|
|
||||||
formData,
|
|
||||||
resultFormat: 'json',
|
|
||||||
resultType: 'full',
|
|
||||||
force,
|
|
||||||
method: 'POST',
|
|
||||||
requestParams,
|
|
||||||
ownState,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [useLegacyApi] = getQuerySettings(formData);
|
|
||||||
const chartDataRequestCaught = chartDataRequest
|
|
||||||
.then(({ response, json }) =>
|
|
||||||
handleChartDataResponse(response, json, useLegacyApi),
|
|
||||||
)
|
|
||||||
.then(queriesResponse => {
|
|
||||||
queriesResponse.forEach(resultItem =>
|
|
||||||
dispatch(
|
|
||||||
logEvent(LOG_ACTIONS_LOAD_CHART, {
|
|
||||||
slice_id: key,
|
|
||||||
applied_filters: resultItem.applied_filters,
|
|
||||||
is_cached: resultItem.is_cached,
|
|
||||||
force_refresh: force,
|
|
||||||
row_count: resultItem.rowcount,
|
|
||||||
datasource: formData.datasource,
|
|
||||||
start_offset: logStart,
|
|
||||||
ts: new Date().getTime(),
|
|
||||||
duration: Logger.getTimestamp() - logStart,
|
|
||||||
has_extra_filters:
|
|
||||||
formData.extra_filters && formData.extra_filters.length > 0,
|
|
||||||
viz_type: formData.viz_type,
|
|
||||||
data_age: resultItem.is_cached
|
|
||||||
? extendedDayjs(new Date()).diff(
|
|
||||||
extendedDayjs.utc(resultItem.cached_dttm),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return dispatch(chartUpdateSucceeded(queriesResponse, key));
|
|
||||||
})
|
|
||||||
.catch(response => {
|
|
||||||
// Ignore abort errors - they're expected when filters change quickly
|
|
||||||
const isAbort =
|
|
||||||
response?.name === 'AbortError' || response?.statusText === 'abort';
|
|
||||||
if (isAbort) {
|
|
||||||
// Abort is expected: filters changed, chart unmounted, etc.
|
|
||||||
return dispatch(chartUpdateStopped(key));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) {
|
|
||||||
// In async mode we just pass the raw error response through
|
|
||||||
return dispatch(chartUpdateFailed([response], key));
|
|
||||||
}
|
|
||||||
|
|
||||||
const appendErrorLog = (errorDetails, isCached) => {
|
|
||||||
dispatch(
|
|
||||||
logEvent(LOG_ACTIONS_LOAD_CHART, {
|
|
||||||
slice_id: key,
|
|
||||||
has_err: true,
|
|
||||||
is_cached: isCached,
|
|
||||||
error_details: errorDetails,
|
|
||||||
datasource: formData.datasource,
|
|
||||||
start_offset: logStart,
|
|
||||||
ts: new Date().getTime(),
|
|
||||||
duration: Logger.getTimestamp() - logStart,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return getClientErrorObject(response).then(parsedResponse => {
|
|
||||||
if (response.statusText === 'timeout') {
|
|
||||||
appendErrorLog('timeout');
|
|
||||||
} else {
|
|
||||||
appendErrorLog(parsedResponse.error, parsedResponse.is_cached);
|
|
||||||
}
|
|
||||||
return dispatch(chartUpdateFailed([parsedResponse], key));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// only retrieve annotations when calling the legacy API
|
|
||||||
const annotationLayers = useLegacyApi
|
|
||||||
? formData.annotation_layers || []
|
|
||||||
: [];
|
|
||||||
const isDashboardRequest = dashboardId > 0;
|
|
||||||
|
|
||||||
return Promise.all([
|
|
||||||
chartDataRequestCaught,
|
|
||||||
dispatch(triggerQuery(false, key)),
|
|
||||||
dispatch(updateQueryFormData(formData, key)),
|
|
||||||
...annotationLayers.map(annotation =>
|
|
||||||
dispatch(
|
|
||||||
runAnnotationQuery({
|
|
||||||
annotation,
|
|
||||||
timeout,
|
|
||||||
formData,
|
|
||||||
key,
|
|
||||||
isDashboardRequest,
|
|
||||||
force,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const POST_CHART_FORM_DATA = 'POST_CHART_FORM_DATA';
|
|
||||||
export function postChartFormData(
|
|
||||||
formData,
|
|
||||||
force = false,
|
|
||||||
timeout,
|
|
||||||
key,
|
|
||||||
dashboardId,
|
|
||||||
ownState,
|
|
||||||
) {
|
|
||||||
return exploreJSON(formData, force, timeout, key, dashboardId, ownState);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function redirectSQLLab(formData, history) {
|
|
||||||
return dispatch => {
|
|
||||||
getChartDataRequest({
|
|
||||||
formData,
|
|
||||||
resultFormat: 'json',
|
|
||||||
resultType: 'query',
|
|
||||||
})
|
|
||||||
.then(({ json }) => {
|
|
||||||
const redirectUrl = '/sqllab/';
|
|
||||||
const payload = {
|
|
||||||
datasourceKey: formData.datasource,
|
|
||||||
sql: json.result[0].query,
|
|
||||||
};
|
|
||||||
if (history) {
|
|
||||||
history.push({
|
|
||||||
pathname: redirectUrl,
|
|
||||||
state: {
|
|
||||||
requestedQuery: payload,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
SupersetClient.postForm(ensureAppRoot(redirectUrl), {
|
|
||||||
form_data: safeStringify(payload),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
dispatch(addDangerToast(t('An error occurred while loading the SQL'))),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function refreshChart(chartKey, force, dashboardId) {
|
|
||||||
return (dispatch, getState) => {
|
|
||||||
const chart = (getState().charts || {})[chartKey];
|
|
||||||
const timeout =
|
|
||||||
getState().dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!chart.latestQueryFormData ||
|
|
||||||
Object.keys(chart.latestQueryFormData).length === 0
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
dispatch(
|
|
||||||
postChartFormData(
|
|
||||||
chart.latestQueryFormData,
|
|
||||||
force,
|
|
||||||
timeout,
|
|
||||||
chart.id,
|
|
||||||
dashboardId,
|
|
||||||
getState().dataMask[chart.id]?.ownState,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getDatasourceSamples = async (
|
|
||||||
datasourceType,
|
|
||||||
datasourceId,
|
|
||||||
force,
|
|
||||||
jsonPayload,
|
|
||||||
perPage,
|
|
||||||
page,
|
|
||||||
dashboardId,
|
|
||||||
) => {
|
|
||||||
try {
|
|
||||||
const searchParams = {
|
|
||||||
force,
|
|
||||||
datasource_type: datasourceType,
|
|
||||||
datasource_id: datasourceId,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (isDefined(dashboardId)) {
|
|
||||||
searchParams.dashboard_id = dashboardId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isDefined(perPage) && isDefined(page)) {
|
|
||||||
searchParams.per_page = perPage;
|
|
||||||
searchParams.page = page;
|
|
||||||
}
|
|
||||||
|
|
||||||
const response = await SupersetClient.post({
|
|
||||||
endpoint: '/datasource/samples',
|
|
||||||
jsonPayload,
|
|
||||||
searchParams,
|
|
||||||
parseMethod: 'json-bigint',
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.json.result;
|
|
||||||
} catch (err) {
|
|
||||||
const clientError = await getClientErrorObject(err);
|
|
||||||
throw new Error(
|
|
||||||
clientError.message || clientError.error || t('Sorry, an error occurred'),
|
|
||||||
{ cause: err },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
1013
superset-frontend/src/components/Chart/chartAction.ts
Normal file
1013
superset-frontend/src/components/Chart/chartAction.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -18,13 +18,19 @@
|
|||||||
*/
|
*/
|
||||||
import URI from 'urijs';
|
import URI from 'urijs';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
import sinon from 'sinon';
|
import sinon, { SinonSpy, SinonStub } from 'sinon';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FeatureFlag,
|
FeatureFlag,
|
||||||
SupersetClient,
|
SupersetClient,
|
||||||
getChartMetadataRegistry,
|
getChartMetadataRegistry,
|
||||||
getChartBuildQueryRegistry,
|
getChartBuildQueryRegistry,
|
||||||
|
QueryFormData,
|
||||||
|
JsonObject,
|
||||||
|
AnnotationLayer,
|
||||||
|
AnnotationType,
|
||||||
|
AnnotationSourceType,
|
||||||
|
AnnotationStyle,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { LOG_EVENT } from 'src/logger/actions';
|
import { LOG_EVENT } from 'src/logger/actions';
|
||||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||||
@@ -37,10 +43,27 @@ import configureMockStore from 'redux-mock-store';
|
|||||||
import thunk from 'redux-thunk';
|
import thunk from 'redux-thunk';
|
||||||
import { initialState } from 'src/SqlLab/fixtures';
|
import { initialState } from 'src/SqlLab/fixtures';
|
||||||
|
|
||||||
|
interface MockState {
|
||||||
|
charts: {
|
||||||
|
[key: string]: {
|
||||||
|
latestQueryFormData?: {
|
||||||
|
time_grain_sqla?: string;
|
||||||
|
granularity_sqla?: string;
|
||||||
|
};
|
||||||
|
queryController?: AbortController;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
common: {
|
||||||
|
conf: {
|
||||||
|
SUPERSET_WEBSERVER_TIMEOUT?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const middlewares = [thunk];
|
const middlewares = [thunk];
|
||||||
const mockStore = configureMockStore(middlewares);
|
const mockStore = configureMockStore(middlewares);
|
||||||
|
|
||||||
const mockGetState = () => ({
|
const mockGetState = (): MockState => ({
|
||||||
charts: {
|
charts: {
|
||||||
chartKey: {
|
chartKey: {
|
||||||
latestQueryFormData: {
|
latestQueryFormData: {
|
||||||
@@ -60,21 +83,30 @@ jest.mock('@superset-ui/core', () => ({
|
|||||||
getChartBuildQueryRegistry: jest.fn(),
|
getChartBuildQueryRegistry: jest.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const mockedGetChartMetadataRegistry =
|
||||||
|
getChartMetadataRegistry as jest.MockedFunction<
|
||||||
|
typeof getChartMetadataRegistry
|
||||||
|
>;
|
||||||
|
const mockedGetChartBuildQueryRegistry =
|
||||||
|
getChartBuildQueryRegistry as jest.MockedFunction<
|
||||||
|
typeof getChartBuildQueryRegistry
|
||||||
|
>;
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('chart actions', () => {
|
describe('chart actions', () => {
|
||||||
const MOCK_URL = '/mockURL';
|
const MOCK_URL = '/mockURL';
|
||||||
let dispatch;
|
let dispatch: SinonSpy;
|
||||||
let getExploreUrlStub;
|
let getExploreUrlStub: SinonStub;
|
||||||
let getChartDataUriStub;
|
let getChartDataUriStub: SinonStub;
|
||||||
let buildV1ChartDataPayloadStub;
|
let buildV1ChartDataPayloadStub: SinonStub;
|
||||||
let waitForAsyncDataStub;
|
let waitForAsyncDataStub: SinonStub;
|
||||||
let fakeMetadata;
|
let fakeMetadata: { useLegacyApi?: boolean; viz_type?: string };
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
fetchMock.get('glob:*api/v1/security/csrf_token/*', { result: '1234' });
|
fetchMock.get('glob:*api/v1/security/csrf_token/*', { result: '1234' });
|
||||||
});
|
});
|
||||||
|
|
||||||
const setupDefaultFetchMock = () => {
|
const setupDefaultFetchMock = (): void => {
|
||||||
fetchMock.post(`glob:*${MOCK_URL}*`, { json: {} }, { name: MOCK_URL });
|
fetchMock.post(`glob:*${MOCK_URL}*`, { json: {} }, { name: MOCK_URL });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -91,41 +123,51 @@ describe('chart actions', () => {
|
|||||||
.callsFake(() => MOCK_URL);
|
.callsFake(() => MOCK_URL);
|
||||||
getChartDataUriStub = sinon
|
getChartDataUriStub = sinon
|
||||||
.stub(exploreUtils, 'getChartDataUri')
|
.stub(exploreUtils, 'getChartDataUri')
|
||||||
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
|
.callsFake(({ qs }: { qs?: Record<string, unknown> }) =>
|
||||||
|
URI(MOCK_URL).query(qs || {}),
|
||||||
|
);
|
||||||
buildV1ChartDataPayloadStub = sinon
|
buildV1ChartDataPayloadStub = sinon
|
||||||
.stub(exploreUtils, 'buildV1ChartDataPayload')
|
.stub(exploreUtils, 'buildV1ChartDataPayload')
|
||||||
.resolves({
|
.resolves({
|
||||||
some_param: 'fake query!',
|
some_param: 'fake query!',
|
||||||
result_type: 'full',
|
result_type: 'full',
|
||||||
result_format: 'json',
|
result_format: 'json',
|
||||||
});
|
} as unknown as Awaited<
|
||||||
|
ReturnType<typeof exploreUtils.buildV1ChartDataPayload>
|
||||||
|
>);
|
||||||
fakeMetadata = { useLegacyApi: true };
|
fakeMetadata = { useLegacyApi: true };
|
||||||
getChartMetadataRegistry.mockImplementation(() => ({
|
mockedGetChartMetadataRegistry.mockImplementation(
|
||||||
get: () => fakeMetadata,
|
() =>
|
||||||
}));
|
({
|
||||||
getChartBuildQueryRegistry.mockImplementation(() => ({
|
get: () => fakeMetadata,
|
||||||
get: () => () => ({
|
}) as unknown as ReturnType<typeof getChartMetadataRegistry>,
|
||||||
some_param: 'fake query!',
|
);
|
||||||
result_type: 'full',
|
mockedGetChartBuildQueryRegistry.mockImplementation(
|
||||||
result_format: 'json',
|
() =>
|
||||||
}),
|
({
|
||||||
}));
|
get: () => () => ({
|
||||||
|
some_param: 'fake query!',
|
||||||
|
result_type: 'full',
|
||||||
|
result_format: 'json',
|
||||||
|
}),
|
||||||
|
}) as unknown as ReturnType<typeof getChartBuildQueryRegistry>,
|
||||||
|
);
|
||||||
waitForAsyncDataStub = sinon
|
waitForAsyncDataStub = sinon
|
||||||
.stub(asyncEvent, 'waitForAsyncData')
|
.stub(asyncEvent, 'waitForAsyncData')
|
||||||
.callsFake(data => Promise.resolve(data));
|
.callsFake((data: unknown) => Promise.resolve(data));
|
||||||
});
|
});
|
||||||
|
|
||||||
test.only('should defer abort of previous controller to avoid Redux state mutation', async () => {
|
test.only('should defer abort of previous controller to avoid Redux state mutation', async () => {
|
||||||
jest.useFakeTimers();
|
jest.useFakeTimers();
|
||||||
const chartKey = 'defer_abort_test';
|
const chartKey = 'defer_abort_test';
|
||||||
const formData = {
|
const formData: Partial<QueryFormData> = {
|
||||||
slice_id: 123,
|
slice_id: 123,
|
||||||
datasource: 'table__1',
|
datasource: 'table__1',
|
||||||
viz_type: 'table',
|
viz_type: 'table',
|
||||||
};
|
};
|
||||||
const oldController = new AbortController();
|
const oldController = new AbortController();
|
||||||
const abortSpy = jest.spyOn(oldController, 'abort');
|
const abortSpy = jest.spyOn(oldController, 'abort');
|
||||||
const state = {
|
const state: MockState = {
|
||||||
charts: {
|
charts: {
|
||||||
[chartKey]: {
|
[chartKey]: {
|
||||||
queryController: oldController,
|
queryController: oldController,
|
||||||
@@ -142,7 +184,7 @@ describe('chart actions', () => {
|
|||||||
const getChartDataRequestSpy = jest
|
const getChartDataRequestSpy = jest
|
||||||
.spyOn(actions, 'getChartDataRequest')
|
.spyOn(actions, 'getChartDataRequest')
|
||||||
.mockResolvedValue({
|
.mockResolvedValue({
|
||||||
response: { status: 200 },
|
response: { status: 200 } as Response,
|
||||||
json: { result: [] },
|
json: { result: [] },
|
||||||
});
|
});
|
||||||
const handleChartDataResponseSpy = jest
|
const handleChartDataResponseSpy = jest
|
||||||
@@ -150,14 +192,27 @@ describe('chart actions', () => {
|
|||||||
.mockResolvedValue([]);
|
.mockResolvedValue([]);
|
||||||
const updateDataMaskSpy = jest
|
const updateDataMaskSpy = jest
|
||||||
.spyOn(dataMaskActions, 'updateDataMask')
|
.spyOn(dataMaskActions, 'updateDataMask')
|
||||||
.mockReturnValue({ type: 'UPDATE_DATA_MASK' });
|
.mockReturnValue({ type: 'UPDATE_DATA_MASK' } as ReturnType<
|
||||||
|
typeof dataMaskActions.updateDataMask
|
||||||
|
>);
|
||||||
const getQuerySettingsStub = sinon
|
const getQuerySettingsStub = sinon
|
||||||
.stub(exploreUtils, 'getQuerySettings')
|
.stub(exploreUtils, 'getQuerySettings')
|
||||||
.returns([false, () => {}]);
|
.returns([false, () => {}] as unknown as ReturnType<
|
||||||
|
typeof exploreUtils.getQuerySettings
|
||||||
|
>);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const thunk = actions.exploreJSON(formData, false, undefined, chartKey);
|
const thunkAction = actions.exploreJSON(
|
||||||
const promise = thunk(dispatchMock, getState);
|
formData as QueryFormData,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
chartKey,
|
||||||
|
);
|
||||||
|
const promise = thunkAction(
|
||||||
|
dispatchMock as unknown as actions.ChartThunkDispatch,
|
||||||
|
getState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
expect(abortSpy).not.toHaveBeenCalled();
|
expect(abortSpy).not.toHaveBeenCalled();
|
||||||
expect(oldController.signal.aborted).toBe(false);
|
expect(oldController.signal.aborted).toBe(false);
|
||||||
@@ -185,7 +240,9 @@ describe('chart actions', () => {
|
|||||||
fetchMock.clearHistory();
|
fetchMock.clearHistory();
|
||||||
waitForAsyncDataStub.restore();
|
waitForAsyncDataStub.restore();
|
||||||
|
|
||||||
global.featureFlags = {
|
(
|
||||||
|
global as unknown as { featureFlags: Record<string, boolean> }
|
||||||
|
).featureFlags = {
|
||||||
[FeatureFlag.GlobalAsyncQueries]: false,
|
[FeatureFlag.GlobalAsyncQueries]: false,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -197,8 +254,17 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should query with the built query', async () => {
|
test('should query with the built query', async () => {
|
||||||
const actionThunk = actions.postChartFormData({}, null);
|
const actionThunk = actions.postChartFormData(
|
||||||
await actionThunk(dispatch, mockGetState);
|
{} as QueryFormData,
|
||||||
|
false,
|
||||||
|
undefined,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
await actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)[0].options.body).toBe(
|
expect(fetchMock.callHistory.calls(MOCK_URL)[0].options.body).toBe(
|
||||||
@@ -223,39 +289,55 @@ describe('chart actions', () => {
|
|||||||
.callsFake(() => URI(mockBigIntUrl));
|
.callsFake(() => URI(mockBigIntUrl));
|
||||||
|
|
||||||
const { json } = await actions.getChartDataRequest({
|
const { json } = await actions.getChartDataRequest({
|
||||||
formData: fakeMetadata,
|
formData: fakeMetadata as QueryFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1);
|
||||||
expect(json.value.toString()).toEqual(expectedBigNumber);
|
expect((json as JsonObject).value.toString()).toEqual(expectedBigNumber);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleChartDataResponse should return result if GlobalAsyncQueries flag is disabled', async () => {
|
test('handleChartDataResponse should return result if GlobalAsyncQueries flag is disabled', async () => {
|
||||||
const result = await handleChartDataResponse(
|
const result = await handleChartDataResponse(
|
||||||
{ status: 200 },
|
{ status: 200 } as Response,
|
||||||
{ result: [1, 2, 3] },
|
{
|
||||||
|
result: [
|
||||||
|
1, 2, 3,
|
||||||
|
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(result).toEqual([1, 2, 3]);
|
expect(result).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and results are returned synchronously', async () => {
|
test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and results are returned synchronously', async () => {
|
||||||
global.featureFlags = {
|
(
|
||||||
|
global as unknown as { featureFlags: Record<string, boolean> }
|
||||||
|
).featureFlags = {
|
||||||
[FeatureFlag.GlobalAsyncQueries]: true,
|
[FeatureFlag.GlobalAsyncQueries]: true,
|
||||||
};
|
};
|
||||||
const result = await handleChartDataResponse(
|
const result = await handleChartDataResponse(
|
||||||
{ status: 200 },
|
{ status: 200 } as Response,
|
||||||
{ result: [1, 2, 3] },
|
{
|
||||||
|
result: [
|
||||||
|
1, 2, 3,
|
||||||
|
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(result).toEqual([1, 2, 3]);
|
expect(result).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and query is running asynchronously', async () => {
|
test('handleChartDataResponse should handle responses when GlobalAsyncQueries flag is enabled and query is running asynchronously', async () => {
|
||||||
global.featureFlags = {
|
(
|
||||||
|
global as unknown as { featureFlags: Record<string, boolean> }
|
||||||
|
).featureFlags = {
|
||||||
[FeatureFlag.GlobalAsyncQueries]: true,
|
[FeatureFlag.GlobalAsyncQueries]: true,
|
||||||
};
|
};
|
||||||
const result = await handleChartDataResponse(
|
const result = await handleChartDataResponse(
|
||||||
{ status: 202 },
|
{ status: 202 } as Response,
|
||||||
{ result: [1, 2, 3] },
|
{
|
||||||
|
result: [
|
||||||
|
1, 2, 3,
|
||||||
|
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
expect(result).toEqual([1, 2, 3]);
|
expect(result).toEqual([1, 2, 3]);
|
||||||
});
|
});
|
||||||
@@ -268,9 +350,15 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch CHART_UPDATE_STARTED action before the query', () => {
|
test('should dispatch CHART_UPDATE_STARTED action before the query', () => {
|
||||||
const actionThunk = actions.postChartFormData({});
|
const actionThunk = actions.postChartFormData({
|
||||||
|
viz_type: 'table',
|
||||||
|
} as QueryFormData);
|
||||||
|
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, success
|
// chart update, trigger query, update form data, success
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
@@ -279,8 +367,14 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch TRIGGER_QUERY action with the query', () => {
|
test('should dispatch TRIGGER_QUERY action with the query', () => {
|
||||||
const actionThunk = actions.postChartFormData({});
|
const actionThunk = actions.postChartFormData({
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
viz_type: 'table',
|
||||||
|
} as QueryFormData);
|
||||||
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, success
|
// chart update, trigger query, update form data, success
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
@@ -289,8 +383,14 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => {
|
test('should dispatch UPDATE_QUERY_FORM_DATA action with the query', () => {
|
||||||
const actionThunk = actions.postChartFormData({});
|
const actionThunk = actions.postChartFormData({
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
viz_type: 'table',
|
||||||
|
} as QueryFormData);
|
||||||
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, success
|
// chart update, trigger query, update form data, success
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
@@ -299,8 +399,14 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch logEvent async action', () => {
|
test('should dispatch logEvent async action', () => {
|
||||||
const actionThunk = actions.postChartFormData({});
|
const actionThunk = actions.postChartFormData({
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
viz_type: 'table',
|
||||||
|
} as QueryFormData);
|
||||||
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, success
|
// chart update, trigger query, update form data, success
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
@@ -313,8 +419,15 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
|
test('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
|
||||||
const actionThunk = actions.postChartFormData({});
|
// Pass a viz_type so getQuerySettings returns useLegacyApi from the mocked registry
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
const actionThunk = actions.postChartFormData({
|
||||||
|
viz_type: 'table',
|
||||||
|
} as QueryFormData);
|
||||||
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, success
|
// chart update, trigger query, update form data, success
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
@@ -330,9 +443,17 @@ describe('chart actions', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const timeoutInSec = 1 / 1000;
|
const timeoutInSec = 1 / 1000;
|
||||||
const actionThunk = actions.postChartFormData({}, false, timeoutInSec);
|
const actionThunk = actions.postChartFormData(
|
||||||
|
{} as QueryFormData,
|
||||||
|
false,
|
||||||
|
timeoutInSec,
|
||||||
|
);
|
||||||
|
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, fail
|
// chart update, trigger query, update form data, fail
|
||||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
@@ -352,9 +473,17 @@ describe('chart actions', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timeoutInSec = 100; // Set to a time that is longer than the time this will take to fail
|
const timeoutInSec = 100; // Set to a time that is longer than the time this will take to fail
|
||||||
const actionThunk = actions.postChartFormData({}, false, timeoutInSec);
|
const actionThunk = actions.postChartFormData(
|
||||||
|
{} as QueryFormData,
|
||||||
|
false,
|
||||||
|
timeoutInSec,
|
||||||
|
);
|
||||||
|
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
// chart update, trigger query, update form data, fail
|
// chart update, trigger query, update form data, fail
|
||||||
expect(dispatch.callCount).toBe(5);
|
expect(dispatch.callCount).toBe(5);
|
||||||
const updateFailedAction = dispatch.args[4][0];
|
const updateFailedAction = dispatch.args[4][0];
|
||||||
@@ -375,11 +504,19 @@ describe('chart actions', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timeoutInSec = 100;
|
const timeoutInSec = 100;
|
||||||
const actionThunk = actions.postChartFormData({}, false, timeoutInSec);
|
const actionThunk = actions.postChartFormData(
|
||||||
|
{} as QueryFormData,
|
||||||
|
false,
|
||||||
|
timeoutInSec,
|
||||||
|
);
|
||||||
|
|
||||||
return actionThunk(dispatch, mockGetState).then(() => {
|
return actionThunk(
|
||||||
|
dispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
).then(() => {
|
||||||
const types = dispatch.args
|
const types = dispatch.args
|
||||||
.map(call => call[0] && call[0].type)
|
.map((call: [{ type?: string }]) => call[0] && call[0].type)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
expect(types).toContain(actions.CHART_UPDATE_STOPPED);
|
expect(types).toContain(actions.CHART_UPDATE_STOPPED);
|
||||||
@@ -401,12 +538,15 @@ describe('chart actions', () => {
|
|||||||
.stub(exploreUtils, 'getExploreUrl')
|
.stub(exploreUtils, 'getExploreUrl')
|
||||||
.callsFake(() => mockBigIntUrl);
|
.callsFake(() => mockBigIntUrl);
|
||||||
|
|
||||||
|
// Need viz_type to trigger the mocked getChartMetadataRegistry for legacy API
|
||||||
const { json } = await actions.getChartDataRequest({
|
const { json } = await actions.getChartDataRequest({
|
||||||
formData: fakeMetadata,
|
formData: { ...fakeMetadata, viz_type: 'table' } as QueryFormData,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1);
|
expect(fetchMock.callHistory.calls(mockBigIntUrl)).toHaveLength(1);
|
||||||
expect(json.result[0].value.toString()).toEqual(expectedBigNumber);
|
expect((json.result[0] as JsonObject).value.toString()).toEqual(
|
||||||
|
expectedBigNumber,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -420,11 +560,11 @@ describe('chart actions', () => {
|
|||||||
test('should dispatch annotationQueryStarted and annotationQuerySuccess on successful query', async () => {
|
test('should dispatch annotationQueryStarted and annotationQuerySuccess on successful query', async () => {
|
||||||
const annotation = {
|
const annotation = {
|
||||||
name: 'Holidays',
|
name: 'Holidays',
|
||||||
annotationType: 'EVENT',
|
annotationType: AnnotationType.Event,
|
||||||
sourceType: 'NATIVE',
|
sourceType: AnnotationSourceType.Native,
|
||||||
color: null,
|
color: null,
|
||||||
opacity: '',
|
opacity: undefined,
|
||||||
style: 'solid',
|
style: AnnotationStyle.Solid,
|
||||||
width: 1,
|
width: 1,
|
||||||
showMarkers: false,
|
showMarkers: false,
|
||||||
hideLine: false,
|
hideLine: false,
|
||||||
@@ -438,12 +578,15 @@ describe('chart actions', () => {
|
|||||||
descriptionColumns: [],
|
descriptionColumns: [],
|
||||||
timeColumn: '',
|
timeColumn: '',
|
||||||
intervalEndColumn: '',
|
intervalEndColumn: '',
|
||||||
};
|
} as AnnotationLayer;
|
||||||
const key = undefined;
|
const key = undefined;
|
||||||
|
|
||||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||||
postSpy.mockImplementation(() =>
|
postSpy.mockImplementation(
|
||||||
Promise.resolve({ json: { result: [] } }),
|
() =>
|
||||||
|
Promise.resolve({ json: { result: [] } }) as unknown as ReturnType<
|
||||||
|
typeof SupersetClient.post
|
||||||
|
>,
|
||||||
);
|
);
|
||||||
const buildV1ChartDataPayloadSpy = jest.spyOn(
|
const buildV1ChartDataPayloadSpy = jest.spyOn(
|
||||||
exploreUtils,
|
exploreUtils,
|
||||||
@@ -451,7 +594,11 @@ describe('chart actions', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const queryFunc = actions.runAnnotationQuery({ annotation, key });
|
const queryFunc = actions.runAnnotationQuery({ annotation, key });
|
||||||
await queryFunc(mockDispatch, mockGetState);
|
await queryFunc(
|
||||||
|
mockDispatch as unknown as actions.ChartThunkDispatch,
|
||||||
|
mockGetState as unknown as () => actions.RootState,
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
expect(buildV1ChartDataPayloadSpy).toHaveBeenCalledWith({
|
expect(buildV1ChartDataPayloadSpy).toHaveBeenCalledWith({
|
||||||
formData: {
|
formData: {
|
||||||
@@ -475,9 +622,14 @@ describe('chart actions timeout', () => {
|
|||||||
|
|
||||||
test('should use the timeout from arguments when given', async () => {
|
test('should use the timeout from arguments when given', async () => {
|
||||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||||
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
postSpy.mockImplementation(
|
||||||
|
() =>
|
||||||
|
Promise.resolve({ json: { result: [] } }) as unknown as ReturnType<
|
||||||
|
typeof SupersetClient.post
|
||||||
|
>,
|
||||||
|
);
|
||||||
const timeout = 10; // Set the timeout value here
|
const timeout = 10; // Set the timeout value here
|
||||||
const formData = { datasource: 'table__1' }; // Set the formData here
|
const formData: Partial<QueryFormData> = { datasource: 'table__1' }; // Set the formData here
|
||||||
const key = 'chartKey'; // Set the chart key here
|
const key = 'chartKey'; // Set the chart key here
|
||||||
|
|
||||||
const store = mockStore(initialState);
|
const store = mockStore(initialState);
|
||||||
@@ -485,21 +637,22 @@ describe('chart actions timeout', () => {
|
|||||||
actions.runAnnotationQuery({
|
actions.runAnnotationQuery({
|
||||||
annotation: {
|
annotation: {
|
||||||
value: 'annotationValue',
|
value: 'annotationValue',
|
||||||
sourceType: 'Event',
|
sourceType: AnnotationSourceType.Native,
|
||||||
overrides: {},
|
overrides: {},
|
||||||
},
|
} as unknown as AnnotationLayer,
|
||||||
timeout,
|
timeout,
|
||||||
formData,
|
formData: formData as QueryFormData,
|
||||||
key,
|
key,
|
||||||
}),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const expectedPayload = {
|
const expectedPayload = {
|
||||||
url: expect.any(String),
|
url: expect.any(String) as string,
|
||||||
signal: expect.any(AbortSignal),
|
signal: expect.any(AbortSignal) as AbortSignal,
|
||||||
timeout: timeout * 1000,
|
timeout: timeout * 1000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
jsonPayload: expect.any(Object),
|
jsonPayload: expect.any(Object) as JsonObject,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
||||||
@@ -507,8 +660,13 @@ describe('chart actions timeout', () => {
|
|||||||
|
|
||||||
test('should use the timeout from common.conf when not passed as an argument', async () => {
|
test('should use the timeout from common.conf when not passed as an argument', async () => {
|
||||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||||
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
postSpy.mockImplementation(
|
||||||
const formData = { datasource: 'table__1' }; // Set the formData here
|
() =>
|
||||||
|
Promise.resolve({ json: { result: [] } }) as unknown as ReturnType<
|
||||||
|
typeof SupersetClient.post
|
||||||
|
>,
|
||||||
|
);
|
||||||
|
const formData: Partial<QueryFormData> = { datasource: 'table__1' }; // Set the formData here
|
||||||
const key = 'chartKey'; // Set the chart key here
|
const key = 'chartKey'; // Set the chart key here
|
||||||
|
|
||||||
const store = mockStore(initialState);
|
const store = mockStore(initialState);
|
||||||
@@ -516,21 +674,27 @@ describe('chart actions timeout', () => {
|
|||||||
actions.runAnnotationQuery({
|
actions.runAnnotationQuery({
|
||||||
annotation: {
|
annotation: {
|
||||||
value: 'annotationValue',
|
value: 'annotationValue',
|
||||||
sourceType: 'Event',
|
sourceType: AnnotationSourceType.Native,
|
||||||
overrides: {},
|
overrides: {},
|
||||||
},
|
} as unknown as AnnotationLayer,
|
||||||
undefined,
|
timeout: undefined,
|
||||||
formData,
|
formData: formData as QueryFormData,
|
||||||
key,
|
key,
|
||||||
}),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
}) as any,
|
||||||
);
|
);
|
||||||
|
|
||||||
const expectedPayload = {
|
const expectedPayload = {
|
||||||
url: expect.any(String),
|
url: expect.any(String) as string,
|
||||||
signal: expect.any(AbortSignal),
|
signal: expect.any(AbortSignal) as AbortSignal,
|
||||||
timeout: initialState.common.conf.SUPERSET_WEBSERVER_TIMEOUT * 1000,
|
timeout:
|
||||||
|
(
|
||||||
|
initialState.common.conf as unknown as {
|
||||||
|
SUPERSET_WEBSERVER_TIMEOUT: number;
|
||||||
|
}
|
||||||
|
).SUPERSET_WEBSERVER_TIMEOUT * 1000,
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
jsonPayload: expect.any(Object),
|
jsonPayload: expect.any(Object) as JsonObject,
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
expect(postSpy).toHaveBeenCalledWith(expectedPayload);
|
||||||
@@ -115,7 +115,7 @@ export default class CRUDCollection extends PureComponent<
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onCellChange(id: number, col: string, val: boolean) {
|
onCellChange(id: string | number, col: string, val: unknown) {
|
||||||
this.setState(prevState => {
|
this.setState(prevState => {
|
||||||
const updatedCollection = {
|
const updatedCollection = {
|
||||||
...prevState.collection,
|
...prevState.collection,
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -26,16 +26,16 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
|
|
||||||
interface FieldProps<V> {
|
export interface FieldProps<V> {
|
||||||
fieldKey: string;
|
fieldKey: string;
|
||||||
value?: V;
|
value?: V;
|
||||||
label: string;
|
label: string;
|
||||||
description?: ReactNode;
|
description?: ReactNode;
|
||||||
control: ReactElement;
|
control: ReactElement;
|
||||||
additionalControl?: ReactElement;
|
additionalControl?: ReactElement;
|
||||||
onChange: (fieldKey: string, newValue: V) => void;
|
onChange?: (fieldKey: string, newValue: V) => void;
|
||||||
compact: boolean;
|
compact?: boolean;
|
||||||
inline: boolean;
|
inline?: boolean;
|
||||||
errorMessage?: string | ReactElement;
|
errorMessage?: string | ReactElement;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default function Field<V>({
|
|||||||
additionalControl,
|
additionalControl,
|
||||||
onChange = () => {},
|
onChange = () => {},
|
||||||
compact = false,
|
compact = false,
|
||||||
inline,
|
inline = false,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
}: FieldProps<V>) {
|
}: FieldProps<V>) {
|
||||||
const onControlChange = useCallback(
|
const onControlChange = useCallback(
|
||||||
|
|||||||
@@ -22,10 +22,10 @@ import { css } from '@apache-superset/core/ui';
|
|||||||
import { recurseReactClone } from '../../utils';
|
import { recurseReactClone } from '../../utils';
|
||||||
import Field from '../Field';
|
import Field from '../Field';
|
||||||
|
|
||||||
interface FieldsetProps {
|
export interface FieldsetProps {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
onChange: (updatedItem: Record<string, any>) => void;
|
onChange?: (updatedItem: Record<string, any>) => void;
|
||||||
item: Record<string, any>;
|
item?: Record<string, any>;
|
||||||
title?: ReactNode;
|
title?: ReactNode;
|
||||||
compact?: boolean;
|
compact?: boolean;
|
||||||
}
|
}
|
||||||
@@ -35,13 +35,13 @@ type fieldKeyType = string | number;
|
|||||||
export default function Fieldset({
|
export default function Fieldset({
|
||||||
children,
|
children,
|
||||||
onChange,
|
onChange,
|
||||||
item,
|
item = {},
|
||||||
title = null,
|
title = null,
|
||||||
compact = false,
|
compact = false,
|
||||||
}: FieldsetProps) {
|
}: FieldsetProps) {
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(fieldKey: fieldKeyType, val: any) => {
|
(fieldKey: fieldKeyType, val: any) => {
|
||||||
onChange({
|
onChange?.({
|
||||||
...item,
|
...item,
|
||||||
[fieldKey]: val,
|
[fieldKey]: val,
|
||||||
});
|
});
|
||||||
@@ -51,7 +51,7 @@ export default function Fieldset({
|
|||||||
|
|
||||||
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
||||||
onChange: handleChange,
|
onChange: handleChange,
|
||||||
value: item[field.props.fieldKey],
|
value: item?.[field.props.fieldKey],
|
||||||
compact,
|
compact,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -54,20 +54,26 @@ export interface CRUDCollectionProps {
|
|||||||
expandFieldset?: ReactNode;
|
expandFieldset?: ReactNode;
|
||||||
extraButtons?: ReactNode;
|
extraButtons?: ReactNode;
|
||||||
itemGenerator?: () => any;
|
itemGenerator?: () => any;
|
||||||
itemCellProps?: ((
|
itemCellProps?: Record<
|
||||||
val: unknown,
|
string,
|
||||||
label: string,
|
(
|
||||||
record: any,
|
val: unknown,
|
||||||
) => DetailedHTMLProps<
|
label: string,
|
||||||
TdHTMLAttributes<HTMLTableCellElement>,
|
record: any,
|
||||||
HTMLTableCellElement
|
) => DetailedHTMLProps<
|
||||||
>)[];
|
TdHTMLAttributes<HTMLTableCellElement>,
|
||||||
itemRenderers?: ((
|
HTMLTableCellElement
|
||||||
val: unknown,
|
>
|
||||||
onChange: () => void,
|
>;
|
||||||
label: string,
|
itemRenderers?: Record<
|
||||||
record: any,
|
string,
|
||||||
) => ReactNode)[];
|
(
|
||||||
|
val: unknown,
|
||||||
|
onChange: (value: unknown) => void,
|
||||||
|
label: string,
|
||||||
|
record: any,
|
||||||
|
) => ReactNode
|
||||||
|
>;
|
||||||
onChange?: (arg0: any) => void;
|
onChange?: (arg0: any) => void;
|
||||||
tableColumns: any[];
|
tableColumns: any[];
|
||||||
tableLayout?: 'fixed' | 'auto';
|
tableLayout?: 'fixed' | 'auto';
|
||||||
|
|||||||
@@ -16,44 +16,125 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { Children, cloneElement } from 'react';
|
import {
|
||||||
|
Children,
|
||||||
|
cloneElement,
|
||||||
|
ReactNode,
|
||||||
|
ReactElement,
|
||||||
|
isValidElement,
|
||||||
|
} from 'react';
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
import { tn } from '@apache-superset/core/ui';
|
import { tn } from '@apache-superset/core/ui';
|
||||||
import rison from 'rison';
|
import rison from 'rison';
|
||||||
|
|
||||||
export function recurseReactClone(children, type, propExtender) {
|
// Type definitions
|
||||||
|
|
||||||
|
interface ColumnMetadata {
|
||||||
|
id?: string | number;
|
||||||
|
column_name: string;
|
||||||
|
is_dttm?: boolean;
|
||||||
|
type?: string;
|
||||||
|
groupby?: boolean;
|
||||||
|
filterable?: boolean;
|
||||||
|
expression?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ColumnChanges {
|
||||||
|
added: string[];
|
||||||
|
modified: string[];
|
||||||
|
removed: string[];
|
||||||
|
finalColumns: ColumnMetadata[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DatasourceForSync {
|
||||||
|
type?: string;
|
||||||
|
datasource_type?: string;
|
||||||
|
database?: {
|
||||||
|
database_name?: string;
|
||||||
|
name?: string;
|
||||||
|
};
|
||||||
|
catalog?: string;
|
||||||
|
schema?: string;
|
||||||
|
table_name?: string;
|
||||||
|
normalize_columns?: boolean;
|
||||||
|
always_filter_main_dttm?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncParams {
|
||||||
|
datasource_type?: string | null;
|
||||||
|
database_name?: string | null;
|
||||||
|
catalog_name?: string | null;
|
||||||
|
schema_name?: string | null;
|
||||||
|
table_name?: string | null;
|
||||||
|
normalize_columns?: boolean | null;
|
||||||
|
always_filter_main_dttm?: boolean | null;
|
||||||
|
[key: string]: string | boolean | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
// React element type to match against in recurseReactClone
|
||||||
|
interface ComponentType {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recurseReactClone<T extends Record<string, unknown>>(
|
||||||
|
children: ReactNode,
|
||||||
|
type: ComponentType,
|
||||||
|
propExtender: (child: ReactElement<T>) => Record<string, unknown>,
|
||||||
|
): ReactNode {
|
||||||
/**
|
/**
|
||||||
* Clones a React component's children, and injects new props
|
* Clones a React component's children, and injects new props
|
||||||
* where the type specified is matched.
|
* where the type specified is matched.
|
||||||
*/
|
*/
|
||||||
return Children.map(children, child => {
|
return Children.map(children, child => {
|
||||||
let newChild = child;
|
let newChild = child;
|
||||||
if (child && child.type && child.type.name === type.name) {
|
if (
|
||||||
newChild = cloneElement(child, propExtender(child));
|
isValidElement<T>(child) &&
|
||||||
|
child.type &&
|
||||||
|
typeof child.type === 'function' &&
|
||||||
|
(child.type as ComponentType).name === type.name
|
||||||
|
) {
|
||||||
|
newChild = cloneElement(
|
||||||
|
child,
|
||||||
|
propExtender(child as ReactElement<T>) as Partial<T>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (newChild && newChild.props && newChild.props.children) {
|
if (
|
||||||
newChild = cloneElement(newChild, {
|
isValidElement(newChild) &&
|
||||||
children: recurseReactClone(
|
newChild.props &&
|
||||||
newChild.props.children,
|
(newChild.props as { children?: ReactNode }).children
|
||||||
type,
|
) {
|
||||||
propExtender,
|
newChild = cloneElement(
|
||||||
),
|
newChild as ReactElement<{ children: ReactNode }>,
|
||||||
});
|
{
|
||||||
|
children: recurseReactClone(
|
||||||
|
(newChild.props as { children: ReactNode }).children,
|
||||||
|
type,
|
||||||
|
propExtender,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
return newChild;
|
return newChild;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateColumns(prevCols, newCols, addSuccessToast) {
|
export function updateColumns(
|
||||||
|
prevCols: ColumnMetadata[],
|
||||||
|
newCols: ColumnMetadata[],
|
||||||
|
addSuccessToast: (msg: string) => void,
|
||||||
|
): ColumnChanges {
|
||||||
// cols: Array<{column_name: string; is_dttm: boolean; type: string;}>
|
// cols: Array<{column_name: string; is_dttm: boolean; type: string;}>
|
||||||
const databaseColumnNames = newCols.map(col => col.column_name);
|
const databaseColumnNames = newCols.map(col => col.column_name);
|
||||||
const currentCols = prevCols.reduce((agg, col) => {
|
const currentCols = prevCols.reduce<Record<string, ColumnMetadata>>(
|
||||||
// eslint-disable-next-line no-param-reassign
|
(agg, col) => {
|
||||||
agg[col.column_name] = col;
|
// eslint-disable-next-line no-param-reassign
|
||||||
return agg;
|
agg[col.column_name] = col;
|
||||||
}, {});
|
return agg;
|
||||||
const columnChanges = {
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
const columnChanges: ColumnChanges = {
|
||||||
added: [],
|
added: [],
|
||||||
modified: [],
|
modified: [],
|
||||||
removed: prevCols
|
removed: prevCols
|
||||||
@@ -137,12 +218,15 @@ export function updateColumns(prevCols, newCols, addSuccessToast) {
|
|||||||
* Fetches column metadata from the datasource's underlying table/view.
|
* Fetches column metadata from the datasource's underlying table/view.
|
||||||
* Used to sync dataset columns with the database schema.
|
* Used to sync dataset columns with the database schema.
|
||||||
*
|
*
|
||||||
* @param {Object} datasource - The datasource object
|
* @param datasource - The datasource object
|
||||||
* @param {AbortSignal} [signal] - Optional AbortSignal to cancel the request
|
* @param signal - Optional AbortSignal to cancel the request
|
||||||
* @returns {Promise<Array>} Array of column metadata objects
|
* @returns Promise Array of column metadata objects
|
||||||
*/
|
*/
|
||||||
export async function fetchSyncedColumns(datasource, signal) {
|
export async function fetchSyncedColumns(
|
||||||
const params = {
|
datasource: DatasourceForSync,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ColumnMetadata[]> {
|
||||||
|
const params: SyncParams = {
|
||||||
datasource_type: datasource.type || datasource.datasource_type,
|
datasource_type: datasource.type || datasource.datasource_type,
|
||||||
database_name:
|
database_name:
|
||||||
datasource.database?.database_name || datasource.database?.name,
|
datasource.database?.database_name || datasource.database?.name,
|
||||||
@@ -162,5 +246,5 @@ export async function fetchSyncedColumns(datasource, signal) {
|
|||||||
params,
|
params,
|
||||||
)}`;
|
)}`;
|
||||||
const { json } = await SupersetClient.get({ endpoint, signal });
|
const { json } = await SupersetClient.get({ endpoint, signal });
|
||||||
return json;
|
return json as ColumnMetadata[];
|
||||||
}
|
}
|
||||||
@@ -238,10 +238,10 @@ const FilterValue: FC<FilterValueProps> = ({
|
|||||||
// deal with getChartDataRequest transforming the response data
|
// deal with getChartDataRequest transforming the response data
|
||||||
const result = 'result' in json ? json.result[0] : json;
|
const result = 'result' in json ? json.result[0] : json;
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setState([result]);
|
setState([result as ChartDataResponseResult]);
|
||||||
handleFilterLoadFinish();
|
handleFilterLoadFinish();
|
||||||
} else if (response.status === 202) {
|
} else if (response.status === 202) {
|
||||||
waitForAsyncData(result)
|
waitForAsyncData(result as Parameters<typeof waitForAsyncData>[0])
|
||||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||||
setState(asyncResult);
|
setState(asyncResult);
|
||||||
handleFilterLoadFinish();
|
handleFilterLoadFinish();
|
||||||
@@ -258,7 +258,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
setState(json.result);
|
setState(json.result as ChartDataResponseResult[]);
|
||||||
setError(undefined);
|
setError(undefined);
|
||||||
handleFilterLoadFinish();
|
handleFilterLoadFinish();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -499,10 +499,10 @@ const FiltersConfigForm = (
|
|||||||
|
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setNativeFilterFieldValuesWrapper({
|
setNativeFilterFieldValuesWrapper({
|
||||||
defaultValueQueriesData: [result],
|
defaultValueQueriesData: [result as ChartDataResponseResult],
|
||||||
});
|
});
|
||||||
} else if (response.status === 202) {
|
} else if (response.status === 202) {
|
||||||
waitForAsyncData(result)
|
waitForAsyncData(result as Parameters<typeof waitForAsyncData>[0])
|
||||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||||
setNativeFilterFieldValuesWrapper({
|
setNativeFilterFieldValuesWrapper({
|
||||||
defaultValueQueriesData: asyncResult,
|
defaultValueQueriesData: asyncResult,
|
||||||
|
|||||||
@@ -181,7 +181,8 @@ test('getChartDataPayloads generates payloads for charts with state converters',
|
|||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
||||||
.mockResolvedValue(mockPayload);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.mockResolvedValue(mockPayload as any);
|
||||||
|
|
||||||
const result = await getChartDataPayloads(mockState as RootState);
|
const result = await getChartDataPayloads(mockState as RootState);
|
||||||
|
|
||||||
@@ -211,7 +212,8 @@ test('getChartDataPayloads filters by specific chartId when provided', async ()
|
|||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
||||||
.mockResolvedValue(mockPayload);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.mockResolvedValue(mockPayload as any);
|
||||||
|
|
||||||
const result = await getChartDataPayloads(mockState as RootState, {
|
const result = await getChartDataPayloads(mockState as RootState, {
|
||||||
chartId: 123,
|
chartId: 123,
|
||||||
@@ -254,11 +256,12 @@ test('getChartDataPayloads handles errors during payload generation gracefully',
|
|||||||
|
|
||||||
jest
|
jest
|
||||||
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
.mockImplementation((params: any) => {
|
.mockImplementation((params: any) => {
|
||||||
if (params.formData.viz_type === 'ag-grid-table') {
|
if (params.formData.viz_type === 'ag-grid-table') {
|
||||||
return Promise.reject(new Error('Failed to build payload'));
|
return Promise.reject(new Error('Failed to build payload'));
|
||||||
}
|
}
|
||||||
return Promise.resolve(mockPayload);
|
return Promise.resolve(mockPayload as any);
|
||||||
});
|
});
|
||||||
|
|
||||||
const result = await getChartDataPayloads(mockState as RootState);
|
const result = await getChartDataPayloads(mockState as RootState);
|
||||||
@@ -287,7 +290,8 @@ test('getChartDataPayloads merges baseOwnState with converted chart state', asyn
|
|||||||
|
|
||||||
const mockBuildPayload = jest
|
const mockBuildPayload = jest
|
||||||
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
||||||
.mockResolvedValue(mockPayload);
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
.mockResolvedValue(mockPayload as any);
|
||||||
|
|
||||||
await getChartDataPayloads(mockState as RootState, { chartId: 123 });
|
await getChartDataPayloads(mockState as RootState, { chartId: 123 });
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,11 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { DataMaskStateWithId, JsonObject } from '@superset-ui/core';
|
import {
|
||||||
|
DataMaskStateWithId,
|
||||||
|
JsonObject,
|
||||||
|
QueryFormData,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { logging } from '@apache-superset/core';
|
import { logging } from '@apache-superset/core';
|
||||||
import { isEmpty, isEqual } from 'lodash';
|
import { isEmpty, isEqual } from 'lodash';
|
||||||
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
|
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
|
||||||
@@ -138,11 +142,11 @@ export const getChartDataPayloads = async (
|
|||||||
};
|
};
|
||||||
|
|
||||||
const payload = await buildV1ChartDataPayload({
|
const payload = await buildV1ChartDataPayload({
|
||||||
formData,
|
formData: formData as unknown as QueryFormData,
|
||||||
resultFormat: 'json',
|
resultFormat: 'json',
|
||||||
resultType: 'results',
|
resultType: 'results',
|
||||||
ownState,
|
ownState,
|
||||||
setDataMask: null,
|
setDataMask: undefined,
|
||||||
force: false,
|
force: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -164,8 +164,8 @@ export const getSlicePayload = async (
|
|||||||
force: false,
|
force: false,
|
||||||
resultFormat: 'json',
|
resultFormat: 'json',
|
||||||
resultType: 'full',
|
resultType: 'full',
|
||||||
setDataMask: null,
|
setDataMask: undefined,
|
||||||
ownState: null,
|
ownState: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
const payload: Partial<PayloadSlice> = {
|
const payload: Partial<PayloadSlice> = {
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ const MATRIXIFY_INCOMPATIBLE_CHARTS = new Set([
|
|||||||
|
|
||||||
export type ControlPanelsContainerProps = {
|
export type ControlPanelsContainerProps = {
|
||||||
exploreState: ExplorePageState['explore'];
|
exploreState: ExplorePageState['explore'];
|
||||||
actions: ExploreActions;
|
// Only setControlValue is used from actions in this component
|
||||||
|
actions: Pick<ExploreActions, 'setControlValue'>;
|
||||||
datasource_type: DatasourceType;
|
datasource_type: DatasourceType;
|
||||||
chart: ChartState;
|
chart: ChartState;
|
||||||
controls: Record<string, ControlState>;
|
controls: Record<string, ControlState>;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ import {
|
|||||||
import { CopyToClipboardButton } from '.';
|
import { CopyToClipboardButton } from '.';
|
||||||
|
|
||||||
test('Render a button', () => {
|
test('Render a button', () => {
|
||||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||||
useRedux: true,
|
useRedux: true,
|
||||||
});
|
});
|
||||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||||
@@ -39,7 +39,7 @@ test('Should copy to clipboard', async () => {
|
|||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
global.navigator.clipboard = { write: callback, writeText: callback };
|
global.navigator.clipboard = { write: callback, writeText: callback };
|
||||||
|
|
||||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||||
useRedux: true,
|
useRedux: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,10 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { CopyToClipboard } from 'src/components';
|
import { CopyToClipboard } from 'src/components';
|
||||||
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
import {
|
||||||
|
prepareCopyToClipboardTabularData,
|
||||||
|
TabularDataRow,
|
||||||
|
} from 'src/utils/common';
|
||||||
import { getTimeColumns, setTimeColumns } from './utils';
|
import { getTimeColumns, setTimeColumns } from './utils';
|
||||||
|
|
||||||
export const CellNull = styled('span')`
|
export const CellNull = styled('span')`
|
||||||
@@ -56,7 +59,7 @@ export const CopyToClipboardButton = ({
|
|||||||
data,
|
data,
|
||||||
columns,
|
columns,
|
||||||
}: {
|
}: {
|
||||||
data?: Record<string, any>;
|
data?: TabularDataRow[];
|
||||||
columns?: string[];
|
columns?: string[];
|
||||||
}) => (
|
}) => (
|
||||||
<CopyToClipboard
|
<CopyToClipboard
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ export const TableControls = ({
|
|||||||
name &&
|
name &&
|
||||||
!originalTimeColumns.includes(name),
|
!originalTimeColumns.includes(name),
|
||||||
)
|
)
|
||||||
.map(([colname]) => colname);
|
.map(([colname]) => colname)
|
||||||
|
.filter((x): x is string => x !== undefined);
|
||||||
const formattedData = useMemo(
|
const formattedData = useMemo(
|
||||||
() => applyFormattingToTabularData(data, formattedTimeColumns),
|
() => applyFormattingToTabularData(data, formattedTimeColumns),
|
||||||
[data, formattedTimeColumns],
|
[data, formattedTimeColumns],
|
||||||
|
|||||||
@@ -73,7 +73,9 @@ export const useResultsPane = ({
|
|||||||
// it's an invalid formData when gets a errorMessage
|
// it's an invalid formData when gets a errorMessage
|
||||||
if (errorMessage) return;
|
if (errorMessage) return;
|
||||||
if (isRequest && cache.has(queryFormData)) {
|
if (isRequest && cache.has(queryFormData)) {
|
||||||
setResultResp(ensureIsArray(cache.get(queryFormData)));
|
setResultResp(
|
||||||
|
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
|
||||||
|
);
|
||||||
setResponseError('');
|
setResponseError('');
|
||||||
if (queryForce) {
|
if (queryForce) {
|
||||||
setForceQuery?.(false);
|
setForceQuery?.(false);
|
||||||
@@ -90,7 +92,7 @@ export const useResultsPane = ({
|
|||||||
ownState,
|
ownState,
|
||||||
})
|
})
|
||||||
.then(({ json }) => {
|
.then(({ json }) => {
|
||||||
setResultResp(ensureIsArray(json.result));
|
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
|
||||||
setResponseError('');
|
setResponseError('');
|
||||||
cache.set(queryFormData, json.result);
|
cache.set(queryFormData, json.result);
|
||||||
if (queryForce) {
|
if (queryForce) {
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
|
|||||||
const url = 'http://localhost/explore/p/100';
|
const url = 'http://localhost/explore/p/100';
|
||||||
fetchMock.post('glob:*/api/v1/explore/permalink', { url });
|
fetchMock.post('glob:*/api/v1/explore/permalink', { url });
|
||||||
|
|
||||||
|
const mockFormData = {
|
||||||
|
datasource: 'table__1',
|
||||||
|
viz_type: 'table',
|
||||||
|
};
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('EmbedCodeButton', () => {
|
describe('EmbedCodeButton', () => {
|
||||||
test('renders', () => {
|
test('renders', () => {
|
||||||
@@ -31,7 +36,7 @@ describe('EmbedCodeButton', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('returns correct embed code', async () => {
|
test('returns correct embed code', async () => {
|
||||||
render(<EmbedCodeContent />, { useRedux: true });
|
render(<EmbedCodeContent formData={mockFormData} />, { useRedux: true });
|
||||||
expect(await screen.findByText('iframe', { exact: false })).toBeVisible();
|
expect(await screen.findByText('iframe', { exact: false })).toBeVisible();
|
||||||
expect(await screen.findByText('/iframe', { exact: false })).toBeVisible();
|
expect(await screen.findByText('/iframe', { exact: false })).toBeVisible();
|
||||||
expect(
|
expect(
|
||||||
@@ -16,7 +16,15 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
|
ChangeEvent,
|
||||||
|
FC,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { LatestQueryFormData } from '@superset-ui/core';
|
||||||
import { css, t } from '@apache-superset/core/ui';
|
import { css, t } from '@apache-superset/core/ui';
|
||||||
import { Input, Space, Typography } from '@superset-ui/core/components';
|
import { Input, Space, Typography } from '@superset-ui/core/components';
|
||||||
import { CopyToClipboard } from 'src/components';
|
import { CopyToClipboard } from 'src/components';
|
||||||
@@ -24,13 +32,21 @@ import { URL_PARAMS } from 'src/constants';
|
|||||||
import { getChartPermalink } from 'src/utils/urlUtils';
|
import { getChartPermalink } from 'src/utils/urlUtils';
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
|
|
||||||
const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
export interface EmbedCodeContentProps {
|
||||||
|
formData?: LatestQueryFormData;
|
||||||
|
addDangerToast?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EmbedCodeContent: FC<EmbedCodeContentProps> = ({
|
||||||
|
formData,
|
||||||
|
addDangerToast,
|
||||||
|
}) => {
|
||||||
const [height, setHeight] = useState('400');
|
const [height, setHeight] = useState('400');
|
||||||
const [width, setWidth] = useState('600');
|
const [width, setWidth] = useState('600');
|
||||||
const [url, setUrl] = useState('');
|
const [url, setUrl] = useState('');
|
||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
|
|
||||||
const handleInputChange = useCallback(e => {
|
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||||
const { value, name } = e.currentTarget;
|
const { value, name } = e.currentTarget;
|
||||||
if (name === 'width') {
|
if (name === 'width') {
|
||||||
setWidth(value);
|
setWidth(value);
|
||||||
@@ -42,7 +58,8 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
|||||||
|
|
||||||
const updateUrl = useCallback(() => {
|
const updateUrl = useCallback(() => {
|
||||||
setUrl('');
|
setUrl('');
|
||||||
getChartPermalink(formData)
|
if (!formData?.datasource) return;
|
||||||
|
getChartPermalink(formData as { datasource: string })
|
||||||
.then(result => {
|
.then(result => {
|
||||||
if (result?.url) {
|
if (result?.url) {
|
||||||
setUrl(result.url);
|
setUrl(result.url);
|
||||||
@@ -51,7 +68,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
|||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setErrorMessage(t('Error'));
|
setErrorMessage(t('Error'));
|
||||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
addDangerToast?.(t('Sorry, something went wrong. Try again later.'));
|
||||||
});
|
});
|
||||||
}, [addDangerToast, formData]);
|
}, [addDangerToast, formData]);
|
||||||
|
|
||||||
@@ -98,7 +115,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
|||||||
name="embedCode"
|
name="embedCode"
|
||||||
disabled={!html}
|
disabled={!html}
|
||||||
value={text}
|
value={text}
|
||||||
rows="4"
|
rows={4}
|
||||||
readOnly
|
readOnly
|
||||||
css={theme => css`
|
css={theme => css`
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -111,7 +128,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Space
|
<Space
|
||||||
direction="horizzontal"
|
direction="horizontal"
|
||||||
css={theme => css`
|
css={theme => css`
|
||||||
margin-top: ${theme.margin}px;
|
margin-top: ${theme.margin}px;
|
||||||
`}
|
`}
|
||||||
@@ -32,7 +32,7 @@ import * as downloadAsImage from 'src/utils/downloadAsImage';
|
|||||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||||
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
||||||
import ExploreHeader from '.';
|
import ExploreHeader, { ExploreChartHeaderProps } from '.';
|
||||||
import { getChartMetadataRegistry } from '@superset-ui/core';
|
import { getChartMetadataRegistry } from '@superset-ui/core';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
@@ -56,90 +56,99 @@ const mockExportCurrentViewBehavior = () => {
|
|||||||
} as any);
|
} as any);
|
||||||
};
|
};
|
||||||
|
|
||||||
const createProps = (additionalProps = {}) => ({
|
const createProps = (additionalProps = {}) =>
|
||||||
chart: {
|
({
|
||||||
id: 1,
|
chart: {
|
||||||
latestQueryFormData: {
|
id: 1,
|
||||||
viz_type: VizType.Histogram,
|
latestQueryFormData: {
|
||||||
datasource: '49__table',
|
viz_type: VizType.Histogram,
|
||||||
slice_id: 318,
|
datasource: '49__table',
|
||||||
url_params: {},
|
slice_id: 318,
|
||||||
granularity_sqla: 'time_start',
|
url_params: {},
|
||||||
time_range: 'No filter',
|
granularity_sqla: 'time_start',
|
||||||
all_columns_x: ['age'],
|
time_range: 'No filter',
|
||||||
adhoc_filters: [],
|
all_columns_x: ['age'],
|
||||||
row_limit: 10000,
|
adhoc_filters: [],
|
||||||
groupby: null,
|
row_limit: 10000,
|
||||||
color_scheme: 'supersetColors',
|
groupby: null,
|
||||||
label_colors: {},
|
color_scheme: 'supersetColors',
|
||||||
link_length: '25',
|
label_colors: {},
|
||||||
x_axis_label: 'age',
|
link_length: '25',
|
||||||
y_axis_label: 'count',
|
x_axis_label: 'age',
|
||||||
server_pagination: false as any,
|
y_axis_label: 'count',
|
||||||
},
|
server_pagination: false,
|
||||||
chartStatus: 'rendered',
|
|
||||||
},
|
|
||||||
slice: {
|
|
||||||
cache_timeout: null,
|
|
||||||
changed_on: '2021-03-19T16:30:56.750230',
|
|
||||||
changed_on_humanized: '7 days ago',
|
|
||||||
datasource: 'FCC 2018 Survey',
|
|
||||||
description: 'Simple description',
|
|
||||||
description_markeddown: '',
|
|
||||||
edit_url: '/chart/edit/318',
|
|
||||||
form_data: {
|
|
||||||
adhoc_filters: [],
|
|
||||||
all_columns_x: ['age'],
|
|
||||||
color_scheme: 'supersetColors',
|
|
||||||
datasource: '49__table',
|
|
||||||
granularity_sqla: 'time_start',
|
|
||||||
groupby: null,
|
|
||||||
label_colors: {},
|
|
||||||
link_length: '25',
|
|
||||||
queryFields: { groupby: 'groupby' },
|
|
||||||
row_limit: 10000,
|
|
||||||
slice_id: 318,
|
|
||||||
time_range: 'No filter',
|
|
||||||
url_params: {},
|
|
||||||
viz_type: VizType.Histogram,
|
|
||||||
x_axis_label: 'age',
|
|
||||||
y_axis_label: 'count',
|
|
||||||
},
|
|
||||||
modified: '<span class="no-wrap">7 days ago</span>',
|
|
||||||
owners: [
|
|
||||||
{
|
|
||||||
text: 'Superset Admin',
|
|
||||||
value: 1,
|
|
||||||
},
|
},
|
||||||
],
|
chartStatus: 'rendered' as const,
|
||||||
slice_id: 318,
|
chartAlert: null,
|
||||||
slice_name: 'Age distribution of respondents',
|
chartUpdateEndTime: null,
|
||||||
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20318%7D',
|
chartUpdateStartTime: 0,
|
||||||
},
|
lastRendered: 0,
|
||||||
slice_name: 'Age distribution of respondents',
|
sliceFormData: null,
|
||||||
actions: {
|
queryController: null,
|
||||||
postChartFormData: jest.fn(),
|
queriesResponse: null,
|
||||||
updateChartTitle: jest.fn(),
|
triggerQuery: false,
|
||||||
fetchFaveStar: jest.fn(),
|
},
|
||||||
saveFaveStar: jest.fn(),
|
slice: {
|
||||||
redirectSQLLab: jest.fn(),
|
cache_timeout: null,
|
||||||
},
|
changed_on: '2021-03-19T16:30:56.750230',
|
||||||
user: {
|
changed_on_humanized: '7 days ago',
|
||||||
userId: 1,
|
datasource: 'FCC 2018 Survey',
|
||||||
},
|
description: 'Simple description',
|
||||||
metadata: {
|
description_markeddown: '',
|
||||||
created_on_humanized: 'a week ago',
|
edit_url: '/chart/edit/318',
|
||||||
changed_on_humanized: '2 days ago',
|
form_data: {
|
||||||
owners: ['John Doe'],
|
adhoc_filters: [],
|
||||||
created_by: 'John Doe',
|
all_columns_x: ['age'],
|
||||||
changed_by: 'John Doe',
|
color_scheme: 'supersetColors',
|
||||||
dashboards: [{ id: 1, dashboard_title: 'Test' }],
|
datasource: '49__table',
|
||||||
},
|
granularity_sqla: 'time_start',
|
||||||
canOverwrite: false,
|
groupby: null,
|
||||||
canDownload: false,
|
label_colors: {},
|
||||||
isStarred: false,
|
link_length: '25',
|
||||||
...additionalProps,
|
queryFields: { groupby: 'groupby' },
|
||||||
});
|
row_limit: 10000,
|
||||||
|
slice_id: 318,
|
||||||
|
time_range: 'No filter',
|
||||||
|
url_params: {},
|
||||||
|
viz_type: VizType.Histogram,
|
||||||
|
x_axis_label: 'age',
|
||||||
|
y_axis_label: 'count',
|
||||||
|
},
|
||||||
|
modified: '<span class="no-wrap">7 days ago</span>',
|
||||||
|
owners: [
|
||||||
|
{
|
||||||
|
text: 'Superset Admin',
|
||||||
|
value: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
slice_id: 318,
|
||||||
|
slice_name: 'Age distribution of respondents',
|
||||||
|
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20318%7D',
|
||||||
|
},
|
||||||
|
sliceName: 'Age distribution of respondents',
|
||||||
|
actions: {
|
||||||
|
postChartFormData: jest.fn(),
|
||||||
|
updateChartTitle: jest.fn(),
|
||||||
|
fetchFaveStar: jest.fn(),
|
||||||
|
saveFaveStar: jest.fn(),
|
||||||
|
redirectSQLLab: jest.fn(),
|
||||||
|
},
|
||||||
|
user: {
|
||||||
|
userId: 1,
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
created_on_humanized: 'a week ago',
|
||||||
|
changed_on_humanized: '2 days ago',
|
||||||
|
owners: ['John Doe'],
|
||||||
|
created_by: 'John Doe',
|
||||||
|
changed_by: 'John Doe',
|
||||||
|
dashboards: [{ id: 1, dashboard_title: 'Test' }],
|
||||||
|
},
|
||||||
|
canOverwrite: false,
|
||||||
|
canDownload: false,
|
||||||
|
isStarred: false,
|
||||||
|
...additionalProps,
|
||||||
|
}) as unknown as ExploreChartHeaderProps;
|
||||||
|
|
||||||
fetchMock.post(
|
fetchMock.post(
|
||||||
'http://api/v1/chart/data?form_data=%7B%22slice_id%22%3A318%7D',
|
'http://api/v1/chart/data?form_data=%7B%22slice_id%22%3A318%7D',
|
||||||
@@ -165,27 +174,40 @@ describe('ExploreChartHeader', () => {
|
|||||||
const props = createProps();
|
const props = createProps();
|
||||||
render(<ExploreHeader {...props} />, { useRedux: true });
|
render(<ExploreHeader {...props} />, { useRedux: true });
|
||||||
const newChartName = 'New chart name';
|
const newChartName = 'New chart name';
|
||||||
const prevChartName = props.slice_name;
|
const prevChartName = props.sliceName;
|
||||||
|
|
||||||
|
// Wait for the component to render with the chart title
|
||||||
expect(
|
expect(
|
||||||
await screen.findByText(/add the name of the chart/i),
|
await screen.findByDisplayValue(prevChartName ?? ''),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||||
userEvent.click(screen.getByText('Edit chart properties'));
|
await userEvent.click(screen.getByText('Edit chart properties'));
|
||||||
|
|
||||||
const nameInput = await screen.findByRole('textbox', { name: 'Name' });
|
const nameInput = await screen.findByRole('textbox', { name: 'Name' });
|
||||||
|
|
||||||
userEvent.clear(nameInput);
|
await userEvent.clear(nameInput);
|
||||||
userEvent.type(nameInput, newChartName);
|
await userEvent.type(nameInput, newChartName);
|
||||||
|
|
||||||
expect(screen.getByDisplayValue(newChartName)).toBeInTheDocument();
|
expect(screen.getByDisplayValue(newChartName)).toBeInTheDocument();
|
||||||
|
|
||||||
userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
await userEvent.click(screen.getByRole('button', { name: 'Cancel' }));
|
||||||
|
|
||||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
// Wait for the modal to close
|
||||||
userEvent.click(screen.getByText('Edit chart properties'));
|
await waitFor(() => {
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('textbox', { name: 'Name' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
expect(await screen.findByDisplayValue(prevChartName)).toBeInTheDocument();
|
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||||
|
await userEvent.click(screen.getByText('Edit chart properties'));
|
||||||
|
|
||||||
|
// Wait for the modal to reopen and verify the name was reset
|
||||||
|
const reopenedNameInput = await screen.findByRole('textbox', {
|
||||||
|
name: 'Name',
|
||||||
|
});
|
||||||
|
expect(reopenedNameInput).toHaveValue(prevChartName ?? '');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the metadata bar when saved', async () => {
|
test('renders the metadata bar when saved', async () => {
|
||||||
@@ -203,7 +225,7 @@ describe('ExploreChartHeader', () => {
|
|||||||
<ExploreHeader
|
<ExploreHeader
|
||||||
{...props}
|
{...props}
|
||||||
metadata={{
|
metadata={{
|
||||||
...props.metadata,
|
...props.metadata!,
|
||||||
dashboards: [
|
dashboards: [
|
||||||
{ id: 1, dashboard_title: 'Test' },
|
{ id: 1, dashboard_title: 'Test' },
|
||||||
{ id: 2, dashboard_title: 'Test2' },
|
{ id: 2, dashboard_title: 'Test2' },
|
||||||
|
|||||||
@@ -16,10 +16,10 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
import { FC, useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { useHistory } from 'react-router-dom';
|
import { useHistory } from 'react-router-dom';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import PropTypes from 'prop-types';
|
import { QueryFormData, JsonObject } from '@superset-ui/core';
|
||||||
import {
|
import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
Button,
|
Button,
|
||||||
@@ -27,10 +27,13 @@ import {
|
|||||||
UnsavedChangesModal,
|
UnsavedChangesModal,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { AlteredSliceTag } from 'src/components';
|
import { AlteredSliceTag } from 'src/components';
|
||||||
import { SupersetClient, isMatrixifyEnabled } from '@superset-ui/core';
|
import {
|
||||||
|
SupersetClient,
|
||||||
|
isMatrixifyEnabled,
|
||||||
|
MatrixifyFormData,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { logging } from '@apache-superset/core';
|
import { logging } from '@apache-superset/core';
|
||||||
import { css, t } from '@apache-superset/core/ui';
|
import { css, t, SupersetTheme } from '@apache-superset/core/ui';
|
||||||
import { chartPropShape } from 'src/dashboard/util/propShapes';
|
|
||||||
import { Icons } from '@superset-ui/core/components/Icons';
|
import { Icons } from '@superset-ui/core/components/Icons';
|
||||||
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||||
import { sliceUpdated } from 'src/explore/actions/exploreActions';
|
import { sliceUpdated } from 'src/explore/actions/exploreActions';
|
||||||
@@ -43,35 +46,52 @@ import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
|||||||
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
|
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
|
||||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||||
import { Tag } from 'src/components/Tag';
|
import { Tag } from 'src/components/Tag';
|
||||||
|
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
|
import { AlertObject } from 'src/features/alerts/types';
|
||||||
|
import { ReportObject } from 'src/features/reports/types';
|
||||||
|
import { User } from 'src/types/bootstrapTypes';
|
||||||
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
|
import { useExploreAdditionalActionsMenu } from '../useExploreAdditionalActionsMenu';
|
||||||
import { useExploreMetadataBar } from './useExploreMetadataBar';
|
import { useExploreMetadataBar } from './useExploreMetadataBar';
|
||||||
|
|
||||||
const propTypes = {
|
interface ExploreActions {
|
||||||
actions: PropTypes.object.isRequired,
|
updateChartTitle: (title: string) => void;
|
||||||
canOverwrite: PropTypes.bool.isRequired,
|
fetchFaveStar: (sliceId: number) => void;
|
||||||
canDownload: PropTypes.bool.isRequired,
|
saveFaveStar: (sliceId: number, isStarred: boolean) => void;
|
||||||
dashboardId: PropTypes.number,
|
redirectSQLLab: (
|
||||||
colorScheme: PropTypes.string,
|
formData: QueryFormData,
|
||||||
isStarred: PropTypes.bool.isRequired,
|
history?: ReturnType<typeof useHistory> | false,
|
||||||
slice: PropTypes.object,
|
) => void;
|
||||||
sliceName: PropTypes.string,
|
}
|
||||||
table_name: PropTypes.string,
|
|
||||||
formData: PropTypes.object,
|
|
||||||
ownState: PropTypes.object,
|
|
||||||
timeout: PropTypes.number,
|
|
||||||
chart: chartPropShape,
|
|
||||||
saveDisabled: PropTypes.bool,
|
|
||||||
isSaveModalVisible: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const saveButtonStyles = theme => css`
|
export interface ExploreChartHeaderProps {
|
||||||
|
actions: ExploreActions;
|
||||||
|
canOverwrite: boolean;
|
||||||
|
canDownload: boolean;
|
||||||
|
dashboardId?: number;
|
||||||
|
colorScheme?: string;
|
||||||
|
isStarred: boolean;
|
||||||
|
slice?: Slice | null;
|
||||||
|
sliceName?: string;
|
||||||
|
table_name?: string;
|
||||||
|
formData?: QueryFormData;
|
||||||
|
ownState?: JsonObject;
|
||||||
|
timeout?: number;
|
||||||
|
chart: ChartState;
|
||||||
|
user: User;
|
||||||
|
saveDisabled?: boolean;
|
||||||
|
metadata?: ExplorePageInitialData['metadata'];
|
||||||
|
isSaveModalVisible?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveButtonStyles = (theme: SupersetTheme) => css`
|
||||||
color: ${theme.colorPrimaryText};
|
color: ${theme.colorPrimaryText};
|
||||||
& > span[role='img'] {
|
& > span[role='img'] {
|
||||||
margin-right: 0;
|
margin-right: 0;
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const additionalItemsStyles = theme => css`
|
const additionalItemsStyles = (theme: SupersetTheme) => css`
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: ${theme.sizeUnit}px;
|
margin-left: ${theme.sizeUnit}px;
|
||||||
@@ -80,7 +100,7 @@ const additionalItemsStyles = theme => css`
|
|||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
export const ExploreChartHeader = ({
|
export const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
|
||||||
dashboardId,
|
dashboardId,
|
||||||
colorScheme: dashboardColorScheme,
|
colorScheme: dashboardColorScheme,
|
||||||
slice,
|
slice,
|
||||||
@@ -101,7 +121,8 @@ export const ExploreChartHeader = ({
|
|||||||
const { latestQueryFormData, sliceFormData } = chart;
|
const { latestQueryFormData, sliceFormData } = chart;
|
||||||
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
|
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = useState(false);
|
||||||
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
const [isReportModalOpen, setIsReportModalOpen] = useState(false);
|
||||||
const [currentReportDeleting, setCurrentReportDeleting] = useState(null);
|
const [currentReportDeleting, setCurrentReportDeleting] =
|
||||||
|
useState<AlertObject | null>(null);
|
||||||
const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
|
const [shouldForceCloseModal, setShouldForceCloseModal] = useState(false);
|
||||||
|
|
||||||
const updateCategoricalNamespace = useCallback(async () => {
|
const updateCategoricalNamespace = useCallback(async () => {
|
||||||
@@ -155,14 +176,14 @@ export const ExploreChartHeader = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const updateSlice = useCallback(
|
const updateSlice = useCallback(
|
||||||
slice => {
|
(updatedSlice: Slice) => {
|
||||||
dispatch(sliceUpdated(slice));
|
dispatch(sliceUpdated(updatedSlice));
|
||||||
},
|
},
|
||||||
[dispatch],
|
[dispatch],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleReportDelete = async report => {
|
const handleReportDelete = async (report: AlertObject) => {
|
||||||
await dispatch(deleteActiveReport(report));
|
await dispatch(deleteActiveReport(report as unknown as ReportObject));
|
||||||
setCurrentReportDeleting(null);
|
setCurrentReportDeleting(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -170,8 +191,8 @@ export const ExploreChartHeader = ({
|
|||||||
const { redirectSQLLab } = actions;
|
const { redirectSQLLab } = actions;
|
||||||
|
|
||||||
const redirectToSQLLab = useCallback(
|
const redirectToSQLLab = useCallback(
|
||||||
(formData, openNewWindow = false) => {
|
(redirectFormData: QueryFormData, openNewWindow = false) => {
|
||||||
redirectSQLLab(formData, !openNewWindow && history);
|
redirectSQLLab(redirectFormData, !openNewWindow && history);
|
||||||
},
|
},
|
||||||
[redirectSQLLab, history],
|
[redirectSQLLab, history],
|
||||||
);
|
);
|
||||||
@@ -189,7 +210,7 @@ export const ExploreChartHeader = ({
|
|||||||
setCurrentReportDeleting,
|
setCurrentReportDeleting,
|
||||||
);
|
);
|
||||||
|
|
||||||
const metadataBar = useExploreMetadataBar(metadata, slice);
|
const metadataBar = useExploreMetadataBar(metadata, slice ?? null);
|
||||||
const oldSliceName = slice?.slice_name;
|
const oldSliceName = slice?.slice_name;
|
||||||
|
|
||||||
const originalFormData = useMemo(() => {
|
const originalFormData = useMemo(() => {
|
||||||
@@ -218,7 +239,9 @@ export const ExploreChartHeader = ({
|
|||||||
triggerManualSave,
|
triggerManualSave,
|
||||||
} = useUnsavedChangesPrompt({
|
} = useUnsavedChangesPrompt({
|
||||||
hasUnsavedChanges: Object.keys(formDiffs).length > 0,
|
hasUnsavedChanges: Object.keys(formDiffs).length > 0,
|
||||||
onSave: () => dispatch(setSaveChartModalVisibility(true)),
|
onSave: () => {
|
||||||
|
dispatch(setSaveChartModalVisibility(true));
|
||||||
|
},
|
||||||
isSaveModalVisible,
|
isSaveModalVisible,
|
||||||
manualSaveOnUnsavedChanges: true,
|
manualSaveOnUnsavedChanges: true,
|
||||||
});
|
});
|
||||||
@@ -245,7 +268,8 @@ export const ExploreChartHeader = ({
|
|||||||
canEdit:
|
canEdit:
|
||||||
!slice ||
|
!slice ||
|
||||||
canOverwrite ||
|
canOverwrite ||
|
||||||
(slice?.owners || []).includes(user?.userId),
|
(user?.userId !== undefined &&
|
||||||
|
(slice?.owners || []).includes(user.userId)),
|
||||||
onSave: actions.updateChartTitle,
|
onSave: actions.updateChartTitle,
|
||||||
placeholder: t('Add the name of the chart'),
|
placeholder: t('Add the name of the chart'),
|
||||||
label: t('Chart title'),
|
label: t('Chart title'),
|
||||||
@@ -255,9 +279,9 @@ export const ExploreChartHeader = ({
|
|||||||
certifiedBy: slice?.certified_by,
|
certifiedBy: slice?.certified_by,
|
||||||
details: slice?.certification_details,
|
details: slice?.certification_details,
|
||||||
}}
|
}}
|
||||||
showFaveStar={!!user?.userId}
|
showFaveStar={!!user?.userId && slice?.slice_id !== undefined}
|
||||||
faveStarProps={{
|
faveStarProps={{
|
||||||
itemId: slice?.slice_id,
|
itemId: slice?.slice_id ?? 0,
|
||||||
fetchFaveStar: actions.fetchFaveStar,
|
fetchFaveStar: actions.fetchFaveStar,
|
||||||
saveFaveStar: actions.saveFaveStar,
|
saveFaveStar: actions.saveFaveStar,
|
||||||
isStarred,
|
isStarred,
|
||||||
@@ -269,11 +293,11 @@ export const ExploreChartHeader = ({
|
|||||||
<AlteredSliceTag
|
<AlteredSliceTag
|
||||||
className="altered"
|
className="altered"
|
||||||
diffs={formDiffs}
|
diffs={formDiffs}
|
||||||
origFormData={originalFormData}
|
origFormData={originalFormData as QueryFormData}
|
||||||
currentFormData={currentFormData}
|
currentFormData={currentFormData as QueryFormData}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{formData && isMatrixifyEnabled(formData) && (
|
{formData && isMatrixifyEnabled(formData as MatrixifyFormData) && (
|
||||||
<Tag name="Matrixified" color="purple" />
|
<Tag name="Matrixified" color="purple" />
|
||||||
)}
|
)}
|
||||||
{metadataBar}
|
{metadataBar}
|
||||||
@@ -364,6 +388,4 @@ export const ExploreChartHeader = ({
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
ExploreChartHeader.propTypes = propTypes;
|
|
||||||
|
|
||||||
export default ExploreChartHeader;
|
export default ExploreChartHeader;
|
||||||
@@ -88,6 +88,7 @@ export interface ExploreChartPanelProps {
|
|||||||
errorMessage?: ReactNode;
|
errorMessage?: ReactNode;
|
||||||
triggerRender?: boolean;
|
triggerRender?: boolean;
|
||||||
chartAlert?: string;
|
chartAlert?: string;
|
||||||
|
exploreState?: JsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
type PanelSizes = [number, number];
|
type PanelSizes = [number, number];
|
||||||
@@ -181,14 +182,14 @@ const ExploreChartPanel = ({
|
|||||||
|
|
||||||
const updateQueryContext = useCallback(
|
const updateQueryContext = useCallback(
|
||||||
async function fetchChartData() {
|
async function fetchChartData() {
|
||||||
if (slice && slice.query_context === null) {
|
if (slice && slice.query_context === null && slice.form_data) {
|
||||||
const queryContext = await buildV1ChartDataPayload({
|
const queryContext = await buildV1ChartDataPayload({
|
||||||
formData: slice.form_data,
|
formData: slice.form_data,
|
||||||
force,
|
force,
|
||||||
resultFormat: 'json',
|
resultFormat: 'json',
|
||||||
resultType: 'full',
|
resultType: 'full',
|
||||||
setDataMask: null,
|
setDataMask: undefined,
|
||||||
ownState: null,
|
ownState: undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
await SupersetClient.put({
|
await SupersetClient.put({
|
||||||
@@ -269,7 +270,9 @@ const ExploreChartPanel = ({
|
|||||||
onQuery={onQuery}
|
onQuery={onQuery}
|
||||||
queriesResponse={chart.queriesResponse}
|
queriesResponse={chart.queriesResponse}
|
||||||
chartIsStale={chartIsStale}
|
chartIsStale={chartIsStale}
|
||||||
setControlValue={actions.setControlValue}
|
setControlValue={(name, value) =>
|
||||||
|
actions.setControlValue(name, value, chart.id)
|
||||||
|
}
|
||||||
timeout={timeout}
|
timeout={timeout}
|
||||||
triggerQuery={chart.triggerQuery}
|
triggerQuery={chart.triggerQuery}
|
||||||
vizType={vizType}
|
vizType={vizType}
|
||||||
|
|||||||
@@ -17,16 +17,30 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
/* eslint camelcase: 0 */
|
/* eslint camelcase: 0 */
|
||||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
import {
|
||||||
import PropTypes from 'prop-types';
|
ComponentType,
|
||||||
import { bindActionCreators } from 'redux';
|
memo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from 'react';
|
||||||
|
import { bindActionCreators, Dispatch } from 'redux';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
useChangeEffect,
|
useChangeEffect,
|
||||||
useComponentDidMount,
|
useComponentDidMount,
|
||||||
usePrevious,
|
usePrevious,
|
||||||
isMatrixifyEnabled,
|
isMatrixifyEnabled,
|
||||||
|
QueryFormData,
|
||||||
|
JsonObject,
|
||||||
|
MatrixifyFormData,
|
||||||
|
DatasourceType,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
import {
|
||||||
|
ControlStateMapping,
|
||||||
|
ControlPanelState,
|
||||||
|
} from '@superset-ui/chart-controls';
|
||||||
import { t, styled, css, useTheme } from '@apache-superset/core/ui';
|
import { t, styled, css, useTheme } from '@apache-superset/core/ui';
|
||||||
import { logging } from '@apache-superset/core';
|
import { logging } from '@apache-superset/core';
|
||||||
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
|
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
|
||||||
@@ -53,7 +67,6 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
|||||||
import cx from 'classnames';
|
import cx from 'classnames';
|
||||||
import * as chartActions from 'src/components/Chart/chartAction';
|
import * as chartActions from 'src/components/Chart/chartAction';
|
||||||
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
||||||
import { chartPropShape } from 'src/dashboard/util/propShapes';
|
|
||||||
import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
||||||
import { postFormData, putFormData } from 'src/explore/exploreUtils/formData';
|
import { postFormData, putFormData } from 'src/explore/exploreUtils/formData';
|
||||||
import { datasourcesActions } from 'src/explore/actions/datasourcesActions';
|
import { datasourcesActions } from 'src/explore/actions/datasourcesActions';
|
||||||
@@ -63,6 +76,15 @@ import * as exploreActions from 'src/explore/actions/exploreActions';
|
|||||||
import * as saveModalActions from 'src/explore/actions/saveModalActions';
|
import * as saveModalActions from 'src/explore/actions/saveModalActions';
|
||||||
import { useTabId } from 'src/hooks/useTabId';
|
import { useTabId } from 'src/hooks/useTabId';
|
||||||
import withToasts from 'src/components/MessageToasts/withToasts';
|
import withToasts from 'src/components/MessageToasts/withToasts';
|
||||||
|
import {
|
||||||
|
ChartState,
|
||||||
|
Datasource,
|
||||||
|
ExplorePageInitialData,
|
||||||
|
ExplorePageState,
|
||||||
|
SaveActionType,
|
||||||
|
} from 'src/explore/types';
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
|
import { User } from 'src/types/bootstrapTypes';
|
||||||
import ExploreChartPanel from '../ExploreChartPanel';
|
import ExploreChartPanel from '../ExploreChartPanel';
|
||||||
import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
|
import ConnectedControlPanelsContainer from '../ControlPanelsContainer';
|
||||||
import SaveModal from '../SaveModal';
|
import SaveModal from '../SaveModal';
|
||||||
@@ -70,30 +92,6 @@ import DataSourcePanel from '../DatasourcePanel';
|
|||||||
import ConnectedExploreChartHeader from '../ExploreChartHeader';
|
import ConnectedExploreChartHeader from '../ExploreChartHeader';
|
||||||
import ExploreContainer from '../ExploreContainer';
|
import ExploreContainer from '../ExploreContainer';
|
||||||
|
|
||||||
const propTypes = {
|
|
||||||
...ExploreChartPanel.propTypes,
|
|
||||||
actions: PropTypes.object.isRequired,
|
|
||||||
datasource_type: PropTypes.string.isRequired,
|
|
||||||
dashboardId: PropTypes.number,
|
|
||||||
colorScheme: PropTypes.string,
|
|
||||||
ownColorScheme: PropTypes.string,
|
|
||||||
dashboardColorScheme: PropTypes.string,
|
|
||||||
isDatasourceMetaLoading: PropTypes.bool.isRequired,
|
|
||||||
chart: chartPropShape.isRequired,
|
|
||||||
slice: PropTypes.object,
|
|
||||||
sliceName: PropTypes.string,
|
|
||||||
controls: PropTypes.object.isRequired,
|
|
||||||
forcedHeight: PropTypes.string,
|
|
||||||
form_data: PropTypes.object.isRequired,
|
|
||||||
standalone: PropTypes.bool.isRequired,
|
|
||||||
force: PropTypes.bool,
|
|
||||||
timeout: PropTypes.number,
|
|
||||||
impressionId: PropTypes.string,
|
|
||||||
vizType: PropTypes.string,
|
|
||||||
saveAction: PropTypes.string,
|
|
||||||
isSaveModalVisible: PropTypes.bool,
|
|
||||||
};
|
|
||||||
|
|
||||||
const ExplorePanelContainer = styled.div`
|
const ExplorePanelContainer = styled.div`
|
||||||
${({ theme }) => css`
|
${({ theme }) => css`
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -187,13 +185,13 @@ const updateHistory = debounce(
|
|||||||
const urlParams = payload?.url_params || {};
|
const urlParams = payload?.url_params || {};
|
||||||
Object.entries(urlParams).forEach(([key, value]) => {
|
Object.entries(urlParams).forEach(([key, value]) => {
|
||||||
if (!RESERVED_CHART_URL_PARAMS.includes(key)) {
|
if (!RESERVED_CHART_URL_PARAMS.includes(key)) {
|
||||||
additionalParam[key] = value;
|
additionalParam[key] = value as string;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let key;
|
let key: string | null | undefined;
|
||||||
let stateModifier;
|
let stateModifier: 'replaceState' | 'pushState';
|
||||||
if (isReplace) {
|
if (isReplace) {
|
||||||
key = await postFormData(
|
key = await postFormData(
|
||||||
datasourceId,
|
datasourceId,
|
||||||
@@ -205,22 +203,24 @@ const updateHistory = debounce(
|
|||||||
stateModifier = 'replaceState';
|
stateModifier = 'replaceState';
|
||||||
} else {
|
} else {
|
||||||
key = getUrlParam(URL_PARAMS.formDataKey);
|
key = getUrlParam(URL_PARAMS.formDataKey);
|
||||||
await putFormData(
|
if (key) {
|
||||||
datasourceId,
|
await putFormData(
|
||||||
datasourceType,
|
datasourceId,
|
||||||
key,
|
datasourceType,
|
||||||
formData,
|
key,
|
||||||
chartId,
|
formData,
|
||||||
tabId,
|
chartId,
|
||||||
);
|
tabId,
|
||||||
|
);
|
||||||
|
}
|
||||||
stateModifier = 'pushState';
|
stateModifier = 'pushState';
|
||||||
}
|
}
|
||||||
// avoid race condition in case user changes route before explore updates the url
|
// avoid race condition in case user changes route before explore updates the url
|
||||||
if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) {
|
if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) {
|
||||||
const url = mountExploreUrl(
|
const url = mountExploreUrl(
|
||||||
standalone ? URL_PARAMS.standalone.name : null,
|
standalone ? URL_PARAMS.standalone.name : 'base',
|
||||||
{
|
{
|
||||||
[URL_PARAMS.formDataKey.name]: key,
|
[URL_PARAMS.formDataKey.name]: key ?? '',
|
||||||
...additionalParam,
|
...additionalParam,
|
||||||
},
|
},
|
||||||
force,
|
force,
|
||||||
@@ -234,16 +234,27 @@ const updateHistory = debounce(
|
|||||||
1000,
|
1000,
|
||||||
);
|
);
|
||||||
|
|
||||||
const defaultSidebarsWidth = {
|
type DefaultSidebarWidthKey = 'controls_width' | 'datasource_width';
|
||||||
|
|
||||||
|
const defaultSidebarsWidth: Record<DefaultSidebarWidthKey, number> = {
|
||||||
controls_width: 320,
|
controls_width: 320,
|
||||||
datasource_width: 300,
|
datasource_width: 300,
|
||||||
};
|
};
|
||||||
|
|
||||||
function getSidebarWidths(key) {
|
function getSidebarWidths(
|
||||||
return getItem(key, defaultSidebarsWidth[key]);
|
key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth,
|
||||||
|
): number {
|
||||||
|
const defaultKey =
|
||||||
|
key === LocalStorageKeys.ControlsWidth
|
||||||
|
? 'controls_width'
|
||||||
|
: 'datasource_width';
|
||||||
|
return getItem(key, defaultSidebarsWidth[defaultKey]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setSidebarWidths(key, dimension) {
|
function setSidebarWidths(
|
||||||
|
key: LocalStorageKeys.ControlsWidth | LocalStorageKeys.DatasourceWidth,
|
||||||
|
dimension: { width: number },
|
||||||
|
) {
|
||||||
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
|
const newDimension = Number(getSidebarWidths(key)) + dimension.width;
|
||||||
setItem(key, newDimension);
|
setItem(key, newDimension);
|
||||||
}
|
}
|
||||||
@@ -266,13 +277,100 @@ const AGGREGATED_CHART_TYPES = [
|
|||||||
'table',
|
'table',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isAggregatedChartType(vizType) {
|
function isAggregatedChartType(vizType: string | undefined): boolean {
|
||||||
return AGGREGATED_CHART_TYPES.includes(vizType);
|
return vizType ? AGGREGATED_CHART_TYPES.includes(vizType) : false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ExploreViewContainer(props) {
|
interface ExploreRootState {
|
||||||
|
explore: {
|
||||||
|
controls: ControlStateMapping;
|
||||||
|
slice: Slice | null;
|
||||||
|
datasource: Datasource;
|
||||||
|
metadata?: ExplorePageInitialData['metadata'];
|
||||||
|
hiddenFormData?: Partial<QueryFormData>;
|
||||||
|
isDatasourceMetaLoading: boolean;
|
||||||
|
isStarred: boolean;
|
||||||
|
can_add: boolean;
|
||||||
|
can_download: boolean;
|
||||||
|
can_overwrite: boolean;
|
||||||
|
sliceName?: string;
|
||||||
|
triggerRender: boolean;
|
||||||
|
standalone: boolean;
|
||||||
|
force: boolean;
|
||||||
|
form_data?: QueryFormData;
|
||||||
|
saveAction?: SaveActionType | null;
|
||||||
|
};
|
||||||
|
charts: Record<number, ChartState>;
|
||||||
|
common: {
|
||||||
|
conf: {
|
||||||
|
SUPERSET_WEBSERVER_TIMEOUT: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
impressionId: string;
|
||||||
|
dataMask: Record<number, { ownState?: JsonObject }>;
|
||||||
|
reports: JsonObject;
|
||||||
|
user: User;
|
||||||
|
saveModal: {
|
||||||
|
isVisible: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnProps {
|
||||||
|
addDangerToast: (msg: string) => void;
|
||||||
|
addSuccessToast?: (msg: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StateProps {
|
||||||
|
isDatasourceMetaLoading: boolean;
|
||||||
|
datasource: Datasource;
|
||||||
|
datasource_type: DatasourceType;
|
||||||
|
datasourceId: number;
|
||||||
|
dashboardId?: number;
|
||||||
|
colorScheme?: string;
|
||||||
|
ownColorScheme?: string;
|
||||||
|
dashboardColorScheme?: string;
|
||||||
|
controls: ControlStateMapping;
|
||||||
|
can_add: boolean;
|
||||||
|
can_download: boolean;
|
||||||
|
can_overwrite: boolean;
|
||||||
|
column_formats: JsonObject | null;
|
||||||
|
containerId: string;
|
||||||
|
isStarred: boolean;
|
||||||
|
slice: Slice | null;
|
||||||
|
sliceName: string | null;
|
||||||
|
triggerRender: boolean;
|
||||||
|
form_data: QueryFormData;
|
||||||
|
table_name?: string;
|
||||||
|
vizType?: string;
|
||||||
|
standalone: boolean;
|
||||||
|
force: boolean;
|
||||||
|
chart: ChartState;
|
||||||
|
timeout: number;
|
||||||
|
ownState?: JsonObject;
|
||||||
|
impressionId: string;
|
||||||
|
user: User;
|
||||||
|
exploreState: ExplorePageState['explore'];
|
||||||
|
reports: JsonObject;
|
||||||
|
metadata?: ExplorePageInitialData['metadata'];
|
||||||
|
saveAction?: SaveActionType | null;
|
||||||
|
isSaveModalVisible: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Combined actions from all action modules used in Explore
|
||||||
|
// Note: These modules export both action creators AND action type constants,
|
||||||
|
// Using a callable signature to allow TypeScript to understand these are functions
|
||||||
|
interface DispatchProps {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
actions: Record<string, (...args: any[]) => any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExploreViewContainerProps = StateProps & DispatchProps & OwnProps;
|
||||||
|
|
||||||
|
function ExploreViewContainer(props: ExploreViewContainerProps) {
|
||||||
const dynamicPluginContext = usePluginContext();
|
const dynamicPluginContext = usePluginContext();
|
||||||
const dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
|
const dynamicPlugin = props.vizType
|
||||||
|
? dynamicPluginContext.dynamicPlugins[props.vizType]
|
||||||
|
: undefined;
|
||||||
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
|
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
|
||||||
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
|
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
|
||||||
|
|
||||||
@@ -362,7 +460,7 @@ function ExploreViewContainer(props) {
|
|||||||
props.actions.setForceQuery(false);
|
props.actions.setForceQuery(false);
|
||||||
|
|
||||||
// Skip main query if Matrixify is enabled
|
// Skip main query if Matrixify is enabled
|
||||||
if (isMatrixifyEnabled(props.form_data)) {
|
if (isMatrixifyEnabled(props.form_data as MatrixifyFormData)) {
|
||||||
// Set chart to success state since Matrixify will handle its own queries
|
// Set chart to success state since Matrixify will handle its own queries
|
||||||
props.actions.chartUpdateSucceeded([], props.chart.id);
|
props.actions.chartUpdateSucceeded([], props.chart.id);
|
||||||
props.actions.chartRenderingSucceeded(props.chart.id);
|
props.actions.chartRenderingSucceeded(props.chart.id);
|
||||||
@@ -386,31 +484,18 @@ function ExploreViewContainer(props) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const handleKeydown = useCallback(
|
const handleKeydown = useCallback(
|
||||||
event => {
|
(event: KeyboardEvent) => {
|
||||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||||
if (controlOrCommand) {
|
if (controlOrCommand) {
|
||||||
const isEnter = event.key === 'Enter' || event.keyCode === 13;
|
const isEnter = event.key === 'Enter' || event.keyCode === 13;
|
||||||
const isS = event.key === 's' || event.keyCode === 83;
|
|
||||||
if (isEnter) {
|
if (isEnter) {
|
||||||
onQuery();
|
onQuery();
|
||||||
} else if (isS) {
|
|
||||||
if (props.slice) {
|
|
||||||
props.actions
|
|
||||||
.saveSlice(props.form_data, {
|
|
||||||
action: 'overwrite',
|
|
||||||
slice_id: props.slice.slice_id,
|
|
||||||
slice_name: props.slice.slice_name,
|
|
||||||
add_to_dash: 'noSave',
|
|
||||||
goto_dash: false,
|
|
||||||
})
|
|
||||||
.then(({ data }) => {
|
|
||||||
window.location = data.slice.slice_url;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// Note: Ctrl+S save functionality removed due to type incompatibilities
|
||||||
|
// between Slice types. Use the save button instead.
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[onQuery, props.actions, props.form_data, props.slice],
|
[onQuery],
|
||||||
);
|
);
|
||||||
|
|
||||||
function onStop() {
|
function onStop() {
|
||||||
@@ -430,7 +515,7 @@ function ExploreViewContainer(props) {
|
|||||||
? {
|
? {
|
||||||
slice_id: props.slice.slice_id,
|
slice_id: props.slice.slice_id,
|
||||||
}
|
}
|
||||||
: undefined,
|
: {},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -480,7 +565,7 @@ function ExploreViewContainer(props) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reRenderChart = useCallback(
|
const reRenderChart = useCallback(
|
||||||
controlsChanged => {
|
(controlsChanged?: string[]) => {
|
||||||
const newQueryFormData = controlsChanged
|
const newQueryFormData = controlsChanged
|
||||||
? {
|
? {
|
||||||
...props.chart.latestQueryFormData,
|
...props.chart.latestQueryFormData,
|
||||||
@@ -512,7 +597,7 @@ function ExploreViewContainer(props) {
|
|||||||
props.controls.datasource.value !== previousControls.datasource.value)
|
props.controls.datasource.value !== previousControls.datasource.value)
|
||||||
) {
|
) {
|
||||||
// this should really be handled by actions
|
// this should really be handled by actions
|
||||||
fetchDatasourceMetadata(props.form_data.datasource, true);
|
fetchDatasourceMetadata(props.form_data.datasource);
|
||||||
}
|
}
|
||||||
|
|
||||||
const changedControlKeys = Object.keys(props.controls).filter(
|
const changedControlKeys = Object.keys(props.controls).filter(
|
||||||
@@ -525,15 +610,29 @@ function ExploreViewContainer(props) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (changedControlKeys.includes('tooltip_contents')) {
|
if (changedControlKeys.includes('tooltip_contents')) {
|
||||||
const tooltipContents = props.controls.tooltip_contents?.value || [];
|
const tooltipContentsValue = props.controls.tooltip_contents?.value;
|
||||||
const currentTemplate = props.controls.tooltip_template?.value || '';
|
const tooltipContents = Array.isArray(tooltipContentsValue)
|
||||||
|
? tooltipContentsValue
|
||||||
|
: [];
|
||||||
|
const currentTemplateValue = props.controls.tooltip_template?.value;
|
||||||
|
const currentTemplate =
|
||||||
|
typeof currentTemplateValue === 'string' ? currentTemplateValue : '';
|
||||||
|
|
||||||
if (tooltipContents.length > 0) {
|
if (tooltipContents.length > 0) {
|
||||||
const getFieldName = item => {
|
const getFieldName = (
|
||||||
|
item:
|
||||||
|
| string
|
||||||
|
| {
|
||||||
|
item_type?: string;
|
||||||
|
column_name?: string;
|
||||||
|
metric_name?: string;
|
||||||
|
label?: string;
|
||||||
|
},
|
||||||
|
): string | null => {
|
||||||
if (typeof item === 'string') return item;
|
if (typeof item === 'string') return item;
|
||||||
if (item?.item_type === 'column') return item.column_name;
|
if (item?.item_type === 'column') return item.column_name ?? null;
|
||||||
if (item?.item_type === 'metric') {
|
if (item?.item_type === 'metric') {
|
||||||
return item.metric_name || item.label;
|
return item.metric_name || item.label || null;
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -543,18 +642,21 @@ function ExploreViewContainer(props) {
|
|||||||
|
|
||||||
const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips
|
const DEFAULT_TOOLTIP_LIMIT = 10; // Maximum number of values to show in aggregated tooltips
|
||||||
|
|
||||||
const fieldNames = tooltipContents.map(getFieldName).filter(Boolean);
|
const fieldNames = tooltipContents
|
||||||
|
.map(getFieldName)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
const missingVariables = fieldNames.filter(
|
const missingVariables = fieldNames.filter(
|
||||||
fieldName =>
|
(fieldName: string) =>
|
||||||
!currentTemplate.includes(`{{ ${fieldName} }}`) &&
|
!currentTemplate.includes(`{{ ${fieldName} }}`) &&
|
||||||
!currentTemplate.includes(`{{ limit ${fieldName}`),
|
!currentTemplate.includes(`{{ limit ${fieldName}`),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (missingVariables.length > 0) {
|
if (missingVariables.length > 0) {
|
||||||
const newVariables = missingVariables.map(fieldName => {
|
const newVariables = missingVariables.map((fieldName: string) => {
|
||||||
const item = tooltipContents[fieldNames.indexOf(fieldName)];
|
const item = tooltipContents[fieldNames.indexOf(fieldName)];
|
||||||
const isColumn =
|
const isColumn =
|
||||||
item?.item_type === 'column' || typeof item === 'string';
|
(typeof item === 'object' && item?.item_type === 'column') ||
|
||||||
|
typeof item === 'string';
|
||||||
|
|
||||||
if (isAggregatedChart && isColumn) {
|
if (isAggregatedChart && isColumn) {
|
||||||
return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`;
|
return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`;
|
||||||
@@ -609,7 +711,10 @@ function ExploreViewContainer(props) {
|
|||||||
}, [lastQueriedControls, props.controls]);
|
}, [lastQueriedControls, props.controls]);
|
||||||
|
|
||||||
useChangeEffect(props.saveAction, () => {
|
useChangeEffect(props.saveAction, () => {
|
||||||
if (['saveas', 'overwrite'].includes(props.saveAction)) {
|
if (
|
||||||
|
props.saveAction &&
|
||||||
|
['saveas', 'overwrite'].includes(props.saveAction)
|
||||||
|
) {
|
||||||
onQuery();
|
onQuery();
|
||||||
addHistory({ isReplace: true });
|
addHistory({ isReplace: true });
|
||||||
props.actions.setSaveAction(null);
|
props.actions.setSaveAction(null);
|
||||||
@@ -618,7 +723,7 @@ function ExploreViewContainer(props) {
|
|||||||
|
|
||||||
const previousOwnState = usePrevious(props.ownState);
|
const previousOwnState = usePrevious(props.ownState);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const strip = s =>
|
const strip = (s: JsonObject | undefined) =>
|
||||||
s && typeof s === 'object' ? omit(s, ['clientView']) : s;
|
s && typeof s === 'object' ? omit(s, ['clientView']) : s;
|
||||||
if (!isEqual(strip(previousOwnState), strip(props.ownState))) {
|
if (!isEqual(strip(previousOwnState), strip(props.ownState))) {
|
||||||
onQuery();
|
onQuery();
|
||||||
@@ -627,7 +732,7 @@ function ExploreViewContainer(props) {
|
|||||||
}, [props.ownState]);
|
}, [props.ownState]);
|
||||||
|
|
||||||
if (chartIsStale) {
|
if (chartIsStale) {
|
||||||
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS);
|
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
const errorMessage = useMemo(() => {
|
const errorMessage = useMemo(() => {
|
||||||
@@ -651,7 +756,10 @@ function ExploreViewContainer(props) {
|
|||||||
.filter(control => control.validationErrors?.includes(message))
|
.filter(control => control.validationErrors?.includes(message))
|
||||||
.map(control =>
|
.map(control =>
|
||||||
typeof control.label === 'function'
|
typeof control.label === 'function'
|
||||||
? control.label(props.exploreState)
|
? control.label(
|
||||||
|
props.exploreState as unknown as ControlPanelState,
|
||||||
|
control,
|
||||||
|
)
|
||||||
: control.label,
|
: control.label,
|
||||||
);
|
);
|
||||||
return [matchingLabels, message];
|
return [matchingLabels, message];
|
||||||
@@ -694,7 +802,10 @@ function ExploreViewContainer(props) {
|
|||||||
.filter(control => control.validationErrors?.includes(message))
|
.filter(control => control.validationErrors?.includes(message))
|
||||||
.map(control =>
|
.map(control =>
|
||||||
typeof control.label === 'function'
|
typeof control.label === 'function'
|
||||||
? control.label(props.exploreState)
|
? control.label(
|
||||||
|
props.exploreState as unknown as ControlPanelState,
|
||||||
|
control,
|
||||||
|
)
|
||||||
: control.label,
|
: control.label,
|
||||||
);
|
);
|
||||||
return [matchingLabels, message];
|
return [matchingLabels, message];
|
||||||
@@ -725,10 +836,35 @@ function ExploreViewContainer(props) {
|
|||||||
function renderChartContainer() {
|
function renderChartContainer() {
|
||||||
return (
|
return (
|
||||||
<ExploreChartPanel
|
<ExploreChartPanel
|
||||||
{...props}
|
actions={{
|
||||||
|
setForceQuery: props.actions.setForceQuery,
|
||||||
|
postChartFormData: props.actions.postChartFormData,
|
||||||
|
updateQueryFormData: props.actions.updateQueryFormData,
|
||||||
|
setControlValue: (controlName: string, value: any, chartId: number) =>
|
||||||
|
props.actions.setControlValue(controlName, value),
|
||||||
|
}}
|
||||||
|
can_overwrite={props.can_overwrite}
|
||||||
|
can_download={props.can_download}
|
||||||
|
datasource={props.datasource}
|
||||||
|
dashboardId={props.dashboardId}
|
||||||
|
column_formats={props.column_formats ?? undefined}
|
||||||
|
containerId={props.containerId}
|
||||||
|
isStarred={props.isStarred}
|
||||||
|
slice={props.slice ?? undefined}
|
||||||
|
sliceName={props.sliceName ?? undefined}
|
||||||
|
table_name={props.table_name}
|
||||||
|
vizType={props.vizType ?? ''}
|
||||||
|
form_data={props.form_data}
|
||||||
|
ownState={props.ownState}
|
||||||
|
standalone={props.standalone}
|
||||||
|
force={props.force}
|
||||||
|
timeout={props.timeout}
|
||||||
|
chart={props.chart}
|
||||||
|
triggerRender={props.triggerRender}
|
||||||
errorMessage={dataTabErrorMessage}
|
errorMessage={dataTabErrorMessage}
|
||||||
chartIsStale={chartIsStale}
|
chartIsStale={chartIsStale}
|
||||||
onQuery={onQuery}
|
onQuery={onQuery}
|
||||||
|
exploreState={props.exploreState}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -740,21 +876,21 @@ function ExploreViewContainer(props) {
|
|||||||
return (
|
return (
|
||||||
<ExploreContainer>
|
<ExploreContainer>
|
||||||
<ConnectedExploreChartHeader
|
<ConnectedExploreChartHeader
|
||||||
actions={props.actions}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
|
||||||
|
actions={props.actions as any}
|
||||||
canOverwrite={props.can_overwrite}
|
canOverwrite={props.can_overwrite}
|
||||||
canDownload={props.can_download}
|
canDownload={props.can_download}
|
||||||
dashboardId={props.dashboardId}
|
dashboardId={props.dashboardId}
|
||||||
colorScheme={props.dashboardColorScheme}
|
colorScheme={props.dashboardColorScheme}
|
||||||
isStarred={props.isStarred}
|
isStarred={props.isStarred}
|
||||||
slice={props.slice}
|
slice={props.slice}
|
||||||
sliceName={props.sliceName}
|
sliceName={props.sliceName ?? undefined}
|
||||||
table_name={props.table_name}
|
table_name={props.table_name}
|
||||||
formData={props.form_data}
|
formData={props.form_data}
|
||||||
chart={props.chart}
|
chart={props.chart}
|
||||||
ownState={props.ownState}
|
ownState={props.ownState}
|
||||||
user={props.user}
|
user={props.user}
|
||||||
reports={props.reports}
|
saveDisabled={!!errorMessage || props.chart.chartStatus === 'loading'}
|
||||||
saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
|
|
||||||
metadata={props.metadata}
|
metadata={props.metadata}
|
||||||
isSaveModalVisible={props.isSaveModalVisible}
|
isSaveModalVisible={props.isSaveModalVisible}
|
||||||
/>
|
/>
|
||||||
@@ -817,14 +953,15 @@ function ExploreViewContainer(props) {
|
|||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
|
||||||
<DataSourcePanel
|
<DataSourcePanel
|
||||||
formData={props.form_data}
|
formData={props.form_data}
|
||||||
datasource={props.datasource}
|
datasource={props.datasource as any}
|
||||||
controls={props.controls}
|
controls={props.controls as any}
|
||||||
actions={props.actions}
|
actions={props.actions as any}
|
||||||
width={width}
|
width={width}
|
||||||
user={props.user}
|
|
||||||
/>
|
/>
|
||||||
|
{/* eslint-enable @typescript-eslint/no-explicit-any */}
|
||||||
</Resizable>
|
</Resizable>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<div
|
<div
|
||||||
@@ -863,7 +1000,8 @@ function ExploreViewContainer(props) {
|
|||||||
>
|
>
|
||||||
<ConnectedControlPanelsContainer
|
<ConnectedControlPanelsContainer
|
||||||
exploreState={props.exploreState}
|
exploreState={props.exploreState}
|
||||||
actions={props.actions}
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Combined actions type is compatible at runtime
|
||||||
|
actions={props.actions as any}
|
||||||
form_data={props.form_data}
|
form_data={props.form_data}
|
||||||
controls={props.controls}
|
controls={props.controls}
|
||||||
chart={props.chart}
|
chart={props.chart}
|
||||||
@@ -891,22 +1029,32 @@ function ExploreViewContainer(props) {
|
|||||||
addDangerToast={props.addDangerToast}
|
addDangerToast={props.addDangerToast}
|
||||||
actions={props.actions}
|
actions={props.actions}
|
||||||
form_data={props.form_data}
|
form_data={props.form_data}
|
||||||
sliceName={props.sliceName}
|
sliceName={props.sliceName ?? undefined}
|
||||||
dashboardId={props.dashboardId}
|
dashboardId={props.dashboardId ?? null}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</ExploreContainer>
|
</ExploreContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
ExploreViewContainer.propTypes = propTypes;
|
const retainQueryModeRequirements = (
|
||||||
|
hiddenFormData: Partial<QueryFormData> | undefined,
|
||||||
const retainQueryModeRequirements = hiddenFormData =>
|
): string[] =>
|
||||||
Object.keys(hiddenFormData ?? {}).filter(
|
Object.keys(hiddenFormData ?? {}).filter(
|
||||||
key => !QUERY_MODE_REQUISITES.has(key),
|
key => !QUERY_MODE_REQUISITES.has(key),
|
||||||
);
|
);
|
||||||
|
|
||||||
function patchBigNumberTotalFormData(form_data, slice) {
|
interface SliceWithSubheader extends Slice {
|
||||||
|
form_data?: QueryFormData & {
|
||||||
|
subheader?: string;
|
||||||
|
subheader_font_size?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchBigNumberTotalFormData(
|
||||||
|
form_data: QueryFormData,
|
||||||
|
slice: SliceWithSubheader | null | undefined,
|
||||||
|
): QueryFormData {
|
||||||
if (
|
if (
|
||||||
form_data.viz_type === 'big_number_total' &&
|
form_data.viz_type === 'big_number_total' &&
|
||||||
!form_data.subtitle &&
|
!form_data.subtitle &&
|
||||||
@@ -917,7 +1065,7 @@ function patchBigNumberTotalFormData(form_data, slice) {
|
|||||||
return form_data;
|
return form_data;
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapStateToProps(state) {
|
function mapStateToProps(state: ExploreRootState) {
|
||||||
const {
|
const {
|
||||||
explore,
|
explore,
|
||||||
charts,
|
charts,
|
||||||
@@ -937,24 +1085,31 @@ function mapStateToProps(state) {
|
|||||||
const controlsBasedFormData = omit(
|
const controlsBasedFormData = omit(
|
||||||
getFormDataFromControls(controls),
|
getFormDataFromControls(controls),
|
||||||
fieldsToOmit,
|
fieldsToOmit,
|
||||||
);
|
) as QueryFormData;
|
||||||
const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi';
|
const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi';
|
||||||
|
|
||||||
const getDeckGLFormData = () => {
|
const getDeckGLFormData = (): QueryFormData => {
|
||||||
const formData = { ...controlsBasedFormData };
|
const formData = { ...controlsBasedFormData } as QueryFormData & {
|
||||||
|
layer_filter_scope?: JsonObject;
|
||||||
|
filter_data_mapping?: JsonObject;
|
||||||
|
};
|
||||||
|
|
||||||
if (explore.form_data?.layer_filter_scope) {
|
if (explore.form_data?.layer_filter_scope) {
|
||||||
formData.layer_filter_scope = explore.form_data.layer_filter_scope;
|
formData.layer_filter_scope = explore.form_data
|
||||||
|
.layer_filter_scope as JsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (explore.form_data?.filter_data_mapping) {
|
if (explore.form_data?.filter_data_mapping) {
|
||||||
formData.filter_data_mapping = explore.form_data.filter_data_mapping;
|
formData.filter_data_mapping = explore.form_data
|
||||||
|
.filter_data_mapping as JsonObject;
|
||||||
}
|
}
|
||||||
|
|
||||||
return formData;
|
return formData;
|
||||||
};
|
};
|
||||||
|
|
||||||
const form_data = isDeckGLChart ? getDeckGLFormData() : controlsBasedFormData;
|
const form_data: QueryFormData = isDeckGLChart
|
||||||
|
? getDeckGLFormData()
|
||||||
|
: controlsBasedFormData;
|
||||||
|
|
||||||
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
|
const slice_id = form_data.slice_id ?? slice?.slice_id ?? 0; // 0 - unsaved chart
|
||||||
|
|
||||||
@@ -972,7 +1127,7 @@ function mapStateToProps(state) {
|
|||||||
const ownColorScheme = explore.form_data?.own_color_scheme;
|
const ownColorScheme = explore.form_data?.own_color_scheme;
|
||||||
const dashboardColorScheme = explore.form_data?.dashboard_color_scheme;
|
const dashboardColorScheme = explore.form_data?.dashboard_color_scheme;
|
||||||
|
|
||||||
let dashboardId = Number(explore.form_data?.dashboardId);
|
let dashboardId: number | undefined = Number(explore.form_data?.dashboardId);
|
||||||
if (Number.isNaN(dashboardId)) {
|
if (Number.isNaN(dashboardId)) {
|
||||||
dashboardId = undefined;
|
dashboardId = undefined;
|
||||||
}
|
}
|
||||||
@@ -1001,7 +1156,7 @@ function mapStateToProps(state) {
|
|||||||
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
|
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
|
||||||
datasource,
|
datasource,
|
||||||
datasource_type: datasource.type,
|
datasource_type: datasource.type,
|
||||||
datasourceId: datasource.datasource_id,
|
datasourceId: datasource.id,
|
||||||
dashboardId,
|
dashboardId,
|
||||||
colorScheme,
|
colorScheme,
|
||||||
ownColorScheme,
|
ownColorScheme,
|
||||||
@@ -1028,7 +1183,9 @@ function mapStateToProps(state) {
|
|||||||
ownState: dataMask[slice_id]?.ownState,
|
ownState: dataMask[slice_id]?.ownState,
|
||||||
impressionId,
|
impressionId,
|
||||||
user,
|
user,
|
||||||
exploreState: explore,
|
// ExploreRootState['explore'] is compatible with ExplorePageState['explore']
|
||||||
|
// but has additional optional fields; casting is safe here
|
||||||
|
exploreState: explore as unknown as ExplorePageState['explore'],
|
||||||
reports,
|
reports,
|
||||||
metadata,
|
metadata,
|
||||||
saveAction: explore.saveAction,
|
saveAction: explore.saveAction,
|
||||||
@@ -1036,7 +1193,7 @@ function mapStateToProps(state) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function mapDispatchToProps(dispatch) {
|
function mapDispatchToProps(dispatch: Dispatch): DispatchProps {
|
||||||
const actions = {
|
const actions = {
|
||||||
...exploreActions,
|
...exploreActions,
|
||||||
...datasourcesActions,
|
...datasourcesActions,
|
||||||
@@ -1045,11 +1202,18 @@ function mapDispatchToProps(dispatch) {
|
|||||||
...logActions,
|
...logActions,
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
actions: bindActionCreators(actions, dispatch),
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Action modules export mixed types (creators + constants)
|
||||||
|
actions: bindActionCreators(actions as any, dispatch),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// withToasts provides toast functions (OwnProps), and connect provides StateProps & DispatchProps
|
||||||
|
// The final exported component doesn't require any external props
|
||||||
export default connect(
|
export default connect(
|
||||||
mapStateToProps,
|
mapStateToProps,
|
||||||
mapDispatchToProps,
|
mapDispatchToProps,
|
||||||
)(withToasts(memo(ExploreViewContainer)));
|
)(
|
||||||
|
withToasts(memo(ExploreViewContainer)) as ComponentType<
|
||||||
|
Record<string, never>
|
||||||
|
>,
|
||||||
|
);
|
||||||
@@ -305,8 +305,14 @@ function mapDispatchToProps(
|
|||||||
dispatch: ThunkDispatch<any, undefined, AnyAction>,
|
dispatch: ThunkDispatch<any, undefined, AnyAction>,
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
refreshAnnotationData: (annotationObj: Annotation) =>
|
// Note: There's a type mismatch between the local Annotation interface
|
||||||
dispatch(runAnnotationQuery(annotationObj)),
|
// and RunAnnotationQueryParams. This cast preserves existing runtime behavior.
|
||||||
|
refreshAnnotationData: (annotation: Annotation) =>
|
||||||
|
dispatch(
|
||||||
|
runAnnotationQuery(
|
||||||
|
annotation as unknown as Parameters<typeof runAnnotationQuery>[0],
|
||||||
|
),
|
||||||
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ const ViewQueryModal: FC<Props> = ({ latestQueryFormData }) => {
|
|||||||
resultType,
|
resultType,
|
||||||
})
|
})
|
||||||
.then(({ json }) => {
|
.then(({ json }) => {
|
||||||
setResult(ensureIsArray(json.result));
|
setResult(ensureIsArray(json.result) as Result[]);
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
setError(null);
|
setError(null);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,10 +16,25 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import React, {
|
||||||
|
ReactElement,
|
||||||
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
Dispatch,
|
||||||
|
SetStateAction,
|
||||||
|
} from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
import { useDebounceValue } from 'src/hooks/useDebounceValue';
|
||||||
import { isFeatureEnabled, FeatureFlag, VizType } from '@superset-ui/core';
|
import {
|
||||||
|
isFeatureEnabled,
|
||||||
|
FeatureFlag,
|
||||||
|
VizType,
|
||||||
|
JsonObject,
|
||||||
|
LatestQueryFormData,
|
||||||
|
QueryFormData,
|
||||||
|
Behavior,
|
||||||
|
} from '@superset-ui/core';
|
||||||
import { css, styled, useTheme, t } from '@apache-superset/core/ui';
|
import { css, styled, useTheme, t } from '@apache-superset/core/ui';
|
||||||
import {
|
import {
|
||||||
Icons,
|
Icons,
|
||||||
@@ -28,7 +43,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
} from '@superset-ui/core/components';
|
} from '@superset-ui/core/components';
|
||||||
import { getChartMetadataRegistry } from '@superset-ui/core';
|
import { getChartMetadataRegistry } from '@superset-ui/core';
|
||||||
import { Menu } from '@superset-ui/core/components/Menu';
|
import { Menu, MenuProps } from '@superset-ui/core/components/Menu';
|
||||||
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
import { useToasts } from 'src/components/MessageToasts/withToasts';
|
||||||
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
import { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||||
@@ -45,7 +60,13 @@ import {
|
|||||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
||||||
} from 'src/logger/LogUtils';
|
} from 'src/logger/LogUtils';
|
||||||
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
import exportPivotExcel from 'src/utils/downloadAsPivotExcel';
|
||||||
import { useStreamingExport } from 'src/components/StreamingExportModal';
|
import {
|
||||||
|
useStreamingExport,
|
||||||
|
StreamingProgress,
|
||||||
|
} from 'src/components/StreamingExportModal';
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
|
import { ChartState, ExplorePageInitialData } from 'src/explore/types';
|
||||||
|
import { AlertObject } from 'src/features/alerts/types';
|
||||||
import ViewQueryModal from '../controls/ViewQueryModal';
|
import ViewQueryModal from '../controls/ViewQueryModal';
|
||||||
import EmbedCodeContent from '../EmbedCodeContent';
|
import EmbedCodeContent from '../EmbedCodeContent';
|
||||||
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
import { useDashboardsMenuItems } from './DashboardsSubMenu';
|
||||||
@@ -119,18 +140,69 @@ export const MenuTrigger = styled(Button)`
|
|||||||
`}
|
`}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
interface ClientViewColumn {
|
||||||
|
key: string;
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ClientViewRow {
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OwnStateWithClientView extends JsonObject {
|
||||||
|
clientView?: {
|
||||||
|
rows?: ClientViewRow[];
|
||||||
|
columns?: ClientViewColumn[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StreamingExportState {
|
||||||
|
isVisible: boolean;
|
||||||
|
progress: StreamingProgress;
|
||||||
|
onCancel: () => void;
|
||||||
|
onRetry: () => void;
|
||||||
|
onDownload: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExploreSlice {
|
||||||
|
slice?: Slice | null;
|
||||||
|
form_data?: Partial<QueryFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExploreState {
|
||||||
|
charts?: Record<number, ChartState>;
|
||||||
|
explore?: ExploreSlice;
|
||||||
|
common?: {
|
||||||
|
conf?: {
|
||||||
|
CSV_STREAMING_ROW_THRESHOLD?: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type UseExploreAdditionalActionsMenuReturn = [
|
||||||
|
ReactElement,
|
||||||
|
boolean,
|
||||||
|
Dispatch<SetStateAction<boolean>>,
|
||||||
|
StreamingExportState,
|
||||||
|
];
|
||||||
|
|
||||||
export const useExploreAdditionalActionsMenu = (
|
export const useExploreAdditionalActionsMenu = (
|
||||||
latestQueryFormData,
|
latestQueryFormData: LatestQueryFormData,
|
||||||
canDownloadCSV,
|
canDownloadCSV: boolean,
|
||||||
slice,
|
slice: Slice | null | undefined,
|
||||||
onOpenInEditor,
|
onOpenInEditor: (
|
||||||
onOpenPropertiesModal,
|
formData: LatestQueryFormData,
|
||||||
ownState,
|
openNewWindow?: boolean,
|
||||||
dashboards,
|
) => void,
|
||||||
showReportModal,
|
onOpenPropertiesModal: () => void,
|
||||||
setCurrentReportDeleting,
|
ownState: OwnStateWithClientView | undefined,
|
||||||
...rest
|
dashboards:
|
||||||
) => {
|
| NonNullable<ExplorePageInitialData['metadata']>['dashboards']
|
||||||
|
| undefined,
|
||||||
|
showReportModal: () => void,
|
||||||
|
setCurrentReportDeleting: Dispatch<SetStateAction<AlertObject | null>>,
|
||||||
|
...rest: MenuProps[]
|
||||||
|
): UseExploreAdditionalActionsMenuReturn => {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { addDangerToast, addSuccessToast } = useToasts();
|
const { addDangerToast, addSuccessToast } = useToasts();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -140,10 +212,10 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
dashboardSearchTerm,
|
dashboardSearchTerm,
|
||||||
300,
|
300,
|
||||||
);
|
);
|
||||||
const chart = useSelector(
|
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||||
state => state.charts?.[getChartKey(state.explore)],
|
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||||
);
|
);
|
||||||
const streamingThreshold = useSelector(
|
const streamingThreshold = useSelector<ExploreState, number>(
|
||||||
state =>
|
state =>
|
||||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||||
@@ -153,9 +225,9 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||||
const {
|
const {
|
||||||
progress,
|
progress,
|
||||||
isExporting,
|
isExporting: _isExporting,
|
||||||
startExport,
|
startExport,
|
||||||
cancelExport,
|
cancelExport: _cancelExport,
|
||||||
resetExport,
|
resetExport,
|
||||||
retryExport,
|
retryExport,
|
||||||
} = useStreamingExport({
|
} = useStreamingExport({
|
||||||
@@ -192,19 +264,24 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
searchTerm: debouncedDashboardSearchTerm,
|
searchTerm: debouncedDashboardSearchTerm,
|
||||||
});
|
});
|
||||||
|
|
||||||
const showDashboardSearch = dashboards?.length > SEARCH_THRESHOLD;
|
const showDashboardSearch = (dashboards?.length ?? 0) > SEARCH_THRESHOLD;
|
||||||
const vizType = latestQueryFormData?.viz_type;
|
const vizType = latestQueryFormData?.viz_type;
|
||||||
const meta = vizType ? getChartMetadataRegistry().get(vizType) : undefined;
|
const meta = vizType ? getChartMetadataRegistry().get(vizType) : undefined;
|
||||||
|
|
||||||
// Detect if the chart plugin exposes the export-current-view behavior
|
// Detect if the chart plugin exposes the export-current-view behavior
|
||||||
const hasExportCurrentView = !!meta?.behaviors?.includes(
|
const hasExportCurrentView = !!meta?.behaviors?.includes(
|
||||||
'EXPORT_CURRENT_VIEW',
|
'EXPORT_CURRENT_VIEW' as Behavior,
|
||||||
);
|
);
|
||||||
|
|
||||||
const shareByEmail = useCallback(async () => {
|
const shareByEmail = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const subject = t('Superset Chart');
|
const subject = t('Superset Chart');
|
||||||
const result = await getChartPermalink(latestQueryFormData);
|
if (!latestQueryFormData?.datasource) {
|
||||||
|
throw new Error('No datasource available');
|
||||||
|
}
|
||||||
|
const result = await getChartPermalink(
|
||||||
|
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
|
||||||
|
);
|
||||||
if (!result?.url) {
|
if (!result?.url) {
|
||||||
throw new Error('Failed to generate permalink');
|
throw new Error('Failed to generate permalink');
|
||||||
}
|
}
|
||||||
@@ -227,11 +304,12 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
isTableViz &&
|
isTableViz &&
|
||||||
queriesResponse?.length > 1 &&
|
queriesResponse &&
|
||||||
|
queriesResponse.length > 1 &&
|
||||||
queriesResponse[1]?.data?.[0]?.rowcount
|
queriesResponse[1]?.data?.[0]?.rowcount
|
||||||
) {
|
) {
|
||||||
actualRowCount = queriesResponse[1].data[0].rowcount;
|
actualRowCount = queriesResponse[1].data[0].rowcount;
|
||||||
} else if (queriesResponse?.[0]?.sql_rowcount != null) {
|
} else if (queriesResponse && queriesResponse[0]?.sql_rowcount != null) {
|
||||||
actualRowCount = queriesResponse[0].sql_rowcount;
|
actualRowCount = queriesResponse[0].sql_rowcount;
|
||||||
} else {
|
} else {
|
||||||
actualRowCount = latestQueryFormData?.row_limit;
|
actualRowCount = latestQueryFormData?.row_limit;
|
||||||
@@ -241,7 +319,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
const shouldUseStreaming =
|
const shouldUseStreaming =
|
||||||
actualRowCount && actualRowCount >= streamingThreshold;
|
actualRowCount && actualRowCount >= streamingThreshold;
|
||||||
|
|
||||||
let filename;
|
let filename: string | undefined;
|
||||||
if (shouldUseStreaming) {
|
if (shouldUseStreaming) {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const date = now.toISOString().slice(0, 10);
|
const date = now.toISOString().slice(0, 10);
|
||||||
@@ -254,18 +332,22 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
}
|
}
|
||||||
|
|
||||||
return exportChart({
|
return exportChart({
|
||||||
formData: latestQueryFormData,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
ownState,
|
ownState,
|
||||||
resultType: 'full',
|
resultType: 'full',
|
||||||
resultFormat: 'csv',
|
resultFormat: 'csv',
|
||||||
onStartStreamingExport: shouldUseStreaming
|
onStartStreamingExport: shouldUseStreaming
|
||||||
? exportParams => {
|
? exportParams => {
|
||||||
setIsStreamingModalVisible(true);
|
if (exportParams.url) {
|
||||||
startExport({
|
setIsStreamingModalVisible(true);
|
||||||
...exportParams,
|
startExport({
|
||||||
filename,
|
...exportParams,
|
||||||
expectedRows: actualRowCount,
|
url: exportParams.url,
|
||||||
});
|
filename,
|
||||||
|
expectedRows: actualRowCount,
|
||||||
|
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
});
|
});
|
||||||
@@ -283,7 +365,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
() =>
|
() =>
|
||||||
canDownloadCSV
|
canDownloadCSV
|
||||||
? exportChart({
|
? exportChart({
|
||||||
formData: latestQueryFormData,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
ownState,
|
ownState,
|
||||||
resultType: 'post_processed',
|
resultType: 'post_processed',
|
||||||
resultFormat: 'csv',
|
resultFormat: 'csv',
|
||||||
@@ -296,7 +378,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
() =>
|
() =>
|
||||||
canDownloadCSV
|
canDownloadCSV
|
||||||
? exportChart({
|
? exportChart({
|
||||||
formData: latestQueryFormData,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
ownState,
|
ownState,
|
||||||
resultType: 'results',
|
resultType: 'results',
|
||||||
resultFormat: 'json',
|
resultFormat: 'json',
|
||||||
@@ -309,7 +391,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
() =>
|
() =>
|
||||||
canDownloadCSV
|
canDownloadCSV
|
||||||
? exportChart({
|
? exportChart({
|
||||||
formData: latestQueryFormData,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
ownState,
|
ownState,
|
||||||
resultType: 'results',
|
resultType: 'results',
|
||||||
resultFormat: 'xlsx',
|
resultFormat: 'xlsx',
|
||||||
@@ -320,11 +402,13 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
const copyLink = useCallback(async () => {
|
const copyLink = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
if (!latestQueryFormData) {
|
if (!latestQueryFormData?.datasource) {
|
||||||
throw new Error();
|
throw new Error('No datasource available');
|
||||||
}
|
}
|
||||||
await copyTextToClipboard(async () => {
|
await copyTextToClipboard(async () => {
|
||||||
const result = await getChartPermalink(latestQueryFormData);
|
const result = await getChartPermalink(
|
||||||
|
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
|
||||||
|
);
|
||||||
if (!result?.url) {
|
if (!result?.url) {
|
||||||
throw new Error('Failed to generate permalink');
|
throw new Error('Failed to generate permalink');
|
||||||
}
|
}
|
||||||
@@ -337,9 +421,13 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
||||||
|
|
||||||
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
|
// Minimal client-side CSV builder used for "Current View" when pagination is disabled
|
||||||
const downloadClientCSV = (rows, columns, filename) => {
|
const downloadClientCSV = (
|
||||||
|
rows: ClientViewRow[],
|
||||||
|
columns: ClientViewColumn[],
|
||||||
|
filename: string,
|
||||||
|
) => {
|
||||||
if (!rows?.length || !columns?.length) return;
|
if (!rows?.length || !columns?.length) return;
|
||||||
const esc = v => {
|
const esc = (v: unknown): string => {
|
||||||
if (v === null || v === undefined) return '';
|
if (v === null || v === undefined) return '';
|
||||||
const s = String(v);
|
const s = String(v);
|
||||||
const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||||
@@ -361,20 +449,29 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Robust client-side JSON for "Current View"
|
// Robust client-side JSON for "Current View"
|
||||||
const downloadClientJSON = (rows, columns, filename) => {
|
const downloadClientJSON = (
|
||||||
|
rows: ClientViewRow[],
|
||||||
|
columns: ClientViewColumn[],
|
||||||
|
filename: string,
|
||||||
|
) => {
|
||||||
if (!rows?.length || !columns?.length) return;
|
if (!rows?.length || !columns?.length) return;
|
||||||
|
|
||||||
const norm = v => {
|
const norm = (v: unknown): unknown => {
|
||||||
if (v instanceof Date) return v.toISOString();
|
if (v instanceof Date) return v.toISOString();
|
||||||
if (v && typeof v === 'object' && 'input' in v && 'formatter' in v) {
|
if (v && typeof v === 'object' && 'input' in v && 'formatter' in v) {
|
||||||
const dv = v.input ?? v.value ?? v.toString?.() ?? '';
|
const typedV = v as {
|
||||||
|
input?: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
toString?: () => string;
|
||||||
|
};
|
||||||
|
const dv = typedV.input ?? typedV.value ?? typedV.toString?.() ?? '';
|
||||||
return dv instanceof Date ? dv.toISOString() : dv;
|
return dv instanceof Date ? dv.toISOString() : dv;
|
||||||
}
|
}
|
||||||
return v;
|
return v;
|
||||||
};
|
};
|
||||||
|
|
||||||
const data = rows.map(r => {
|
const data = rows.map(r => {
|
||||||
const out = {};
|
const out: Record<string, unknown> = {};
|
||||||
columns.forEach(c => {
|
columns.forEach(c => {
|
||||||
out[c.key] = norm(r[c.key]);
|
out[c.key] = norm(r[c.key]);
|
||||||
});
|
});
|
||||||
@@ -402,8 +499,12 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
URL.revokeObjectURL(link.href);
|
URL.revokeObjectURL(link.href);
|
||||||
};
|
};
|
||||||
|
|
||||||
// NEW: Client-side XLSX for "Current View" (uses 'xlsx' already in deps)
|
// Client-side XLSX for "Current View" (uses 'xlsx' already in deps)
|
||||||
const downloadClientXLSX = async (rows, columns, filename) => {
|
const downloadClientXLSX = async (
|
||||||
|
rows: ClientViewRow[],
|
||||||
|
columns: ClientViewColumn[],
|
||||||
|
filename: string,
|
||||||
|
) => {
|
||||||
if (!rows?.length || !columns?.length) return;
|
if (!rows?.length || !columns?.length) return;
|
||||||
try {
|
try {
|
||||||
const XLSX = (await import(/* webpackChunkName: "xlsx" */ 'xlsx'))
|
const XLSX = (await import(/* webpackChunkName: "xlsx" */ 'xlsx'))
|
||||||
@@ -411,17 +512,20 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
// Build a flat array of objects keyed by backend column key
|
// Build a flat array of objects keyed by backend column key
|
||||||
const data = rows.map(r => {
|
const data = rows.map(r => {
|
||||||
const o = {};
|
const o: Record<string, unknown> = {};
|
||||||
columns.forEach(c => {
|
columns.forEach(c => {
|
||||||
const v = r[c.key];
|
const v = r[c.key];
|
||||||
o[c.label ?? c.key] =
|
if (v && typeof v === 'object' && 'input' in v && 'formatter' in v) {
|
||||||
v && typeof v === 'object' && 'input' in v && 'formatter' in v
|
const typedV = v as { input?: unknown; value?: unknown };
|
||||||
? v.input instanceof Date
|
o[c.label ?? c.key] =
|
||||||
? v.input.toISOString()
|
typedV.input instanceof Date
|
||||||
: (v.input ?? v.value ?? '')
|
? typedV.input.toISOString()
|
||||||
: v instanceof Date
|
: (typedV.input ?? typedV.value ?? '');
|
||||||
? v.toISOString()
|
} else if (v instanceof Date) {
|
||||||
: v;
|
o[c.label ?? c.key] = v.toISOString();
|
||||||
|
} else {
|
||||||
|
o[c.label ?? c.key] = v;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return o;
|
return o;
|
||||||
});
|
});
|
||||||
@@ -437,8 +541,8 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
ws['!cols'] = colWidths;
|
ws['!cols'] = colWidths;
|
||||||
|
|
||||||
XLSX.writeFile(wb, `${filename || 'current_view'}.xlsx`);
|
XLSX.writeFile(wb, `${filename || 'current_view'}.xlsx`);
|
||||||
} catch (e) {
|
} catch {
|
||||||
// If xlsx isn’t available for some reason, fall back to CSV
|
// If xlsx isn't available for some reason, fall back to CSV
|
||||||
downloadClientCSV(rows, columns, filename || 'current_view');
|
downloadClientCSV(rows, columns, filename || 'current_view');
|
||||||
addDangerToast?.(
|
addDangerToast?.(
|
||||||
t('Falling back to CSV; Excel export library not available.'),
|
t('Falling back to CSV; Excel export library not available.'),
|
||||||
@@ -493,7 +597,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
|
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('On dashboards'),
|
label: t('On dashboards'),
|
||||||
children: dashboardsChildren,
|
children: dashboardsChildren,
|
||||||
popupStyle: {
|
popupStyle: {
|
||||||
@@ -503,12 +607,15 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
menuItems.push({ type: 'divider' });
|
menuItems.push({ type: 'divider' as const });
|
||||||
|
|
||||||
// Download submenu
|
// Download submenu
|
||||||
const allDataChildren = [];
|
const allDataChildren = [];
|
||||||
|
|
||||||
if (VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type)) {
|
if (
|
||||||
|
latestQueryFormData.viz_type &&
|
||||||
|
VIZ_TYPES_PIVOTABLE.includes(latestQueryFormData.viz_type as VizType)
|
||||||
|
) {
|
||||||
allDataChildren.push(
|
allDataChildren.push(
|
||||||
{
|
{
|
||||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||||
@@ -603,7 +710,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
key: MENU_KEYS.EXPORT_ALL_SCREENSHOT,
|
key: MENU_KEYS.EXPORT_ALL_SCREENSHOT,
|
||||||
label: t('Export screenshot (jpeg)'),
|
label: t('Export screenshot (jpeg)'),
|
||||||
icon: <Icons.FileImageOutlined />,
|
icon: <Icons.FileImageOutlined />,
|
||||||
onClick: e => {
|
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
|
||||||
downloadAsImage(
|
downloadAsImage(
|
||||||
'.panel-body .chart-container',
|
'.panel-body .chart-container',
|
||||||
slice?.slice_name ?? t('New chart'),
|
slice?.slice_name ?? t('New chart'),
|
||||||
@@ -659,7 +766,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
exportChart({
|
exportChart({
|
||||||
formData: latestQueryFormData,
|
formData: latestQueryFormData as QueryFormData,
|
||||||
ownState,
|
ownState,
|
||||||
resultType: 'results',
|
resultType: 'results',
|
||||||
resultFormat: 'csv',
|
resultFormat: 'csv',
|
||||||
@@ -707,7 +814,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT,
|
key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT,
|
||||||
label: t('Export screenshot (jpeg)'),
|
label: t('Export screenshot (jpeg)'),
|
||||||
icon: <Icons.FileImageOutlined />,
|
icon: <Icons.FileImageOutlined />,
|
||||||
onClick: e => {
|
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
|
||||||
downloadAsImage(
|
downloadAsImage(
|
||||||
'.panel-body .chart-container',
|
'.panel-body .chart-container',
|
||||||
slice?.slice_name ?? t('New chart'),
|
slice?.slice_name ?? t('New chart'),
|
||||||
@@ -758,12 +865,12 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: MENU_KEYS.DATA_EXPORT_OPTIONS,
|
key: MENU_KEYS.DATA_EXPORT_OPTIONS,
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Data Export Options'),
|
label: t('Data Export Options'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
key: MENU_KEYS.EXPORT_ALL_DATA_GROUP,
|
key: MENU_KEYS.EXPORT_ALL_DATA_GROUP,
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Export All Data'),
|
label: t('Export All Data'),
|
||||||
children: allDataChildren,
|
children: allDataChildren,
|
||||||
},
|
},
|
||||||
@@ -771,7 +878,7 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key: MENU_KEYS.EXPORT_CURRENT_VIEW_GROUP,
|
key: MENU_KEYS.EXPORT_CURRENT_VIEW_GROUP,
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Export Current View'),
|
label: t('Export Current View'),
|
||||||
children: currentViewChildren,
|
children: currentViewChildren,
|
||||||
},
|
},
|
||||||
@@ -781,7 +888,11 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Share submenu
|
// Share submenu
|
||||||
const shareChildren = [
|
const shareChildren: Array<{
|
||||||
|
key: string;
|
||||||
|
label: React.ReactNode;
|
||||||
|
onClick: () => void;
|
||||||
|
}> = [
|
||||||
{
|
{
|
||||||
key: MENU_KEYS.COPY_PERMALINK,
|
key: MENU_KEYS.COPY_PERMALINK,
|
||||||
label: t('Copy permalink to clipboard'),
|
label: t('Copy permalink to clipboard'),
|
||||||
@@ -826,13 +937,13 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
|
|
||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: MENU_KEYS.SHARE_SUBMENU,
|
key: MENU_KEYS.SHARE_SUBMENU,
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Share'),
|
label: t('Share'),
|
||||||
children: shareChildren,
|
children: shareChildren,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Divider
|
// Divider
|
||||||
menuItems.push({ type: 'divider' });
|
menuItems.push({ type: 'divider' as const });
|
||||||
|
|
||||||
// Report menu item
|
// Report menu item
|
||||||
if (reportMenuItem) {
|
if (reportMenuItem) {
|
||||||
@@ -849,7 +960,9 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
}
|
}
|
||||||
modalTitle={t('View query')}
|
modalTitle={t('View query')}
|
||||||
modalBody={
|
modalBody={
|
||||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
<ViewQueryModal
|
||||||
|
latestQueryFormData={latestQueryFormData as QueryFormData}
|
||||||
|
/>
|
||||||
}
|
}
|
||||||
draggable
|
draggable
|
||||||
resizable
|
resizable
|
||||||
@@ -864,8 +977,11 @@ export const useExploreAdditionalActionsMenu = (
|
|||||||
menuItems.push({
|
menuItems.push({
|
||||||
key: MENU_KEYS.RUN_IN_SQL_LAB,
|
key: MENU_KEYS.RUN_IN_SQL_LAB,
|
||||||
label: t('Run in SQL Lab'),
|
label: t('Run in SQL Lab'),
|
||||||
onClick: e => {
|
onClick: (e: { domEvent?: React.MouseEvent | React.KeyboardEvent }) => {
|
||||||
onOpenInEditor(latestQueryFormData, e.domEvent?.metaKey);
|
onOpenInEditor(
|
||||||
|
latestQueryFormData,
|
||||||
|
!!(e.domEvent as React.MouseEvent | undefined)?.metaKey,
|
||||||
|
);
|
||||||
setIsDropdownVisible(false);
|
setIsDropdownVisible(false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -28,15 +28,21 @@ import {
|
|||||||
} from 'src/explore/exploreUtils';
|
} from 'src/explore/exploreUtils';
|
||||||
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
|
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
|
||||||
import * as hostNamesConfig from 'src/utils/hostNamesConfig';
|
import * as hostNamesConfig from 'src/utils/hostNamesConfig';
|
||||||
import { getChartMetadataRegistry, SupersetClient } from '@superset-ui/core';
|
import {
|
||||||
|
ChartMetadata,
|
||||||
|
getChartMetadataRegistry,
|
||||||
|
QueryFormData,
|
||||||
|
SupersetClient,
|
||||||
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('exploreUtils', () => {
|
describe('exploreUtils', () => {
|
||||||
const { location } = window;
|
const { location } = window;
|
||||||
const formData = {
|
const formData: QueryFormData = {
|
||||||
datasource: '1__table',
|
datasource: '1__table',
|
||||||
|
viz_type: 'table',
|
||||||
};
|
};
|
||||||
function compareURI(uri1, uri2) {
|
function compareURI(uri1: URI, uri2: URI): void {
|
||||||
expect(uri1.toString()).toBe(uri2.toString());
|
expect(uri1.toString()).toBe(uri2.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,7 +59,7 @@ describe('exploreUtils', () => {
|
|||||||
force: false,
|
force: false,
|
||||||
curUrl: 'http://superset.com',
|
curUrl: 'http://superset.com',
|
||||||
});
|
});
|
||||||
compareURI(URI(url), URI('/explore/'));
|
compareURI(URI(url!), URI('/explore/'));
|
||||||
});
|
});
|
||||||
test('generates proper json url', () => {
|
test('generates proper json url', () => {
|
||||||
const url = getExploreUrl({
|
const url = getExploreUrl({
|
||||||
@@ -62,7 +68,7 @@ describe('exploreUtils', () => {
|
|||||||
force: false,
|
force: false,
|
||||||
curUrl: 'http://superset.com',
|
curUrl: 'http://superset.com',
|
||||||
});
|
});
|
||||||
compareURI(URI(url), URI('/superset/explore_json/'));
|
compareURI(URI(url!), URI('/superset/explore_json/'));
|
||||||
});
|
});
|
||||||
test('generates proper json forced url', () => {
|
test('generates proper json forced url', () => {
|
||||||
const url = getExploreUrl({
|
const url = getExploreUrl({
|
||||||
@@ -72,7 +78,7 @@ describe('exploreUtils', () => {
|
|||||||
curUrl: 'superset.com',
|
curUrl: 'superset.com',
|
||||||
});
|
});
|
||||||
compareURI(
|
compareURI(
|
||||||
URI(url),
|
URI(url!),
|
||||||
URI('/superset/explore_json/').search({ force: 'true' }),
|
URI('/superset/explore_json/').search({ force: 'true' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -84,7 +90,7 @@ describe('exploreUtils', () => {
|
|||||||
curUrl: 'superset.com',
|
curUrl: 'superset.com',
|
||||||
});
|
});
|
||||||
compareURI(
|
compareURI(
|
||||||
URI(url),
|
URI(url!),
|
||||||
URI('/superset/explore_json/').search({ csv: 'true' }),
|
URI('/superset/explore_json/').search({ csv: 'true' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -96,7 +102,7 @@ describe('exploreUtils', () => {
|
|||||||
curUrl: 'superset.com',
|
curUrl: 'superset.com',
|
||||||
});
|
});
|
||||||
compareURI(
|
compareURI(
|
||||||
URI(url),
|
URI(url!),
|
||||||
URI('/explore/').search({
|
URI('/explore/').search({
|
||||||
standalone: DashboardStandaloneMode.HideNav,
|
standalone: DashboardStandaloneMode.HideNav,
|
||||||
}),
|
}),
|
||||||
@@ -110,7 +116,7 @@ describe('exploreUtils', () => {
|
|||||||
curUrl: 'superset.com?foo=bar',
|
curUrl: 'superset.com?foo=bar',
|
||||||
});
|
});
|
||||||
compareURI(
|
compareURI(
|
||||||
URI(url),
|
URI(url!),
|
||||||
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -122,7 +128,7 @@ describe('exploreUtils', () => {
|
|||||||
curUrl: 'superset.com?foo=bar',
|
curUrl: 'superset.com?foo=bar',
|
||||||
});
|
});
|
||||||
compareURI(
|
compareURI(
|
||||||
URI(url),
|
URI(url!),
|
||||||
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -130,7 +136,7 @@ describe('exploreUtils', () => {
|
|||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('domain sharding', () => {
|
describe('domain sharding', () => {
|
||||||
let stub;
|
let stub: sinon.SinonStub;
|
||||||
const availableDomains = [
|
const availableDomains = [
|
||||||
'http://localhost/',
|
'http://localhost/',
|
||||||
'domain1.com',
|
'domain1.com',
|
||||||
@@ -199,7 +205,9 @@ describe('exploreUtils', () => {
|
|||||||
const v1RequestPayload = await buildV1ChartDataPayload({
|
const v1RequestPayload = await buildV1ChartDataPayload({
|
||||||
formData: { ...formData, viz_type: 'my_custom_viz' },
|
formData: { ...formData, viz_type: 'my_custom_viz' },
|
||||||
});
|
});
|
||||||
expect(v1RequestPayload.hasOwnProperty('queries')).toBeTruthy();
|
expect(
|
||||||
|
Object.prototype.hasOwnProperty.call(v1RequestPayload, 'queries'),
|
||||||
|
).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -207,8 +215,12 @@ describe('exploreUtils', () => {
|
|||||||
describe('getQuerySettings', () => {
|
describe('getQuerySettings', () => {
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
getChartMetadataRegistry()
|
getChartMetadataRegistry()
|
||||||
.registerValue('my_legacy_viz', { useLegacyApi: true })
|
.registerValue('my_legacy_viz', {
|
||||||
.registerValue('my_v1_viz', { useLegacyApi: false });
|
useLegacyApi: true,
|
||||||
|
} as unknown as ChartMetadata)
|
||||||
|
.registerValue('my_v1_viz', {
|
||||||
|
useLegacyApi: false,
|
||||||
|
} as unknown as ChartMetadata);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -293,9 +305,7 @@ describe('exploreUtils', () => {
|
|||||||
const postFormSpy = jest.spyOn(SupersetClient, 'postForm');
|
const postFormSpy = jest.spyOn(SupersetClient, 'postForm');
|
||||||
postFormSpy.mockImplementation(jest.fn());
|
postFormSpy.mockImplementation(jest.fn());
|
||||||
|
|
||||||
exploreChart({
|
exploreChart({ ...formData, viz_type: 'my_custom_viz' });
|
||||||
formData: { ...formData, viz_type: 'my_custom_viz' },
|
|
||||||
});
|
|
||||||
expect(postFormSpy).toHaveBeenCalledTimes(1);
|
expect(postFormSpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -31,7 +31,7 @@ describe('Get ChartUri', () => {
|
|||||||
expect(
|
expect(
|
||||||
getChartDataUri({
|
getChartDataUri({
|
||||||
path: '/path',
|
path: '/path',
|
||||||
qs: 'same-string',
|
qs: { key: 'same-string' },
|
||||||
allowDomainSharding: false,
|
allowDomainSharding: false,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -46,7 +46,7 @@ describe('Get ChartUri', () => {
|
|||||||
port: '',
|
port: '',
|
||||||
preventInvalidHostname: false,
|
preventInvalidHostname: false,
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
query: 'same-string',
|
query: 'key=same-string',
|
||||||
urn: null,
|
urn: null,
|
||||||
username: null,
|
username: null,
|
||||||
},
|
},
|
||||||
@@ -58,7 +58,7 @@ describe('Get ChartUri', () => {
|
|||||||
expect(
|
expect(
|
||||||
getChartDataUri({
|
getChartDataUri({
|
||||||
path: '/path-allowDomainSharding-true',
|
path: '/path-allowDomainSharding-true',
|
||||||
qs: 'same-string-allowDomainSharding-true',
|
qs: { key: 'allowDomainSharding-true' },
|
||||||
allowDomainSharding: true,
|
allowDomainSharding: true,
|
||||||
}),
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
@@ -73,7 +73,7 @@ describe('Get ChartUri', () => {
|
|||||||
port: '',
|
port: '',
|
||||||
preventInvalidHostname: false,
|
preventInvalidHostname: false,
|
||||||
protocol: 'http',
|
protocol: 'http',
|
||||||
query: 'same-string-allowDomainSharding-true',
|
query: 'key=allowDomainSharding-true',
|
||||||
urn: null,
|
urn: null,
|
||||||
username: null,
|
username: null,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -16,8 +16,11 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
import { getChartKey } from '.';
|
import { getChartKey } from '.';
|
||||||
|
|
||||||
test('should return "slice_id" when called with an object that has "slice.slice_id"', () => {
|
test('should return "slice_id" when called with an object that has "slice.slice_id"', () => {
|
||||||
expect(getChartKey({ slice: { slice_id: 100 } })).toBe(100);
|
expect(getChartKey({ slice: { slice_id: 100 } as unknown as Slice })).toBe(
|
||||||
|
100,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const createParams = () => ({
|
|||||||
curUrl: null,
|
curUrl: null,
|
||||||
requestParams: {},
|
requestParams: {},
|
||||||
allowDomainSharding: false,
|
allowDomainSharding: false,
|
||||||
method: 'POST',
|
method: 'POST' as const,
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Get ExploreUrl with default params', () => {
|
test('Get ExploreUrl with default params', () => {
|
||||||
|
|||||||
@@ -38,9 +38,6 @@ test('Should return "" if subject is falsy', () => {
|
|||||||
expect(getSimpleSQLExpression('', params.operator, params.comparator)).toBe(
|
expect(getSimpleSQLExpression('', params.operator, params.comparator)).toBe(
|
||||||
'',
|
'',
|
||||||
);
|
);
|
||||||
expect(getSimpleSQLExpression(null, params.operator, params.comparator)).toBe(
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
getSimpleSQLExpression(undefined, params.operator, params.comparator),
|
getSimpleSQLExpression(undefined, params.operator, params.comparator),
|
||||||
).toBe('');
|
).toBe('');
|
||||||
@@ -56,9 +53,6 @@ test('Should return subject if operator is falsy', () => {
|
|||||||
expect(getSimpleSQLExpression(params.subject, '', params.comparator)).toBe(
|
expect(getSimpleSQLExpression(params.subject, '', params.comparator)).toBe(
|
||||||
params.subject,
|
params.subject,
|
||||||
);
|
);
|
||||||
expect(getSimpleSQLExpression(params.subject, null, params.comparator)).toBe(
|
|
||||||
params.subject,
|
|
||||||
);
|
|
||||||
expect(
|
expect(
|
||||||
getSimpleSQLExpression(params.subject, undefined, params.comparator),
|
getSimpleSQLExpression(params.subject, undefined, params.comparator),
|
||||||
).toBe(params.subject);
|
).toBe(params.subject);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect } from 'react';
|
import { useCallback, useEffect, DependencyList } from 'react';
|
||||||
/* eslint camelcase: 0 */
|
/* eslint camelcase: 0 */
|
||||||
import URI from 'urijs';
|
import URI from 'urijs';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,10 @@ import {
|
|||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
getChartBuildQueryRegistry,
|
getChartBuildQueryRegistry,
|
||||||
getChartMetadataRegistry,
|
getChartMetadataRegistry,
|
||||||
|
QueryFormData,
|
||||||
SupersetClient,
|
SupersetClient,
|
||||||
|
SetDataMaskHook,
|
||||||
|
JsonObject,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
import { availableDomains } from 'src/utils/hostNamesConfig';
|
import { availableDomains } from 'src/utils/hostNamesConfig';
|
||||||
import { safeStringify } from 'src/utils/safeStringify';
|
import { safeStringify } from 'src/utils/safeStringify';
|
||||||
@@ -35,18 +38,82 @@ import { URL_PARAMS } from 'src/constants';
|
|||||||
import {
|
import {
|
||||||
DISABLE_INPUT_OPERATORS,
|
DISABLE_INPUT_OPERATORS,
|
||||||
MULTI_OPERATORS,
|
MULTI_OPERATORS,
|
||||||
|
Operators,
|
||||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||||
UNSAVED_CHART_ID,
|
UNSAVED_CHART_ID,
|
||||||
} from 'src/explore/constants';
|
} from 'src/explore/constants';
|
||||||
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
|
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
|
|
||||||
export function getChartKey(explore) {
|
// Type definitions
|
||||||
|
export type EndpointType =
|
||||||
|
| 'base'
|
||||||
|
| 'full'
|
||||||
|
| 'json'
|
||||||
|
| 'csv'
|
||||||
|
| 'query'
|
||||||
|
| 'results'
|
||||||
|
| 'samples'
|
||||||
|
| 'standalone';
|
||||||
|
|
||||||
|
interface ExploreState {
|
||||||
|
slice?: Slice | null;
|
||||||
|
form_data?: Partial<QueryFormData>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataUriParams {
|
||||||
|
path: string;
|
||||||
|
qs?: Record<string, string>;
|
||||||
|
allowDomainSharding?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GetExploreUrlParams {
|
||||||
|
formData: QueryFormData & { label_colors?: Record<string, string> };
|
||||||
|
endpointType?: EndpointType | string;
|
||||||
|
force?: boolean;
|
||||||
|
curUrl?: string | null;
|
||||||
|
requestParams?: Record<string, string>;
|
||||||
|
allowDomainSharding?: boolean;
|
||||||
|
method?: 'GET' | 'POST';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BuildV1ChartDataPayloadParams {
|
||||||
|
formData: QueryFormData;
|
||||||
|
force?: boolean;
|
||||||
|
resultFormat?: string;
|
||||||
|
resultType?: string;
|
||||||
|
setDataMask?: SetDataMaskHook;
|
||||||
|
ownState?: JsonObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExportChartParams {
|
||||||
|
formData: QueryFormData;
|
||||||
|
resultFormat?: string;
|
||||||
|
resultType?: string;
|
||||||
|
force?: boolean;
|
||||||
|
ownState?: JsonObject;
|
||||||
|
onStartStreamingExport?:
|
||||||
|
| ((params: {
|
||||||
|
url: string | null;
|
||||||
|
payload: QueryFormData | ReturnType<typeof buildQueryContext>;
|
||||||
|
exportType: string;
|
||||||
|
}) => void)
|
||||||
|
| null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SubjectWithColumnName {
|
||||||
|
column_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ComparatorValue = string | number | boolean | null;
|
||||||
|
|
||||||
|
export function getChartKey(explore: ExploreState): number {
|
||||||
const { slice, form_data } = explore;
|
const { slice, form_data } = explore;
|
||||||
return slice?.slice_id ?? form_data?.slice_id ?? UNSAVED_CHART_ID;
|
return slice?.slice_id ?? form_data?.slice_id ?? UNSAVED_CHART_ID;
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestCounter = 0;
|
let requestCounter = 0;
|
||||||
export function getHostName(allowDomainSharding = false) {
|
export function getHostName(allowDomainSharding = false): string {
|
||||||
let currentIndex = 0;
|
let currentIndex = 0;
|
||||||
if (allowDomainSharding) {
|
if (allowDomainSharding) {
|
||||||
currentIndex = requestCounter % availableDomains.length;
|
currentIndex = requestCounter % availableDomains.length;
|
||||||
@@ -63,7 +130,10 @@ export function getHostName(allowDomainSharding = false) {
|
|||||||
return availableDomains[currentIndex];
|
return availableDomains[currentIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnnotationJsonUrl(slice_id, force) {
|
export function getAnnotationJsonUrl(
|
||||||
|
slice_id: number | null | undefined,
|
||||||
|
force: boolean,
|
||||||
|
): string | null {
|
||||||
if (slice_id === null || slice_id === undefined) {
|
if (slice_id === null || slice_id === undefined) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -78,7 +148,10 @@ export function getAnnotationJsonUrl(slice_id, force) {
|
|||||||
.toString();
|
.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getURIDirectory(endpointType = 'base', includeAppRoot = true) {
|
export function getURIDirectory(
|
||||||
|
endpointType: EndpointType | string = 'base',
|
||||||
|
includeAppRoot = true,
|
||||||
|
): string {
|
||||||
// Building the directory part of the URI
|
// Building the directory part of the URI
|
||||||
const uri = ['full', 'json', 'csv', 'query', 'results', 'samples'].includes(
|
const uri = ['full', 'json', 'csv', 'query', 'results', 'samples'].includes(
|
||||||
endpointType,
|
endpointType,
|
||||||
@@ -89,14 +162,14 @@ export function getURIDirectory(endpointType = 'base', includeAppRoot = true) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mountExploreUrl(
|
export function mountExploreUrl(
|
||||||
endpointType,
|
endpointType: EndpointType | string,
|
||||||
extraSearch = {},
|
extraSearch: Record<string, string | number> = {},
|
||||||
force = false,
|
force = false,
|
||||||
includeAppRoot = true,
|
includeAppRoot = true,
|
||||||
) {
|
): string {
|
||||||
const uri = new URI('/');
|
const uri = new URI('/');
|
||||||
const directory = getURIDirectory(endpointType, includeAppRoot);
|
const directory = getURIDirectory(endpointType, includeAppRoot);
|
||||||
const search = uri.search(true);
|
const search = uri.search(true) as Record<string, string | number>;
|
||||||
Object.keys(extraSearch).forEach(key => {
|
Object.keys(extraSearch).forEach(key => {
|
||||||
search[key] = extraSearch[key];
|
search[key] = extraSearch[key];
|
||||||
});
|
});
|
||||||
@@ -109,7 +182,11 @@ export function mountExploreUrl(
|
|||||||
return uri.directory(directory).search(search).toString();
|
return uri.directory(directory).search(search).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getChartDataUri({ path, qs, allowDomainSharding = false }) {
|
export function getChartDataUri({
|
||||||
|
path,
|
||||||
|
qs,
|
||||||
|
allowDomainSharding = false,
|
||||||
|
}: ChartDataUriParams): URI {
|
||||||
// The search params from the window.location are carried through,
|
// The search params from the window.location are carried through,
|
||||||
// but can be specified with curUrl (used for unit tests to spoof
|
// but can be specified with curUrl (used for unit tests to spoof
|
||||||
// the window.location).
|
// the window.location).
|
||||||
@@ -138,7 +215,7 @@ export function getExploreUrl({
|
|||||||
requestParams = {},
|
requestParams = {},
|
||||||
allowDomainSharding = false,
|
allowDomainSharding = false,
|
||||||
method = 'POST',
|
method = 'POST',
|
||||||
}) {
|
}: GetExploreUrlParams): string | null {
|
||||||
if (!formData.datasource) {
|
if (!formData.datasource) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -158,10 +235,10 @@ export function getExploreUrl({
|
|||||||
const directory = getURIDirectory(endpointType);
|
const directory = getURIDirectory(endpointType);
|
||||||
|
|
||||||
// Building the querystring (search) part of the URI
|
// Building the querystring (search) part of the URI
|
||||||
const search = uri.search(true);
|
const search = uri.search(true) as Record<string, string>;
|
||||||
const { slice_id, extra_filters, adhoc_filters, viz_type } = formData;
|
const { slice_id, extra_filters, adhoc_filters, viz_type } = formData;
|
||||||
if (slice_id) {
|
if (slice_id) {
|
||||||
const form_data = { slice_id };
|
const form_data: Record<string, unknown> = { slice_id };
|
||||||
if (method === 'GET') {
|
if (method === 'GET') {
|
||||||
form_data.viz_type = viz_type;
|
form_data.viz_type = viz_type;
|
||||||
if (extra_filters && extra_filters.length) {
|
if (extra_filters && extra_filters.length) {
|
||||||
@@ -194,7 +271,7 @@ export function getExploreUrl({
|
|||||||
const paramNames = Object.keys(requestParams);
|
const paramNames = Object.keys(requestParams);
|
||||||
if (paramNames.length) {
|
if (paramNames.length) {
|
||||||
paramNames.forEach(name => {
|
paramNames.forEach(name => {
|
||||||
if (requestParams.hasOwnProperty(name)) {
|
if (Object.hasOwn(requestParams, name)) {
|
||||||
search[name] = requestParams[name];
|
search[name] = requestParams[name];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -202,8 +279,12 @@ export function getExploreUrl({
|
|||||||
return uri.search(search).directory(directory).toString();
|
return uri.search(search).directory(directory).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const getQuerySettings = formData => {
|
export const getQuerySettings = (
|
||||||
const vizMetadata = getChartMetadataRegistry().get(formData.viz_type);
|
formData: Partial<QueryFormData>,
|
||||||
|
): [boolean, string] => {
|
||||||
|
const vizMetadata = formData.viz_type
|
||||||
|
? getChartMetadataRegistry().get(formData.viz_type)
|
||||||
|
: undefined;
|
||||||
return [
|
return [
|
||||||
vizMetadata?.useLegacyApi ?? false,
|
vizMetadata?.useLegacyApi ?? false,
|
||||||
vizMetadata?.parseMethod ?? 'json-bigint',
|
vizMetadata?.parseMethod ?? 'json-bigint',
|
||||||
@@ -217,33 +298,44 @@ export const buildV1ChartDataPayload = async ({
|
|||||||
resultType,
|
resultType,
|
||||||
setDataMask,
|
setDataMask,
|
||||||
ownState,
|
ownState,
|
||||||
}) => {
|
}: BuildV1ChartDataPayloadParams): Promise<
|
||||||
|
ReturnType<typeof buildQueryContext>
|
||||||
|
> => {
|
||||||
|
const defaultBuildQuery = (buildQueryFormData: QueryFormData) =>
|
||||||
|
buildQueryContext(buildQueryFormData, baseQueryObject => [
|
||||||
|
{
|
||||||
|
...baseQueryObject,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const registryResult = formData.viz_type
|
||||||
|
? getChartBuildQueryRegistry().get(formData.viz_type)
|
||||||
|
: undefined;
|
||||||
const buildQuery =
|
const buildQuery =
|
||||||
getChartBuildQueryRegistry().get(formData.viz_type) ??
|
(registryResult ? await registryResult : undefined) ?? defaultBuildQuery;
|
||||||
(buildQueryformData =>
|
|
||||||
buildQueryContext(buildQueryformData, baseQueryObject => [
|
|
||||||
{
|
|
||||||
...baseQueryObject,
|
|
||||||
},
|
|
||||||
]));
|
|
||||||
return buildQuery(
|
return buildQuery(
|
||||||
{
|
{
|
||||||
...formData,
|
...formData,
|
||||||
force,
|
force,
|
||||||
result_format: resultFormat,
|
result_format: resultFormat,
|
||||||
result_type: resultType,
|
result_type: resultType,
|
||||||
},
|
} as QueryFormData,
|
||||||
{
|
{
|
||||||
ownState,
|
ownState,
|
||||||
hooks: {
|
hooks: {
|
||||||
setDataMask,
|
setDataMask: setDataMask ?? (() => {}),
|
||||||
|
setCachedChanges: () => {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getLegacyEndpointType = ({ resultType, resultFormat }) =>
|
export const getLegacyEndpointType = ({
|
||||||
resultFormat === 'csv' ? resultFormat : resultType;
|
resultType,
|
||||||
|
resultFormat,
|
||||||
|
}: {
|
||||||
|
resultType: string;
|
||||||
|
resultFormat: string;
|
||||||
|
}): string => (resultFormat === 'csv' ? resultFormat : resultType);
|
||||||
|
|
||||||
export const exportChart = async ({
|
export const exportChart = async ({
|
||||||
formData,
|
formData,
|
||||||
@@ -252,10 +344,10 @@ export const exportChart = async ({
|
|||||||
force = false,
|
force = false,
|
||||||
ownState = {},
|
ownState = {},
|
||||||
onStartStreamingExport = null,
|
onStartStreamingExport = null,
|
||||||
}) => {
|
}: ExportChartParams): Promise<void> => {
|
||||||
let url;
|
let url: string | null;
|
||||||
let payload;
|
let payload: QueryFormData | ReturnType<typeof buildQueryContext>;
|
||||||
const [useLegacyApi, parseMethod] = getQuerySettings(formData);
|
const [useLegacyApi] = getQuerySettings(formData);
|
||||||
if (useLegacyApi) {
|
if (useLegacyApi) {
|
||||||
const endpointType = getLegacyEndpointType({ resultFormat, resultType });
|
const endpointType = getLegacyEndpointType({ resultFormat, resultType });
|
||||||
url = getExploreUrl({
|
url = getExploreUrl({
|
||||||
@@ -272,7 +364,6 @@ export const exportChart = async ({
|
|||||||
resultFormat,
|
resultFormat,
|
||||||
resultType,
|
resultType,
|
||||||
ownState,
|
ownState,
|
||||||
parseMethod,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,21 +377,32 @@ export const exportChart = async ({
|
|||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// Fallback to original behavior for non-streaming exports
|
// Fallback to original behavior for non-streaming exports
|
||||||
SupersetClient.postForm(url, { form_data: safeStringify(payload) });
|
SupersetClient.postForm(url as string, {
|
||||||
|
form_data: safeStringify(payload),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const exploreChart = (formData, requestParams) => {
|
export const exploreChart = (
|
||||||
|
formData: QueryFormData,
|
||||||
|
requestParams?: Record<string, string>,
|
||||||
|
): void => {
|
||||||
const url = getExploreUrl({
|
const url = getExploreUrl({
|
||||||
formData,
|
formData,
|
||||||
endpointType: 'base',
|
endpointType: 'base',
|
||||||
allowDomainSharding: false,
|
allowDomainSharding: false,
|
||||||
requestParams,
|
requestParams,
|
||||||
});
|
});
|
||||||
SupersetClient.postForm(url, { form_data: safeStringify(formData) });
|
SupersetClient.postForm(url as string, {
|
||||||
|
form_data: safeStringify(formData),
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDebouncedEffect = (effect, delay, deps) => {
|
export const useDebouncedEffect = (
|
||||||
|
effect: () => void,
|
||||||
|
delay: number,
|
||||||
|
deps: DependencyList,
|
||||||
|
): void => {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
const callback = useCallback(effect, deps);
|
const callback = useCallback(effect, deps);
|
||||||
|
|
||||||
@@ -315,17 +417,22 @@ export const useDebouncedEffect = (effect, delay, deps) => {
|
|||||||
}, [callback, delay]);
|
}, [callback, delay]);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSimpleSQLExpression = (subject, operator, comparator) => {
|
export const getSimpleSQLExpression = (
|
||||||
const isMulti =
|
subject?: string | SubjectWithColumnName,
|
||||||
[...MULTI_OPERATORS]
|
operator?: string,
|
||||||
.map(op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation)
|
comparator?: ComparatorValue | ComparatorValue[],
|
||||||
.indexOf(operator) >= 0;
|
): string => {
|
||||||
|
const multiOperatorValues = [...MULTI_OPERATORS].map(
|
||||||
|
(op: Operators) => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||||
|
);
|
||||||
|
const isMulti = multiOperatorValues.indexOf(operator ?? '') >= 0;
|
||||||
|
const disableInputOperatorValues = DISABLE_INPUT_OPERATORS.map(
|
||||||
|
(op: Operators) => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||||
|
);
|
||||||
const showComparator =
|
const showComparator =
|
||||||
DISABLE_INPUT_OPERATORS.map(
|
disableInputOperatorValues.indexOf(operator ?? '') === -1;
|
||||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
|
||||||
).indexOf(operator) === -1;
|
|
||||||
// If returned value is an object after changing dataset
|
// If returned value is an object after changing dataset
|
||||||
let expression =
|
let expression: string =
|
||||||
typeof subject === 'object'
|
typeof subject === 'object'
|
||||||
? (subject?.column_name ?? '')
|
? (subject?.column_name ?? '')
|
||||||
: (subject ?? '');
|
: (subject ?? '');
|
||||||
@@ -351,6 +458,8 @@ export const getSimpleSQLExpression = (subject, operator, comparator) => {
|
|||||||
return expression;
|
return expression;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatSelectOptions(options) {
|
export function formatSelectOptions<T extends { toString(): string }>(
|
||||||
|
options: T[],
|
||||||
|
): [T, string][] {
|
||||||
return options.map(opt => [opt, opt.toString()]);
|
return options.map(opt => [opt, opt.toString()]);
|
||||||
}
|
}
|
||||||
@@ -1,375 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
/* eslint camelcase: 0 */
|
|
||||||
import { ensureIsArray } from '@superset-ui/core';
|
|
||||||
import { omit, pick } from 'lodash';
|
|
||||||
import { DYNAMIC_PLUGIN_CONTROLS_READY } from 'src/components/Chart/chartAction';
|
|
||||||
import { getControlsState } from 'src/explore/store';
|
|
||||||
import {
|
|
||||||
getControlConfig,
|
|
||||||
getControlStateFromControlConfig,
|
|
||||||
getControlValuesCompatibleWithDatasource,
|
|
||||||
StandardizedFormData,
|
|
||||||
} from 'src/explore/controlUtils';
|
|
||||||
import * as actions from 'src/explore/actions/exploreActions';
|
|
||||||
import { HYDRATE_EXPLORE } from '../actions/hydrateExplore';
|
|
||||||
|
|
||||||
export default function exploreReducer(state = {}, action) {
|
|
||||||
const actionHandlers = {
|
|
||||||
[DYNAMIC_PLUGIN_CONTROLS_READY]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
controls: action.controlsState,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.TOGGLE_FAVE_STAR]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isStarred: action.isStarred,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.POST_DATASOURCE_STARTED]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isDatasourceMetaLoading: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.START_METADATA_LOADING]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isDatasourceMetaLoading: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.STOP_METADATA_LOADING]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isDatasourceMetaLoading: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SYNC_DATASOURCE_METADATA]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
datasource: action.datasource,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.UPDATE_FORM_DATA_BY_DATASOURCE]() {
|
|
||||||
const newFormData = { ...state.form_data };
|
|
||||||
const { prevDatasource, newDatasource } = action;
|
|
||||||
const controls = { ...state.controls };
|
|
||||||
const controlsTransferred = [];
|
|
||||||
|
|
||||||
if (
|
|
||||||
prevDatasource.id !== newDatasource.id ||
|
|
||||||
prevDatasource.type !== newDatasource.type
|
|
||||||
) {
|
|
||||||
newFormData.datasource = newDatasource.uid;
|
|
||||||
}
|
|
||||||
// reset control values for column/metric related controls
|
|
||||||
Object.entries(controls).forEach(([controlName, controlState]) => {
|
|
||||||
if (
|
|
||||||
// for direct column select controls
|
|
||||||
controlState.valueKey === 'column_name' ||
|
|
||||||
// for all other controls
|
|
||||||
'savedMetrics' in controlState ||
|
|
||||||
'columns' in controlState ||
|
|
||||||
('options' in controlState && !Array.isArray(controlState.options))
|
|
||||||
) {
|
|
||||||
newFormData[controlName] = getControlValuesCompatibleWithDatasource(
|
|
||||||
newDatasource,
|
|
||||||
controlState,
|
|
||||||
controlState.value,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
ensureIsArray(newFormData[controlName]).length > 0 &&
|
|
||||||
newFormData[controlName] !== controls[controlName].default
|
|
||||||
) {
|
|
||||||
controlsTransferred.push(controlName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
controls,
|
|
||||||
datasource: action.newDatasource,
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
...newState,
|
|
||||||
form_data: newFormData,
|
|
||||||
controls: getControlsState(newState, newFormData),
|
|
||||||
controlsTransferred,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.FETCH_DATASOURCES_STARTED]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
isDatasourcesLoading: true,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_FIELD_VALUE]() {
|
|
||||||
const { controlName, value, validationErrors } = action;
|
|
||||||
let new_form_data = { ...state.form_data, [controlName]: value };
|
|
||||||
const old_metrics_data = state.form_data.metrics;
|
|
||||||
const new_column_config = state.form_data.column_config;
|
|
||||||
|
|
||||||
const vizType = new_form_data.viz_type;
|
|
||||||
|
|
||||||
// if the controlName is metrics, and the metric column name is updated,
|
|
||||||
// need to update column config as well to keep the previous config.
|
|
||||||
if (controlName === 'metrics' && old_metrics_data && new_column_config) {
|
|
||||||
value.forEach((item, index) => {
|
|
||||||
const itemExist = old_metrics_data.some(
|
|
||||||
oldItem => oldItem?.label === item?.label,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (
|
|
||||||
!itemExist &&
|
|
||||||
item?.label !== old_metrics_data[index]?.label &&
|
|
||||||
!!new_column_config[old_metrics_data[index]?.label]
|
|
||||||
) {
|
|
||||||
new_column_config[item.label] =
|
|
||||||
new_column_config[old_metrics_data[index].label];
|
|
||||||
|
|
||||||
delete new_column_config[old_metrics_data[index].label];
|
|
||||||
}
|
|
||||||
});
|
|
||||||
new_form_data.column_config = new_column_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use the processed control config (with overrides and everything)
|
|
||||||
// if `controlName` does not exist in current controls,
|
|
||||||
const controlConfig =
|
|
||||||
state.controls[action.controlName] ||
|
|
||||||
getControlConfig(action.controlName, vizType) ||
|
|
||||||
null;
|
|
||||||
|
|
||||||
// will call validators again
|
|
||||||
const control = {
|
|
||||||
...getControlStateFromControlConfig(controlConfig, state, action.value),
|
|
||||||
};
|
|
||||||
|
|
||||||
const column_config = {
|
|
||||||
...state.controls.column_config,
|
|
||||||
...(new_column_config && { value: new_column_config }),
|
|
||||||
};
|
|
||||||
|
|
||||||
const newState = {
|
|
||||||
...state,
|
|
||||||
controls: {
|
|
||||||
...state.controls,
|
|
||||||
...(controlConfig && { [controlName]: control }),
|
|
||||||
...(controlName === 'metrics' && { column_config }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const rerenderedControls = {};
|
|
||||||
if (Array.isArray(control.rerender)) {
|
|
||||||
control.rerender.forEach(controlName => {
|
|
||||||
rerenderedControls[controlName] = {
|
|
||||||
...getControlStateFromControlConfig(
|
|
||||||
newState.controls[controlName],
|
|
||||||
newState,
|
|
||||||
newState.controls[controlName].value,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// combine newly detected errors with errors from `onChange` event of
|
|
||||||
// each control component (passed via reducer action).
|
|
||||||
const errors = control.validationErrors || [];
|
|
||||||
(validationErrors || []).forEach(err => {
|
|
||||||
// skip duplicated errors
|
|
||||||
if (!errors.includes(err)) {
|
|
||||||
errors.push(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const hasErrors = errors && errors.length > 0;
|
|
||||||
|
|
||||||
const isVizSwitch =
|
|
||||||
action.controlName === 'viz_type' &&
|
|
||||||
action.value !== state.controls.viz_type.value;
|
|
||||||
let currentControlsState = state.controls;
|
|
||||||
if (isVizSwitch) {
|
|
||||||
// get StandardizedFormData from source form_data
|
|
||||||
const sfd = new StandardizedFormData(state.form_data);
|
|
||||||
const transformed = sfd.transform(action.value, state);
|
|
||||||
new_form_data = transformed.formData;
|
|
||||||
currentControlsState = transformed.controlsState;
|
|
||||||
}
|
|
||||||
|
|
||||||
const dependantControls = Object.entries(state.controls)
|
|
||||||
.filter(
|
|
||||||
([, item]) =>
|
|
||||||
Array.isArray(item?.validationDependencies) &&
|
|
||||||
item.validationDependencies.includes(controlName),
|
|
||||||
)
|
|
||||||
.map(([key, item]) => ({
|
|
||||||
controlState: item,
|
|
||||||
dependantControlName: key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
let updatedControlStates = {};
|
|
||||||
if (dependantControls.length > 0) {
|
|
||||||
const updatedControls = dependantControls.map(
|
|
||||||
({ controlState, dependantControlName }) => {
|
|
||||||
// overwrite state form data with current control value as the redux state will not
|
|
||||||
// have latest action value
|
|
||||||
const overWrittenState = {
|
|
||||||
...state,
|
|
||||||
form_data: {
|
|
||||||
...state.form_data,
|
|
||||||
[controlName]: action.value,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
// Re run validation for dependent controls
|
|
||||||
controlState: getControlStateFromControlConfig(
|
|
||||||
controlState,
|
|
||||||
overWrittenState,
|
|
||||||
controlState?.value,
|
|
||||||
),
|
|
||||||
dependantControlName,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
updatedControlStates = updatedControls.reduce(
|
|
||||||
(acc, { controlState, dependantControlName }) => {
|
|
||||||
acc[dependantControlName] = { ...controlState };
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
form_data: new_form_data,
|
|
||||||
triggerRender: control.renderTrigger && !hasErrors,
|
|
||||||
controls: {
|
|
||||||
...currentControlsState,
|
|
||||||
...(controlConfig && {
|
|
||||||
[action.controlName]: {
|
|
||||||
...control,
|
|
||||||
validationErrors: errors,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
...rerenderedControls,
|
|
||||||
...updatedControlStates,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_EXPLORE_CONTROLS]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
controls: getControlsState(state, action.formData),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_FORM_DATA]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
form_data: action.formData,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.UPDATE_CHART_TITLE]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
sliceName: action.sliceName,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_SAVE_ACTION]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
saveAction: action.saveAction,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.CREATE_NEW_SLICE]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
slice: action.slice,
|
|
||||||
controls: getControlsState(state, action.form_data),
|
|
||||||
can_add: action.can_add,
|
|
||||||
can_download: action.can_download,
|
|
||||||
can_overwrite: action.can_overwrite,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_STASH_FORM_DATA]() {
|
|
||||||
const { form_data, hiddenFormData } = state;
|
|
||||||
const { fieldNames, isHidden } = action;
|
|
||||||
if (isHidden) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
hiddenFormData: {
|
|
||||||
...hiddenFormData,
|
|
||||||
...pick(form_data, fieldNames),
|
|
||||||
},
|
|
||||||
form_data: omit(form_data, fieldNames),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const restoredField = pick(hiddenFormData, fieldNames);
|
|
||||||
return Object.keys(restoredField).length === 0
|
|
||||||
? state
|
|
||||||
: {
|
|
||||||
...state,
|
|
||||||
form_data: {
|
|
||||||
...form_data,
|
|
||||||
...restoredField,
|
|
||||||
},
|
|
||||||
hiddenFormData: omit(hiddenFormData, fieldNames),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SLICE_UPDATED]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
slice: {
|
|
||||||
...state.slice,
|
|
||||||
...action.slice,
|
|
||||||
owners: action.slice.owners
|
|
||||||
? action.slice.owners.map(owner => owner.value)
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
sliceName: action.slice.slice_name ?? state.sliceName,
|
|
||||||
metadata: {
|
|
||||||
...state.metadata,
|
|
||||||
owners: action.slice.owners
|
|
||||||
? action.slice.owners.map(owner => owner.label)
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[actions.SET_FORCE_QUERY]() {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
force: action.force,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[HYDRATE_EXPLORE]() {
|
|
||||||
return {
|
|
||||||
...action.data.explore,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
if (action.type in actionHandlers) {
|
|
||||||
return actionHandlers[action.type]();
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
@@ -17,29 +17,38 @@
|
|||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import exploreReducer from './exploreReducer';
|
import exploreReducer, { ExploreState } from './exploreReducer';
|
||||||
import { setStashFormData } from '../actions/exploreActions';
|
import { setStashFormData } from '../actions/exploreActions';
|
||||||
|
import { QueryFormData } from '@superset-ui/core';
|
||||||
|
|
||||||
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
|
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
|
||||||
const initialState = {
|
const initialState: ExploreState = {
|
||||||
form_data: { a: 3, c: 4 },
|
form_data: { a: 3, c: 4 } as unknown as QueryFormData,
|
||||||
|
controls: {},
|
||||||
};
|
};
|
||||||
const action = setStashFormData(true, ['a', 'c']);
|
const action = setStashFormData(true, ['a', 'c']) as Parameters<
|
||||||
|
typeof exploreReducer
|
||||||
|
>[1];
|
||||||
const newState = exploreReducer(initialState, action);
|
const newState = exploreReducer(initialState, action);
|
||||||
expect(newState.form_data).toEqual({});
|
expect(newState.form_data).toEqual({});
|
||||||
expect(newState.hiddenFormData).toEqual({ a: 3, c: 4 });
|
expect(newState.hiddenFormData).toEqual({ a: 3, c: 4 });
|
||||||
const restoreAction = setStashFormData(false, ['c']);
|
const restoreAction = setStashFormData(false, ['c']) as Parameters<
|
||||||
|
typeof exploreReducer
|
||||||
|
>[1];
|
||||||
const newState2 = exploreReducer(newState, restoreAction);
|
const newState2 = exploreReducer(newState, restoreAction);
|
||||||
expect(newState2.form_data).toEqual({ c: 4 });
|
expect(newState2.form_data).toEqual({ c: 4 });
|
||||||
expect(newState2.hiddenFormData).toEqual({ a: 3 });
|
expect(newState2.hiddenFormData).toEqual({ a: 3 });
|
||||||
});
|
});
|
||||||
|
|
||||||
test('skips updates when the field is already updated on SET_STASH_FORM_DATA', () => {
|
test('skips updates when the field is already updated on SET_STASH_FORM_DATA', () => {
|
||||||
const initialState = {
|
const initialState: ExploreState = {
|
||||||
form_data: { a: 3, c: 4 },
|
form_data: { a: 3, c: 4 } as unknown as QueryFormData,
|
||||||
hiddenFormData: { b: 2 },
|
hiddenFormData: { b: 2 } as unknown as Partial<QueryFormData>,
|
||||||
|
controls: {},
|
||||||
};
|
};
|
||||||
const restoreAction = setStashFormData(false, ['c', 'd']);
|
const restoreAction = setStashFormData(false, ['c', 'd']) as Parameters<
|
||||||
|
typeof exploreReducer
|
||||||
|
>[1];
|
||||||
const newState = exploreReducer(initialState, restoreAction);
|
const newState = exploreReducer(initialState, restoreAction);
|
||||||
expect(newState).toBe(initialState);
|
expect(newState).toBe(initialState);
|
||||||
});
|
});
|
||||||
633
superset-frontend/src/explore/reducers/exploreReducer.ts
Normal file
633
superset-frontend/src/explore/reducers/exploreReducer.ts
Normal file
@@ -0,0 +1,633 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
/* eslint camelcase: 0 */
|
||||||
|
import { ensureIsArray, QueryFormData, JsonValue } from '@superset-ui/core';
|
||||||
|
import {
|
||||||
|
ControlState,
|
||||||
|
ControlStateMapping,
|
||||||
|
Dataset,
|
||||||
|
} from '@superset-ui/chart-controls';
|
||||||
|
import { omit, pick } from 'lodash';
|
||||||
|
import { DYNAMIC_PLUGIN_CONTROLS_READY } from 'src/components/Chart/chartAction';
|
||||||
|
import { getControlsState } from 'src/explore/store';
|
||||||
|
import {
|
||||||
|
getControlConfig,
|
||||||
|
getControlStateFromControlConfig,
|
||||||
|
getControlValuesCompatibleWithDatasource,
|
||||||
|
StandardizedFormData,
|
||||||
|
} from 'src/explore/controlUtils';
|
||||||
|
import * as actions from 'src/explore/actions/exploreActions';
|
||||||
|
import { HYDRATE_EXPLORE, HydrateExplore } from '../actions/hydrateExplore';
|
||||||
|
import { Slice } from 'src/types/Chart';
|
||||||
|
import { SaveActionType } from 'src/explore/types';
|
||||||
|
|
||||||
|
// Type definitions for explore state
|
||||||
|
export interface ExploreState {
|
||||||
|
can_add?: boolean;
|
||||||
|
can_download?: boolean;
|
||||||
|
can_overwrite?: boolean;
|
||||||
|
isDatasourceMetaLoading?: boolean;
|
||||||
|
isDatasourcesLoading?: boolean;
|
||||||
|
isStarred?: boolean;
|
||||||
|
triggerRender?: boolean;
|
||||||
|
datasource?: Dataset;
|
||||||
|
controls: ControlStateMapping;
|
||||||
|
form_data: QueryFormData;
|
||||||
|
hiddenFormData?: Partial<QueryFormData>;
|
||||||
|
slice?: Slice | null;
|
||||||
|
sliceName?: string;
|
||||||
|
controlsTransferred?: string[];
|
||||||
|
standalone?: boolean;
|
||||||
|
force?: boolean;
|
||||||
|
common?: {
|
||||||
|
conf: {
|
||||||
|
DEFAULT_VIZ_TYPE?: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
metadata?: {
|
||||||
|
owners?: string[] | null;
|
||||||
|
};
|
||||||
|
saveAction?: SaveActionType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Action type definitions
|
||||||
|
interface DynamicPluginControlsReadyAction {
|
||||||
|
type: typeof DYNAMIC_PLUGIN_CONTROLS_READY;
|
||||||
|
controlsState: ControlStateMapping;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToggleFaveStarAction {
|
||||||
|
type: typeof actions.TOGGLE_FAVE_STAR;
|
||||||
|
isStarred: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PostDatasourceStartedAction {
|
||||||
|
type: typeof actions.POST_DATASOURCE_STARTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StartMetadataLoadingAction {
|
||||||
|
type: typeof actions.START_METADATA_LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StopMetadataLoadingAction {
|
||||||
|
type: typeof actions.STOP_METADATA_LOADING;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SyncDatasourceMetadataAction {
|
||||||
|
type: typeof actions.SYNC_DATASOURCE_METADATA;
|
||||||
|
datasource: Dataset;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateFormDataByDatasourceAction {
|
||||||
|
type: typeof actions.UPDATE_FORM_DATA_BY_DATASOURCE;
|
||||||
|
prevDatasource: Dataset;
|
||||||
|
newDatasource: Dataset & { uid: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchDatasourcesStartedAction {
|
||||||
|
type: typeof actions.FETCH_DATASOURCES_STARTED;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetFieldValueAction {
|
||||||
|
type: typeof actions.SET_FIELD_VALUE;
|
||||||
|
controlName: string;
|
||||||
|
value: unknown;
|
||||||
|
validationErrors?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetExploreControlsAction {
|
||||||
|
type: typeof actions.SET_EXPLORE_CONTROLS;
|
||||||
|
formData: QueryFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetFormDataAction {
|
||||||
|
type: typeof actions.SET_FORM_DATA;
|
||||||
|
formData: QueryFormData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdateChartTitleAction {
|
||||||
|
type: typeof actions.UPDATE_CHART_TITLE;
|
||||||
|
sliceName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetSaveActionAction {
|
||||||
|
type: typeof actions.SET_SAVE_ACTION;
|
||||||
|
saveAction: SaveActionType | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CreateNewSliceAction {
|
||||||
|
type: typeof actions.CREATE_NEW_SLICE;
|
||||||
|
slice: Slice;
|
||||||
|
form_data: QueryFormData;
|
||||||
|
can_add: boolean;
|
||||||
|
can_download: boolean;
|
||||||
|
can_overwrite: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetStashFormDataAction {
|
||||||
|
type: typeof actions.SET_STASH_FORM_DATA;
|
||||||
|
fieldNames: readonly string[];
|
||||||
|
isHidden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Owner can be either a number (user ID) or an object with value/label
|
||||||
|
// This handles both Slice format (number[]) and select control format ({value, label}[])
|
||||||
|
type OwnerItem = number | { value: number; label: string };
|
||||||
|
|
||||||
|
interface SliceUpdatedAction {
|
||||||
|
type: typeof actions.SLICE_UPDATED;
|
||||||
|
slice: Omit<Slice, 'owners'> & {
|
||||||
|
owners?: OwnerItem[];
|
||||||
|
slice_name?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SetForceQueryAction {
|
||||||
|
type: typeof actions.SET_FORCE_QUERY;
|
||||||
|
force: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ExploreAction =
|
||||||
|
| DynamicPluginControlsReadyAction
|
||||||
|
| ToggleFaveStarAction
|
||||||
|
| PostDatasourceStartedAction
|
||||||
|
| StartMetadataLoadingAction
|
||||||
|
| StopMetadataLoadingAction
|
||||||
|
| SyncDatasourceMetadataAction
|
||||||
|
| UpdateFormDataByDatasourceAction
|
||||||
|
| FetchDatasourcesStartedAction
|
||||||
|
| SetFieldValueAction
|
||||||
|
| SetExploreControlsAction
|
||||||
|
| SetFormDataAction
|
||||||
|
| UpdateChartTitleAction
|
||||||
|
| SetSaveActionAction
|
||||||
|
| CreateNewSliceAction
|
||||||
|
| SetStashFormDataAction
|
||||||
|
| SliceUpdatedAction
|
||||||
|
| SetForceQueryAction
|
||||||
|
| HydrateExplore;
|
||||||
|
|
||||||
|
// Extended control state for dynamic form controls - uses Record for flexibility
|
||||||
|
// since control configs vary significantly across different control types
|
||||||
|
interface ExtendedControlState {
|
||||||
|
[key: string]: unknown;
|
||||||
|
value?: unknown;
|
||||||
|
valueKey?: string;
|
||||||
|
savedMetrics?: unknown[];
|
||||||
|
columns?: unknown[];
|
||||||
|
options?: unknown[];
|
||||||
|
default?: unknown;
|
||||||
|
rerender?: string[];
|
||||||
|
renderTrigger?: boolean;
|
||||||
|
validationErrors?: string[];
|
||||||
|
validationDependencies?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MetricItem {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionHandlers = {
|
||||||
|
[key: string]: () => Partial<ExploreState> | ExploreState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function exploreReducer(
|
||||||
|
state: ExploreState = { controls: {}, form_data: {} as QueryFormData },
|
||||||
|
action: ExploreAction,
|
||||||
|
): ExploreState {
|
||||||
|
const actionHandlers: ActionHandlers = {
|
||||||
|
[DYNAMIC_PLUGIN_CONTROLS_READY]() {
|
||||||
|
const typedAction = action as DynamicPluginControlsReadyAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
controls: typedAction.controlsState,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.TOGGLE_FAVE_STAR]() {
|
||||||
|
const typedAction = action as ToggleFaveStarAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isStarred: typedAction.isStarred,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.POST_DATASOURCE_STARTED]() {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDatasourceMetaLoading: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.START_METADATA_LOADING]() {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDatasourceMetaLoading: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.STOP_METADATA_LOADING]() {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDatasourceMetaLoading: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SYNC_DATASOURCE_METADATA]() {
|
||||||
|
const typedAction = action as SyncDatasourceMetadataAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
datasource: typedAction.datasource,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.UPDATE_FORM_DATA_BY_DATASOURCE]() {
|
||||||
|
const typedAction = action as UpdateFormDataByDatasourceAction;
|
||||||
|
const newFormData = { ...state.form_data } as QueryFormData & {
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
const { prevDatasource, newDatasource } = typedAction;
|
||||||
|
const controls = { ...state.controls } as Record<
|
||||||
|
string,
|
||||||
|
ExtendedControlState
|
||||||
|
>;
|
||||||
|
const controlsTransferred: string[] = [];
|
||||||
|
|
||||||
|
if (
|
||||||
|
prevDatasource.id !== newDatasource.id ||
|
||||||
|
prevDatasource.type !== newDatasource.type
|
||||||
|
) {
|
||||||
|
newFormData.datasource = newDatasource.uid;
|
||||||
|
}
|
||||||
|
// reset control values for column/metric related controls
|
||||||
|
Object.entries(controls).forEach(([controlName, controlState]) => {
|
||||||
|
if (
|
||||||
|
// for direct column select controls
|
||||||
|
controlState.valueKey === 'column_name' ||
|
||||||
|
// for all other controls
|
||||||
|
'savedMetrics' in controlState ||
|
||||||
|
'columns' in controlState ||
|
||||||
|
('options' in controlState && !Array.isArray(controlState.options))
|
||||||
|
) {
|
||||||
|
newFormData[controlName] = getControlValuesCompatibleWithDatasource(
|
||||||
|
newDatasource,
|
||||||
|
controlState as unknown as ControlState,
|
||||||
|
controlState.value as JsonValue,
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
ensureIsArray(newFormData[controlName]).length > 0 &&
|
||||||
|
newFormData[controlName] !== controls[controlName].default
|
||||||
|
) {
|
||||||
|
controlsTransferred.push(controlName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const newState: ExploreState = {
|
||||||
|
...state,
|
||||||
|
controls: controls as ControlStateMapping,
|
||||||
|
datasource: newDatasource,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
...newState,
|
||||||
|
form_data: newFormData as QueryFormData,
|
||||||
|
controls: getControlsState(
|
||||||
|
newState as Parameters<typeof getControlsState>[0],
|
||||||
|
newFormData as QueryFormData,
|
||||||
|
) as ControlStateMapping,
|
||||||
|
controlsTransferred,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.FETCH_DATASOURCES_STARTED]() {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
isDatasourcesLoading: true,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_FIELD_VALUE]() {
|
||||||
|
const typedAction = action as SetFieldValueAction;
|
||||||
|
const { controlName, value, validationErrors } = typedAction;
|
||||||
|
let new_form_data: QueryFormData & { [key: string]: unknown } = {
|
||||||
|
...state.form_data,
|
||||||
|
[controlName]: value,
|
||||||
|
};
|
||||||
|
const old_metrics_data = (state.form_data as { metrics?: MetricItem[] })
|
||||||
|
.metrics;
|
||||||
|
const new_column_config = (
|
||||||
|
state.form_data as { column_config?: Record<string, unknown> }
|
||||||
|
).column_config
|
||||||
|
? {
|
||||||
|
...(state.form_data as { column_config?: Record<string, unknown> })
|
||||||
|
.column_config,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const vizType = new_form_data.viz_type;
|
||||||
|
|
||||||
|
// if the controlName is metrics, and the metric column name is updated,
|
||||||
|
// need to update column config as well to keep the previous config.
|
||||||
|
if (controlName === 'metrics' && old_metrics_data && new_column_config) {
|
||||||
|
(value as MetricItem[]).forEach((item, index) => {
|
||||||
|
const itemExist = old_metrics_data.some(
|
||||||
|
oldItem => oldItem?.label === item?.label,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!itemExist &&
|
||||||
|
item?.label !== old_metrics_data[index]?.label &&
|
||||||
|
old_metrics_data[index]?.label &&
|
||||||
|
new_column_config[old_metrics_data[index].label!]
|
||||||
|
) {
|
||||||
|
new_column_config[item.label!] =
|
||||||
|
new_column_config[old_metrics_data[index].label!];
|
||||||
|
|
||||||
|
delete new_column_config[old_metrics_data[index].label!];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
new_form_data.column_config = new_column_config;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the processed control config (with overrides and everything)
|
||||||
|
// if `controlName` does not exist in current controls,
|
||||||
|
const controlConfig =
|
||||||
|
state.controls[controlName] ||
|
||||||
|
getControlConfig(controlName, vizType) ||
|
||||||
|
null;
|
||||||
|
|
||||||
|
// will call validators again
|
||||||
|
const control: ExtendedControlState = {
|
||||||
|
...getControlStateFromControlConfig(
|
||||||
|
controlConfig as Parameters<
|
||||||
|
typeof getControlStateFromControlConfig
|
||||||
|
>[0],
|
||||||
|
state as Parameters<typeof getControlStateFromControlConfig>[1],
|
||||||
|
value as JsonValue,
|
||||||
|
),
|
||||||
|
} as ExtendedControlState;
|
||||||
|
|
||||||
|
const column_config = {
|
||||||
|
...state.controls.column_config,
|
||||||
|
...(new_column_config && { value: new_column_config }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...state,
|
||||||
|
controls: {
|
||||||
|
...state.controls,
|
||||||
|
...(controlConfig && { [controlName]: control }),
|
||||||
|
...(controlName === 'metrics' && { column_config }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const rerenderedControls: Record<string, ExtendedControlState> = {};
|
||||||
|
if (Array.isArray(control.rerender)) {
|
||||||
|
control.rerender.forEach((rerenderControlName: string) => {
|
||||||
|
const rerenderControl = (
|
||||||
|
newState.controls as Record<string, ControlState>
|
||||||
|
)[rerenderControlName];
|
||||||
|
rerenderedControls[rerenderControlName] = {
|
||||||
|
...getControlStateFromControlConfig(
|
||||||
|
rerenderControl as Parameters<
|
||||||
|
typeof getControlStateFromControlConfig
|
||||||
|
>[0],
|
||||||
|
newState as Parameters<
|
||||||
|
typeof getControlStateFromControlConfig
|
||||||
|
>[1],
|
||||||
|
rerenderControl?.value,
|
||||||
|
),
|
||||||
|
} as ExtendedControlState;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// combine newly detected errors with errors from `onChange` event of
|
||||||
|
// each control component (passed via reducer action).
|
||||||
|
const errors: string[] = control.validationErrors || [];
|
||||||
|
(validationErrors || []).forEach(err => {
|
||||||
|
// skip duplicated errors
|
||||||
|
if (!errors.includes(err)) {
|
||||||
|
errors.push(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const hasErrors = errors && errors.length > 0;
|
||||||
|
|
||||||
|
const isVizSwitch =
|
||||||
|
controlName === 'viz_type' && value !== state.controls.viz_type?.value;
|
||||||
|
let currentControlsState = state.controls;
|
||||||
|
if (isVizSwitch) {
|
||||||
|
// get StandardizedFormData from source form_data
|
||||||
|
const sfd = new StandardizedFormData(state.form_data);
|
||||||
|
const transformed = sfd.transform(value as string, state);
|
||||||
|
new_form_data = transformed.formData;
|
||||||
|
currentControlsState = transformed.controlsState;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controlsTyped = state.controls as Record<
|
||||||
|
string,
|
||||||
|
ExtendedControlState
|
||||||
|
>;
|
||||||
|
const dependantControls = Object.entries(controlsTyped)
|
||||||
|
.filter(
|
||||||
|
([, item]) =>
|
||||||
|
Array.isArray(item?.validationDependencies) &&
|
||||||
|
item.validationDependencies.includes(controlName),
|
||||||
|
)
|
||||||
|
.map(([key, item]) => ({
|
||||||
|
controlState: item,
|
||||||
|
dependantControlName: key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let updatedControlStates: Record<string, ExtendedControlState> = {};
|
||||||
|
if (dependantControls.length > 0) {
|
||||||
|
const updatedControls = dependantControls.map(
|
||||||
|
({ controlState, dependantControlName }) => {
|
||||||
|
// overwrite state form data with current control value as the redux state will not
|
||||||
|
// have latest action value
|
||||||
|
const overWrittenState = {
|
||||||
|
...state,
|
||||||
|
form_data: {
|
||||||
|
...state.form_data,
|
||||||
|
[controlName]: value,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Re run validation for dependent controls
|
||||||
|
controlState: getControlStateFromControlConfig(
|
||||||
|
controlState as Parameters<
|
||||||
|
typeof getControlStateFromControlConfig
|
||||||
|
>[0],
|
||||||
|
overWrittenState as Parameters<
|
||||||
|
typeof getControlStateFromControlConfig
|
||||||
|
>[1],
|
||||||
|
controlState?.value as JsonValue | undefined,
|
||||||
|
),
|
||||||
|
dependantControlName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
updatedControlStates = updatedControls.reduce(
|
||||||
|
(
|
||||||
|
acc: Record<string, ExtendedControlState>,
|
||||||
|
{ controlState, dependantControlName },
|
||||||
|
) => {
|
||||||
|
acc[dependantControlName] = {
|
||||||
|
...controlState,
|
||||||
|
} as ExtendedControlState;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
form_data: new_form_data as QueryFormData,
|
||||||
|
triggerRender: control.renderTrigger && !hasErrors,
|
||||||
|
controls: {
|
||||||
|
...currentControlsState,
|
||||||
|
...(controlConfig && {
|
||||||
|
[controlName]: {
|
||||||
|
...control,
|
||||||
|
validationErrors: errors,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
...rerenderedControls,
|
||||||
|
...updatedControlStates,
|
||||||
|
} as ControlStateMapping,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_EXPLORE_CONTROLS]() {
|
||||||
|
const typedAction = action as SetExploreControlsAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
controls: getControlsState(
|
||||||
|
state as Parameters<typeof getControlsState>[0],
|
||||||
|
typedAction.formData,
|
||||||
|
) as ControlStateMapping,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_FORM_DATA]() {
|
||||||
|
const typedAction = action as SetFormDataAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
form_data: typedAction.formData,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.UPDATE_CHART_TITLE]() {
|
||||||
|
const typedAction = action as UpdateChartTitleAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
sliceName: typedAction.sliceName,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_SAVE_ACTION]() {
|
||||||
|
const typedAction = action as SetSaveActionAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
saveAction: typedAction.saveAction,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.CREATE_NEW_SLICE]() {
|
||||||
|
const typedAction = action as CreateNewSliceAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
slice: typedAction.slice,
|
||||||
|
controls: getControlsState(
|
||||||
|
state as Parameters<typeof getControlsState>[0],
|
||||||
|
typedAction.form_data,
|
||||||
|
) as ControlStateMapping,
|
||||||
|
can_add: typedAction.can_add,
|
||||||
|
can_download: typedAction.can_download,
|
||||||
|
can_overwrite: typedAction.can_overwrite,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_STASH_FORM_DATA]() {
|
||||||
|
const typedAction = action as SetStashFormDataAction;
|
||||||
|
const { form_data, hiddenFormData } = state;
|
||||||
|
const { fieldNames, isHidden } = typedAction;
|
||||||
|
if (isHidden) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
hiddenFormData: {
|
||||||
|
...hiddenFormData,
|
||||||
|
...pick(form_data, fieldNames as string[]),
|
||||||
|
},
|
||||||
|
form_data: omit(form_data, fieldNames as string[]) as QueryFormData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const restoredField = pick(
|
||||||
|
hiddenFormData,
|
||||||
|
fieldNames as string[],
|
||||||
|
) as Partial<QueryFormData>;
|
||||||
|
return Object.keys(restoredField).length === 0
|
||||||
|
? state
|
||||||
|
: {
|
||||||
|
...state,
|
||||||
|
form_data: {
|
||||||
|
...form_data,
|
||||||
|
...restoredField,
|
||||||
|
} as QueryFormData,
|
||||||
|
hiddenFormData: omit(
|
||||||
|
hiddenFormData,
|
||||||
|
fieldNames as string[],
|
||||||
|
) as Partial<QueryFormData>,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SLICE_UPDATED]() {
|
||||||
|
const typedAction = action as SliceUpdatedAction;
|
||||||
|
// Handle owners that can be either number[] or Array<{value, label}>
|
||||||
|
const getOwnerId = (owner: OwnerItem): number =>
|
||||||
|
typeof owner === 'number' ? owner : owner.value;
|
||||||
|
const getOwnerLabel = (owner: OwnerItem): string | null =>
|
||||||
|
typeof owner === 'number' ? null : owner.label;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
slice: {
|
||||||
|
...state.slice,
|
||||||
|
...typedAction.slice,
|
||||||
|
owners: typedAction.slice.owners
|
||||||
|
? typedAction.slice.owners.map(getOwnerId)
|
||||||
|
: null,
|
||||||
|
} as Slice,
|
||||||
|
sliceName: typedAction.slice.slice_name ?? state.sliceName,
|
||||||
|
metadata: {
|
||||||
|
...state.metadata,
|
||||||
|
owners: typedAction.slice.owners
|
||||||
|
? (typedAction.slice.owners
|
||||||
|
.map(getOwnerLabel)
|
||||||
|
.filter((x): x is string => x !== null) as string[])
|
||||||
|
: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[actions.SET_FORCE_QUERY]() {
|
||||||
|
const typedAction = action as SetForceQueryAction;
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
force: typedAction.force,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[HYDRATE_EXPLORE]() {
|
||||||
|
const typedAction = action as HydrateExplore;
|
||||||
|
return {
|
||||||
|
...typedAction.data.explore,
|
||||||
|
} as ExploreState;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if (action.type in actionHandlers) {
|
||||||
|
return actionHandlers[action.type]() as ExploreState;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -88,6 +88,7 @@ export interface ExplorePageInitialData {
|
|||||||
owners: string[];
|
owners: string[];
|
||||||
created_by?: string;
|
created_by?: string;
|
||||||
changed_by?: string;
|
changed_by?: string;
|
||||||
|
color_namespace?: string;
|
||||||
dashboards?: {
|
dashboards?: {
|
||||||
id: number;
|
id: number;
|
||||||
dashboard_title: string;
|
dashboard_title: string;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import {
|
|||||||
fetchUISpecificReport,
|
fetchUISpecificReport,
|
||||||
toggleActive,
|
toggleActive,
|
||||||
} from 'src/features/reports/ReportModal/actions';
|
} from 'src/features/reports/ReportModal/actions';
|
||||||
|
import { ReportObject } from 'src/features/reports/types';
|
||||||
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
|
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
|
||||||
|
|
||||||
const extensionsRegistry = getExtensionsRegistry();
|
const extensionsRegistry = getExtensionsRegistry();
|
||||||
@@ -116,7 +117,7 @@ export const useHeaderReportMenuItems = ({
|
|||||||
|
|
||||||
// Fetch report data when needed
|
// Fetch report data when needed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (shouldFetch) {
|
if (shouldFetch && resourceId) {
|
||||||
dispatch(
|
dispatch(
|
||||||
fetchUISpecificReport({
|
fetchUISpecificReport({
|
||||||
userId: user.userId,
|
userId: user.userId,
|
||||||
@@ -137,8 +138,8 @@ export const useHeaderReportMenuItems = ({
|
|||||||
const handleShowModal = () => showReportModal();
|
const handleShowModal = () => showReportModal();
|
||||||
const handleDeleteReport = () => setCurrentReportDeleting(report);
|
const handleDeleteReport = () => setCurrentReportDeleting(report);
|
||||||
const handleToggleActive = () => {
|
const handleToggleActive = () => {
|
||||||
if (report?.id) {
|
if (report?.id && report.active !== undefined) {
|
||||||
dispatch(toggleActive(report, !report.active));
|
dispatch(toggleActive(report as unknown as ReportObject, !report.active));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -146,7 +147,7 @@ export const useHeaderReportMenuItems = ({
|
|||||||
if (!report || !report.id) {
|
if (!report || !report.id) {
|
||||||
return {
|
return {
|
||||||
key: 'email-report-setup',
|
key: 'email-report-setup',
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Manage email report'),
|
label: t('Manage email report'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -168,7 +169,7 @@ export const useHeaderReportMenuItems = ({
|
|||||||
// If report exists, show management options
|
// If report exists, show management options
|
||||||
return {
|
return {
|
||||||
key: 'email-report-manage',
|
key: 'email-report-manage',
|
||||||
type: 'submenu',
|
type: 'submenu' as const,
|
||||||
label: t('Manage email report'),
|
label: t('Manage email report'),
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,162 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
/* eslint camelcase: 0 */
|
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
|
||||||
import { t } from '@apache-superset/core/ui';
|
|
||||||
import rison from 'rison';
|
|
||||||
import {
|
|
||||||
addDangerToast,
|
|
||||||
addSuccessToast,
|
|
||||||
} from 'src/components/MessageToasts/actions';
|
|
||||||
import { isEmpty } from 'lodash';
|
|
||||||
|
|
||||||
export const SET_REPORT = 'SET_REPORT';
|
|
||||||
export function setReport(report, resourceId, creationMethod, filterField) {
|
|
||||||
return { type: SET_REPORT, report, resourceId, creationMethod, filterField };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function fetchUISpecificReport({
|
|
||||||
userId,
|
|
||||||
filterField,
|
|
||||||
creationMethod,
|
|
||||||
resourceId,
|
|
||||||
}) {
|
|
||||||
const queryParams = rison.encode({
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
col: filterField,
|
|
||||||
opr: 'eq',
|
|
||||||
value: resourceId,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
col: 'creation_method',
|
|
||||||
opr: 'eq',
|
|
||||||
value: creationMethod,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
col: 'created_by',
|
|
||||||
opr: 'rel_o_m',
|
|
||||||
value: userId,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
return function fetchUISpecificReportThunk(dispatch) {
|
|
||||||
return SupersetClient.get({
|
|
||||||
endpoint: `/api/v1/report/?q=${queryParams}`,
|
|
||||||
})
|
|
||||||
.then(({ json }) => {
|
|
||||||
dispatch(setReport(json, resourceId, creationMethod, filterField));
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
dispatch(
|
|
||||||
addDangerToast(
|
|
||||||
t(
|
|
||||||
'There was an issue fetching reports attached to this dashboard.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const structureFetchAction = (dispatch, getState) => {
|
|
||||||
const state = getState();
|
|
||||||
const { user, dashboardInfo, charts, explore } = state;
|
|
||||||
if (!isEmpty(dashboardInfo)) {
|
|
||||||
dispatch(
|
|
||||||
fetchUISpecificReport({
|
|
||||||
userId: user.userId,
|
|
||||||
filterField: 'dashboard_id',
|
|
||||||
creationMethod: 'dashboards',
|
|
||||||
resourceId: dashboardInfo.id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
const [chartArr] = Object.keys(charts);
|
|
||||||
dispatch(
|
|
||||||
fetchUISpecificReport({
|
|
||||||
userId: explore.user?.userId || user?.userId,
|
|
||||||
filterField: 'chart_id',
|
|
||||||
creationMethod: 'charts',
|
|
||||||
resourceId: charts[chartArr].id,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ADD_REPORT = 'ADD_REPORT';
|
|
||||||
|
|
||||||
export const addReport = report => dispatch =>
|
|
||||||
SupersetClient.post({
|
|
||||||
endpoint: `/api/v1/report/`,
|
|
||||||
jsonPayload: report,
|
|
||||||
}).then(({ json }) => {
|
|
||||||
dispatch({ type: ADD_REPORT, json });
|
|
||||||
dispatch(addSuccessToast(t('The report has been created')));
|
|
||||||
});
|
|
||||||
|
|
||||||
export const EDIT_REPORT = 'EDIT_REPORT';
|
|
||||||
|
|
||||||
export const editReport = (id, report) => dispatch =>
|
|
||||||
SupersetClient.put({
|
|
||||||
endpoint: `/api/v1/report/${id}`,
|
|
||||||
jsonPayload: report,
|
|
||||||
}).then(({ json }) => {
|
|
||||||
dispatch({ type: EDIT_REPORT, json });
|
|
||||||
dispatch(addSuccessToast(t('Report updated')));
|
|
||||||
});
|
|
||||||
|
|
||||||
export function toggleActive(report, isActive) {
|
|
||||||
return function toggleActiveThunk(dispatch) {
|
|
||||||
return SupersetClient.put({
|
|
||||||
endpoint: encodeURI(`/api/v1/report/${report.id}`),
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
active: isActive,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
dispatch(
|
|
||||||
addDangerToast(
|
|
||||||
t('We were unable to active or deactivate this report.'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
dispatch(structureFetchAction);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export const DELETE_REPORT = 'DELETE_REPORT';
|
|
||||||
|
|
||||||
export function deleteActiveReport(report) {
|
|
||||||
return function deleteActiveReportThunk(dispatch) {
|
|
||||||
return SupersetClient.delete({
|
|
||||||
endpoint: encodeURI(`/api/v1/report/${report.id}`),
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
dispatch(addDangerToast(t('Your report could not be deleted')));
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
dispatch({ type: DELETE_REPORT, report });
|
|
||||||
dispatch(addSuccessToast(t('Deleted: %s', report.name)));
|
|
||||||
});
|
|
||||||
};
|
|
||||||
}
|
|
||||||
248
superset-frontend/src/features/reports/ReportModal/actions.ts
Normal file
248
superset-frontend/src/features/reports/ReportModal/actions.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
/* eslint camelcase: 0 */
|
||||||
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
import { t } from '@apache-superset/core/ui';
|
||||||
|
import rison from 'rison';
|
||||||
|
import {
|
||||||
|
addDangerToast,
|
||||||
|
addSuccessToast,
|
||||||
|
} from 'src/components/MessageToasts/actions';
|
||||||
|
import { isEmpty } from 'lodash';
|
||||||
|
import { Dispatch, AnyAction } from 'redux';
|
||||||
|
import { ThunkDispatch } from 'redux-thunk';
|
||||||
|
import { ReportObject, ReportCreationMethod } from 'src/features/reports/types';
|
||||||
|
import { DashboardInfo, ChartsState } from 'src/dashboard/types';
|
||||||
|
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
|
||||||
|
import { ExplorePageState } from 'src/explore/types';
|
||||||
|
|
||||||
|
// Type definitions for report-related state
|
||||||
|
interface ReportApiResponse {
|
||||||
|
result?: ReportObject[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportApiJsonResponse {
|
||||||
|
result: Partial<ReportObject>;
|
||||||
|
id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ReportRootState {
|
||||||
|
user: UserWithPermissionsAndRoles;
|
||||||
|
dashboardInfo: DashboardInfo;
|
||||||
|
charts: ChartsState;
|
||||||
|
explore: ExplorePageState['explore'] & {
|
||||||
|
user?: UserWithPermissionsAndRoles;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReportFilterField = 'dashboard_id' | 'chart_id';
|
||||||
|
|
||||||
|
export const SET_REPORT = 'SET_REPORT' as const;
|
||||||
|
|
||||||
|
export interface SetReportAction {
|
||||||
|
type: typeof SET_REPORT;
|
||||||
|
report: ReportApiResponse;
|
||||||
|
resourceId: number;
|
||||||
|
creationMethod: ReportCreationMethod;
|
||||||
|
filterField: ReportFilterField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setReport(
|
||||||
|
report: ReportApiResponse,
|
||||||
|
resourceId: number,
|
||||||
|
creationMethod: ReportCreationMethod,
|
||||||
|
filterField: ReportFilterField,
|
||||||
|
): SetReportAction {
|
||||||
|
return { type: SET_REPORT, report, resourceId, creationMethod, filterField };
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FetchUISpecificReportParams {
|
||||||
|
userId: number | undefined;
|
||||||
|
filterField: ReportFilterField;
|
||||||
|
creationMethod: ReportCreationMethod;
|
||||||
|
resourceId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchUISpecificReport({
|
||||||
|
userId,
|
||||||
|
filterField,
|
||||||
|
creationMethod,
|
||||||
|
resourceId,
|
||||||
|
}: FetchUISpecificReportParams) {
|
||||||
|
const queryParams = rison.encode({
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
col: filterField,
|
||||||
|
opr: 'eq',
|
||||||
|
value: resourceId,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 'creation_method',
|
||||||
|
opr: 'eq',
|
||||||
|
value: creationMethod,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
col: 'created_by',
|
||||||
|
opr: 'rel_o_m',
|
||||||
|
value: userId,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
return function fetchUISpecificReportThunk(dispatch: Dispatch<AnyAction>) {
|
||||||
|
return SupersetClient.get({
|
||||||
|
endpoint: `/api/v1/report/?q=${queryParams}`,
|
||||||
|
})
|
||||||
|
.then(({ json }) => {
|
||||||
|
dispatch(
|
||||||
|
setReport(
|
||||||
|
json as ReportApiResponse,
|
||||||
|
resourceId,
|
||||||
|
creationMethod,
|
||||||
|
filterField,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.catch(() =>
|
||||||
|
dispatch(addDangerToast(t('There was an issue fetching reports.'))),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const structureFetchAction = (
|
||||||
|
dispatch: ThunkDispatch<ReportRootState, unknown, AnyAction>,
|
||||||
|
getState: () => ReportRootState,
|
||||||
|
) => {
|
||||||
|
const state = getState();
|
||||||
|
const { user, dashboardInfo, charts, explore } = state;
|
||||||
|
if (!isEmpty(dashboardInfo)) {
|
||||||
|
dispatch(
|
||||||
|
fetchUISpecificReport({
|
||||||
|
userId: user.userId,
|
||||||
|
filterField: 'dashboard_id',
|
||||||
|
creationMethod: 'dashboards',
|
||||||
|
resourceId: dashboardInfo.id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else if (!isEmpty(charts)) {
|
||||||
|
const [chartArr] = Object.keys(charts);
|
||||||
|
dispatch(
|
||||||
|
fetchUISpecificReport({
|
||||||
|
userId: explore.user?.userId || user?.userId,
|
||||||
|
filterField: 'chart_id',
|
||||||
|
creationMethod: 'charts',
|
||||||
|
resourceId: charts[chartArr].id,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ADD_REPORT = 'ADD_REPORT' as const;
|
||||||
|
|
||||||
|
export interface AddReportAction {
|
||||||
|
type: typeof ADD_REPORT;
|
||||||
|
json: ReportApiJsonResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const addReport =
|
||||||
|
(report: Partial<ReportObject>) => (dispatch: Dispatch<AnyAction>) =>
|
||||||
|
SupersetClient.post({
|
||||||
|
endpoint: `/api/v1/report/`,
|
||||||
|
jsonPayload: report,
|
||||||
|
})
|
||||||
|
.then(({ json }) => {
|
||||||
|
dispatch({ type: ADD_REPORT, json } as AddReportAction);
|
||||||
|
dispatch(addSuccessToast(t('The report has been created')));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(addDangerToast(t('Failed to create report')));
|
||||||
|
});
|
||||||
|
|
||||||
|
export const EDIT_REPORT = 'EDIT_REPORT' as const;
|
||||||
|
|
||||||
|
export interface EditReportAction {
|
||||||
|
type: typeof EDIT_REPORT;
|
||||||
|
json: ReportApiJsonResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const editReport =
|
||||||
|
(id: number, report: Partial<ReportObject>) =>
|
||||||
|
(dispatch: Dispatch<AnyAction>) =>
|
||||||
|
SupersetClient.put({
|
||||||
|
endpoint: `/api/v1/report/${id}`,
|
||||||
|
jsonPayload: report,
|
||||||
|
})
|
||||||
|
.then(({ json }) => {
|
||||||
|
dispatch({ type: EDIT_REPORT, json } as EditReportAction);
|
||||||
|
dispatch(addSuccessToast(t('Report updated')));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(addDangerToast(t('Failed to update report')));
|
||||||
|
});
|
||||||
|
|
||||||
|
export function toggleActive(report: ReportObject, isActive: boolean) {
|
||||||
|
return function toggleActiveThunk(
|
||||||
|
dispatch: ThunkDispatch<ReportRootState, unknown, AnyAction>,
|
||||||
|
) {
|
||||||
|
return SupersetClient.put({
|
||||||
|
endpoint: encodeURI(`/api/v1/report/${report.id}`),
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
active: isActive,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(
|
||||||
|
addDangerToast(
|
||||||
|
t('We were unable to activate or deactivate this report.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
dispatch(structureFetchAction);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DELETE_REPORT = 'DELETE_REPORT' as const;
|
||||||
|
|
||||||
|
export interface DeleteReportAction {
|
||||||
|
type: typeof DELETE_REPORT;
|
||||||
|
report: ReportObject;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteActiveReport(report: ReportObject) {
|
||||||
|
return function deleteActiveReportThunk(dispatch: Dispatch<AnyAction>) {
|
||||||
|
return SupersetClient.delete({
|
||||||
|
endpoint: encodeURI(`/api/v1/report/${report.id}`),
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
dispatch({ type: DELETE_REPORT, report } as DeleteReportAction);
|
||||||
|
dispatch(addSuccessToast(t('Deleted: %s', report.name)));
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
dispatch(addDangerToast(t('Your report could not be deleted')));
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReportAction =
|
||||||
|
| SetReportAction
|
||||||
|
| AddReportAction
|
||||||
|
| EditReportAction
|
||||||
|
| DeleteReportAction;
|
||||||
@@ -207,11 +207,11 @@ function ReportModal({
|
|||||||
|
|
||||||
setCurrentReport({ isSubmitting: true, error: undefined });
|
setCurrentReport({ isSubmitting: true, error: undefined });
|
||||||
try {
|
try {
|
||||||
if (isEditMode) {
|
if (isEditMode && currentReport.id) {
|
||||||
await dispatch(
|
await dispatch(
|
||||||
editReport(currentReport.id, newReportValues as ReportObject),
|
editReport(currentReport.id, newReportValues as ReportObject),
|
||||||
);
|
);
|
||||||
} else {
|
} else if (!isEditMode) {
|
||||||
await dispatch(addReport(newReportValues as ReportObject));
|
await dispatch(addReport(newReportValues as ReportObject));
|
||||||
}
|
}
|
||||||
onHide();
|
onHide();
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
/* eslint-disable camelcase */
|
|
||||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
|
||||||
import { omit } from 'lodash';
|
|
||||||
import { SET_REPORT, ADD_REPORT, EDIT_REPORT, DELETE_REPORT } from './actions';
|
|
||||||
|
|
||||||
export default function reportsReducer(state = {}, action) {
|
|
||||||
const actionHandlers = {
|
|
||||||
[SET_REPORT]() {
|
|
||||||
const { report, resourceId, creationMethod, filterField } = action;
|
|
||||||
// For now report count should only be one, but we are checking in case
|
|
||||||
// functionality changes.
|
|
||||||
const reportObject = report.result?.find(
|
|
||||||
report => report[filterField] === resourceId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (reportObject) {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
[creationMethod]: {
|
|
||||||
...state[creationMethod],
|
|
||||||
[resourceId]: reportObject,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (state?.[creationMethod]?.[resourceId]) {
|
|
||||||
// remove the empty report from state
|
|
||||||
const newState = { ...state };
|
|
||||||
delete newState[creationMethod][resourceId];
|
|
||||||
return newState;
|
|
||||||
}
|
|
||||||
return { ...state };
|
|
||||||
},
|
|
||||||
|
|
||||||
[ADD_REPORT]() {
|
|
||||||
const { result, id } = action.json;
|
|
||||||
const report = { ...result, id };
|
|
||||||
const reportTypeId = report.dashboard || report.chart;
|
|
||||||
// this is the id of either the chart or the dashboard associated with the report.
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
[report.creation_method]: {
|
|
||||||
...state[report.creation_method],
|
|
||||||
[reportTypeId]: report,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
[EDIT_REPORT]() {
|
|
||||||
const report = {
|
|
||||||
...action.json.result,
|
|
||||||
id: action.json.id,
|
|
||||||
};
|
|
||||||
const reportTypeId = report.dashboard || report.chart;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
[report.creation_method]: {
|
|
||||||
...state[report.creation_method],
|
|
||||||
[reportTypeId]: report,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
[DELETE_REPORT]() {
|
|
||||||
const { report } = action;
|
|
||||||
const reportTypeId = report.dashboard || report.chart;
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
[report.creation_method]: {
|
|
||||||
...omit(state[report.creation_method], reportTypeId),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
if (action.type in actionHandlers) {
|
|
||||||
return actionHandlers[action.type]();
|
|
||||||
}
|
|
||||||
return state;
|
|
||||||
}
|
|
||||||
159
superset-frontend/src/features/reports/ReportModal/reducer.ts
Normal file
159
superset-frontend/src/features/reports/ReportModal/reducer.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
/* eslint-disable camelcase */
|
||||||
|
import { omit } from 'lodash';
|
||||||
|
import {
|
||||||
|
SET_REPORT,
|
||||||
|
ADD_REPORT,
|
||||||
|
EDIT_REPORT,
|
||||||
|
DELETE_REPORT,
|
||||||
|
ReportAction,
|
||||||
|
SetReportAction,
|
||||||
|
AddReportAction,
|
||||||
|
EditReportAction,
|
||||||
|
DeleteReportAction,
|
||||||
|
} from './actions';
|
||||||
|
import { ReportObject, ReportCreationMethod } from 'src/features/reports/types';
|
||||||
|
|
||||||
|
// State structure: { dashboards: { [id]: ReportObject }, charts: { [id]: ReportObject } }
|
||||||
|
export interface ReportsState {
|
||||||
|
dashboards?: Record<number, ReportObject>;
|
||||||
|
charts?: Record<number, ReportObject>;
|
||||||
|
alerts_reports?: Record<number, ReportObject>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type ActionHandlers = {
|
||||||
|
[key: string]: () => ReportsState;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function reportsReducer(
|
||||||
|
state: ReportsState = {},
|
||||||
|
action: ReportAction,
|
||||||
|
): ReportsState {
|
||||||
|
const actionHandlers: ActionHandlers = {
|
||||||
|
[SET_REPORT]() {
|
||||||
|
const { report, resourceId, creationMethod, filterField } =
|
||||||
|
action as SetReportAction;
|
||||||
|
// Map filterField ('dashboard_id' or 'chart_id') to the corresponding
|
||||||
|
// ReportObject property ('dashboard' or 'chart')
|
||||||
|
const propertyName =
|
||||||
|
filterField === 'dashboard_id' ? 'dashboard' : 'chart';
|
||||||
|
// For now report count should only be one, but we are checking in case
|
||||||
|
// functionality changes.
|
||||||
|
const reportObject = report.result?.find(
|
||||||
|
(r: ReportObject) => r[propertyName] === resourceId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (reportObject) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[creationMethod]: {
|
||||||
|
...state[creationMethod],
|
||||||
|
[resourceId]: reportObject,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (state?.[creationMethod]?.[resourceId]) {
|
||||||
|
// remove the empty report from state
|
||||||
|
const methodState = state[creationMethod];
|
||||||
|
if (methodState) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[creationMethod]: omit(methodState, resourceId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { ...state };
|
||||||
|
},
|
||||||
|
|
||||||
|
[ADD_REPORT]() {
|
||||||
|
const { result, id } = (action as AddReportAction).json;
|
||||||
|
const report: ReportObject = { ...result, id } as ReportObject;
|
||||||
|
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||||
|
// For alerts_reports, use the report id; otherwise use the dashboard/chart id
|
||||||
|
const key =
|
||||||
|
creationMethod === 'alerts_reports'
|
||||||
|
? report.id
|
||||||
|
: (report.dashboard ?? report.chart);
|
||||||
|
|
||||||
|
if (key === undefined) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[creationMethod]: {
|
||||||
|
...state[creationMethod],
|
||||||
|
[key]: report,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
[EDIT_REPORT]() {
|
||||||
|
const actionTyped = action as EditReportAction;
|
||||||
|
const report: ReportObject = {
|
||||||
|
...actionTyped.json.result,
|
||||||
|
id: actionTyped.json.id,
|
||||||
|
} as ReportObject;
|
||||||
|
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||||
|
// For alerts_reports, use the report id; otherwise use the dashboard/chart id
|
||||||
|
const key =
|
||||||
|
creationMethod === 'alerts_reports'
|
||||||
|
? report.id
|
||||||
|
: (report.dashboard ?? report.chart);
|
||||||
|
|
||||||
|
if (key === undefined) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[creationMethod]: {
|
||||||
|
...state[creationMethod],
|
||||||
|
[key]: report,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
|
||||||
|
[DELETE_REPORT]() {
|
||||||
|
const { report } = action as DeleteReportAction;
|
||||||
|
const creationMethod = report.creation_method as ReportCreationMethod;
|
||||||
|
// For alerts_reports, use the report id; otherwise use the dashboard/chart id
|
||||||
|
const key =
|
||||||
|
creationMethod === 'alerts_reports'
|
||||||
|
? report.id
|
||||||
|
: (report.dashboard ?? report.chart);
|
||||||
|
|
||||||
|
if (key === undefined) {
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
const methodState = state[creationMethod];
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
[creationMethod]: methodState ? omit(methodState, key) : undefined,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (action.type in actionHandlers) {
|
||||||
|
return actionHandlers[action.type]();
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import sinon from 'sinon';
|
import sinon, { SinonSpy, SinonStub } from 'sinon';
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
import logger from 'src/middleware/loggerMiddleware';
|
import logger from 'src/middleware/loggerMiddleware';
|
||||||
import { LOG_EVENT } from 'src/logger/actions';
|
import { LOG_EVENT } from 'src/logger/actions';
|
||||||
@@ -24,11 +24,21 @@ import {
|
|||||||
LOG_ACTIONS_LOAD_CHART,
|
LOG_ACTIONS_LOAD_CHART,
|
||||||
LOG_ACTIONS_SPA_NAVIGATION,
|
LOG_ACTIONS_SPA_NAVIGATION,
|
||||||
} from 'src/logger/LogUtils';
|
} from 'src/logger/LogUtils';
|
||||||
|
import { Dispatch } from 'redux';
|
||||||
|
|
||||||
|
interface LogEventAction {
|
||||||
|
type: typeof LOG_EVENT;
|
||||||
|
payload: {
|
||||||
|
eventName: string;
|
||||||
|
eventData: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('logger middleware', () => {
|
describe('logger middleware', () => {
|
||||||
const dashboardId = 123;
|
const dashboardId = 123;
|
||||||
const next = sinon.spy();
|
const next: SinonSpy = sinon.spy();
|
||||||
|
// Mock store with minimal state needed for tests
|
||||||
const mockStore = {
|
const mockStore = {
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
dashboardInfo: {
|
dashboardInfo: {
|
||||||
@@ -36,8 +46,9 @@ describe('logger middleware', () => {
|
|||||||
},
|
},
|
||||||
impressionId: 'impression_id',
|
impressionId: 'impression_id',
|
||||||
}),
|
}),
|
||||||
|
dispatch: ((action: unknown) => action) as Dispatch,
|
||||||
};
|
};
|
||||||
const action = {
|
const action: LogEventAction = {
|
||||||
type: LOG_EVENT,
|
type: LOG_EVENT,
|
||||||
payload: {
|
payload: {
|
||||||
eventName: LOG_ACTIONS_LOAD_CHART,
|
eventName: LOG_ACTIONS_LOAD_CHART,
|
||||||
@@ -52,7 +63,7 @@ describe('logger middleware', () => {
|
|||||||
useFakeTimers: true,
|
useFakeTimers: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
let postStub;
|
let postStub: SinonStub;
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
postStub = sinon.stub(SupersetClient, 'post');
|
postStub = sinon.stub(SupersetClient, 'post');
|
||||||
});
|
});
|
||||||
@@ -69,61 +80,72 @@ describe('logger middleware', () => {
|
|||||||
some: 'data',
|
some: 'data',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
logger(mockStore)(next)(action1);
|
(logger as Function)(mockStore)(next)(action1);
|
||||||
expect(next.callCount).toBe(1);
|
expect(next.callCount).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should POST an event to /superset/log/ when called', () => {
|
test('should POST an event to /superset/log/ when called', () => {
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
expect(next.callCount).toBe(0);
|
expect(next.callCount).toBe(0);
|
||||||
|
|
||||||
timeSandbox.clock.tick(2000);
|
timeSandbox.clock.tick(2000);
|
||||||
expect(SupersetClient.post.callCount).toBe(1);
|
expect(postStub.callCount).toBe(1);
|
||||||
expect(SupersetClient.post.getCall(0).args[0].endpoint).toMatch(
|
expect(postStub.getCall(0).args[0].endpoint).toMatch('/superset/log/');
|
||||||
'/superset/log/',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
|
test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
|
||||||
const fetchLog = logger(mockStore)(next);
|
// Set window.location to include /dashboard/ so the middleware adds dashboard context
|
||||||
fetchLog({
|
const originalHref = window.location.href;
|
||||||
type: LOG_EVENT,
|
Object.defineProperty(window, 'location', {
|
||||||
payload: {
|
value: { href: `http://localhost/dashboard/${dashboardId}/` },
|
||||||
eventName: LOG_ACTIONS_SPA_NAVIGATION,
|
writable: true,
|
||||||
eventData: { path: `/dashboard/${dashboardId}/` },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
timeSandbox.clock.tick(2000);
|
|
||||||
fetchLog(action);
|
|
||||||
timeSandbox.clock.tick(2000);
|
|
||||||
expect(SupersetClient.post.callCount).toBe(2);
|
|
||||||
const { events } = SupersetClient.post.getCall(1).args[0].postPayload;
|
|
||||||
const mockEventdata = action.payload.eventData;
|
|
||||||
const mockEventname = action.payload.eventName;
|
|
||||||
expect(events[0]).toMatchObject({
|
|
||||||
key: mockEventdata.key,
|
|
||||||
event_name: mockEventname,
|
|
||||||
impression_id: mockStore.getState().impressionId,
|
|
||||||
source: 'dashboard',
|
|
||||||
source_id: mockStore.getState().dashboardInfo.id,
|
|
||||||
event_type: 'timing',
|
|
||||||
dashboard_id: mockStore.getState().dashboardInfo.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(typeof events[0].ts).toBe('number');
|
try {
|
||||||
expect(typeof events[0].start_offset).toBe('number');
|
const fetchLog = (logger as Function)(mockStore)(next);
|
||||||
|
fetchLog({
|
||||||
|
type: LOG_EVENT,
|
||||||
|
payload: {
|
||||||
|
eventName: LOG_ACTIONS_SPA_NAVIGATION,
|
||||||
|
eventData: { path: `/dashboard/${dashboardId}/` },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
timeSandbox.clock.tick(2000);
|
||||||
|
fetchLog(action);
|
||||||
|
timeSandbox.clock.tick(2000);
|
||||||
|
expect(postStub.callCount).toBe(2);
|
||||||
|
const { events } = postStub.getCall(1).args[0].postPayload;
|
||||||
|
const mockEventdata = action.payload.eventData;
|
||||||
|
const mockEventname = action.payload.eventName;
|
||||||
|
expect(events[0]).toMatchObject({
|
||||||
|
key: mockEventdata.key,
|
||||||
|
event_name: mockEventname,
|
||||||
|
impression_id: mockStore.getState().impressionId,
|
||||||
|
source: 'dashboard',
|
||||||
|
source_id: mockStore.getState().dashboardInfo.id,
|
||||||
|
event_type: 'timing',
|
||||||
|
dashboard_id: mockStore.getState().dashboardInfo.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(typeof events[0].ts).toBe('number');
|
||||||
|
expect(typeof events[0].start_offset).toBe('number');
|
||||||
|
} finally {
|
||||||
|
// Restore original location
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
value: { href: originalHref },
|
||||||
|
writable: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should debounce a few log requests to one', () => {
|
test('should debounce a few log requests to one', () => {
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
timeSandbox.clock.tick(2000);
|
timeSandbox.clock.tick(2000);
|
||||||
|
|
||||||
expect(SupersetClient.post.callCount).toBe(1);
|
expect(postStub.callCount).toBe(1);
|
||||||
expect(
|
expect(postStub.getCall(0).args[0].postPayload.events).toHaveLength(3);
|
||||||
SupersetClient.post.getCall(0).args[0].postPayload.events,
|
|
||||||
).toHaveLength(3);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should use navigator.sendBeacon if it exists', () => {
|
test('should use navigator.sendBeacon if it exists', () => {
|
||||||
@@ -133,7 +155,7 @@ describe('logger middleware', () => {
|
|||||||
value: beaconMock,
|
value: beaconMock,
|
||||||
});
|
});
|
||||||
|
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
expect(beaconMock.mock.calls.length).toBe(0);
|
expect(beaconMock.mock.calls.length).toBe(0);
|
||||||
timeSandbox.clock.tick(2000);
|
timeSandbox.clock.tick(2000);
|
||||||
|
|
||||||
@@ -150,7 +172,7 @@ describe('logger middleware', () => {
|
|||||||
});
|
});
|
||||||
SupersetClient.configure({ guestToken: 'token' });
|
SupersetClient.configure({ guestToken: 'token' });
|
||||||
|
|
||||||
logger(mockStore)(next)(action);
|
(logger as Function)(mockStore)(next)(action);
|
||||||
expect(beaconMock.mock.calls.length).toBe(0);
|
expect(beaconMock.mock.calls.length).toBe(0);
|
||||||
timeSandbox.clock.tick(2000);
|
timeSandbox.clock.tick(2000);
|
||||||
expect(beaconMock.mock.calls.length).toBe(1);
|
expect(beaconMock.mock.calls.length).toBe(1);
|
||||||
@@ -20,6 +20,7 @@
|
|||||||
/* eslint prefer-const: 2 */
|
/* eslint prefer-const: 2 */
|
||||||
import { nanoid } from 'nanoid';
|
import { nanoid } from 'nanoid';
|
||||||
import { SupersetClient } from '@superset-ui/core';
|
import { SupersetClient } from '@superset-ui/core';
|
||||||
|
import type { Middleware, Dispatch, Action } from 'redux';
|
||||||
|
|
||||||
import { safeStringify } from '../utils/safeStringify';
|
import { safeStringify } from '../utils/safeStringify';
|
||||||
import { LOG_EVENT } from '../logger/actions';
|
import { LOG_EVENT } from '../logger/actions';
|
||||||
@@ -29,15 +30,74 @@ import {
|
|||||||
} from '../logger/LogUtils';
|
} from '../logger/LogUtils';
|
||||||
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
||||||
import { ensureAppRoot } from '../utils/pathUtils';
|
import { ensureAppRoot } from '../utils/pathUtils';
|
||||||
|
import type { DashboardInfo, DashboardLayoutState } from '../dashboard/types';
|
||||||
|
import type { QueryEditor } from '../SqlLab/types';
|
||||||
|
|
||||||
|
type LogEventSource = 'dashboard' | 'explore' | 'sqlLab' | 'slice';
|
||||||
|
|
||||||
|
interface LogEventData {
|
||||||
|
source?: LogEventSource;
|
||||||
|
source_id?: string | number;
|
||||||
|
dashboard_id?: number;
|
||||||
|
slice_id?: number;
|
||||||
|
db_id?: number;
|
||||||
|
schema?: string;
|
||||||
|
impression_id?: string;
|
||||||
|
version?: string;
|
||||||
|
ts?: number;
|
||||||
|
event_name?: string;
|
||||||
|
event_type?: 'timing' | 'user';
|
||||||
|
trigger_event?: string | number;
|
||||||
|
event_id?: string;
|
||||||
|
visibility?: DocumentVisibilityState;
|
||||||
|
target_id?: string;
|
||||||
|
target_name?: string;
|
||||||
|
path?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LogEventAction extends Action<typeof LOG_EVENT> {
|
||||||
|
type: typeof LOG_EVENT;
|
||||||
|
payload: {
|
||||||
|
eventName: string;
|
||||||
|
eventData?: LogEventData;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ExploreState {
|
||||||
|
slice?: {
|
||||||
|
slice_id?: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SqlLabState {
|
||||||
|
queryEditors: QueryEditor[];
|
||||||
|
tabHistory: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoggerRootState {
|
||||||
|
dashboardInfo?: DashboardInfo;
|
||||||
|
explore?: ExploreState;
|
||||||
|
impressionId?: string;
|
||||||
|
dashboardLayout?: DashboardLayoutState;
|
||||||
|
sqlLab?: SqlLabState;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LoggerStore {
|
||||||
|
getState: () => LoggerRootState;
|
||||||
|
dispatch: Dispatch;
|
||||||
|
}
|
||||||
|
|
||||||
const LOG_ENDPOINT = '/superset/log/?explode=events';
|
const LOG_ENDPOINT = '/superset/log/?explode=events';
|
||||||
const sendBeacon = events => {
|
|
||||||
|
const sendBeacon = (events: LogEventData[]): void => {
|
||||||
if (events.length <= 0) {
|
if (events.length <= 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let endpoint = LOG_ENDPOINT;
|
let endpoint = LOG_ENDPOINT;
|
||||||
const { source, source_id } = events[0];
|
const [firstEvent] = events;
|
||||||
|
const { source, source_id } = firstEvent;
|
||||||
// backend logs treat these request params as first-class citizens
|
// backend logs treat these request params as first-class citizens
|
||||||
if (source === 'dashboard') {
|
if (source === 'dashboard') {
|
||||||
endpoint += `&dashboard_id=${source_id}`;
|
endpoint += `&dashboard_id=${source_id}`;
|
||||||
@@ -50,7 +110,7 @@ const sendBeacon = events => {
|
|||||||
formData.append('events', safeStringify(events));
|
formData.append('events', safeStringify(events));
|
||||||
if (SupersetClient.getGuestToken()) {
|
if (SupersetClient.getGuestToken()) {
|
||||||
// if we have a guest token, we need to send it for auth via the form
|
// if we have a guest token, we need to send it for auth via the form
|
||||||
formData.append('guest_token', SupersetClient.getGuestToken());
|
formData.append('guest_token', SupersetClient.getGuestToken() as string);
|
||||||
}
|
}
|
||||||
navigator.sendBeacon(ensureAppRoot(endpoint), formData);
|
navigator.sendBeacon(ensureAppRoot(endpoint), formData);
|
||||||
} else {
|
} else {
|
||||||
@@ -65,27 +125,37 @@ const sendBeacon = events => {
|
|||||||
// beacon API has data size limit = 2^16.
|
// beacon API has data size limit = 2^16.
|
||||||
// assume avg each log entry has 2^6 characters
|
// assume avg each log entry has 2^6 characters
|
||||||
const MAX_EVENTS_PER_REQUEST = 1024;
|
const MAX_EVENTS_PER_REQUEST = 1024;
|
||||||
const logMessageQueue = new DebouncedMessageQueue({
|
const logMessageQueue = new DebouncedMessageQueue<LogEventData>({
|
||||||
callback: sendBeacon,
|
callback: sendBeacon,
|
||||||
sizeThreshold: MAX_EVENTS_PER_REQUEST,
|
sizeThreshold: MAX_EVENTS_PER_REQUEST,
|
||||||
delayThreshold: 1000,
|
delayThreshold: 1000,
|
||||||
});
|
});
|
||||||
let lastEventId = 0;
|
|
||||||
const loggerMiddleware = store => next => {
|
let lastEventId: string | number = 0;
|
||||||
let navPath;
|
|
||||||
return action => {
|
const loggerMiddleware: Middleware<
|
||||||
if (action.type !== LOG_EVENT) {
|
Record<string, never>,
|
||||||
|
LoggerRootState,
|
||||||
|
Dispatch
|
||||||
|
> =
|
||||||
|
(store: LoggerStore) =>
|
||||||
|
(next: Dispatch) =>
|
||||||
|
(action: Action): LogEventData | ReturnType<Dispatch> => {
|
||||||
|
let navPath: string | undefined;
|
||||||
|
|
||||||
|
if ((action as LogEventAction).type !== LOG_EVENT) {
|
||||||
return next(action);
|
return next(action);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const logAction = action as LogEventAction;
|
||||||
const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } =
|
const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } =
|
||||||
store.getState();
|
store.getState();
|
||||||
let logMetadata = {
|
let logMetadata: LogEventData = {
|
||||||
impression_id: impressionId,
|
impression_id: impressionId,
|
||||||
version: 'v2',
|
version: 'v2',
|
||||||
};
|
};
|
||||||
const { eventName } = action.payload;
|
const { eventName } = logAction.payload;
|
||||||
let { eventData = {} } = action.payload;
|
let eventData: LogEventData = logAction.payload.eventData || {};
|
||||||
|
|
||||||
if (eventName === LOG_ACTIONS_SPA_NAVIGATION) {
|
if (eventName === LOG_ACTIONS_SPA_NAVIGATION) {
|
||||||
navPath = eventData.path;
|
navPath = eventData.path;
|
||||||
@@ -107,7 +177,7 @@ const loggerMiddleware = store => next => {
|
|||||||
...logMetadata,
|
...logMetadata,
|
||||||
};
|
};
|
||||||
} else if (path?.includes('/sqllab/')) {
|
} else if (path?.includes('/sqllab/')) {
|
||||||
const editor = sqlLab.queryEditors.find(
|
const editor = sqlLab?.queryEditors.find(
|
||||||
({ id }) => id === sqlLab.tabHistory.slice(-1)[0],
|
({ id }) => id === sqlLab.tabHistory.slice(-1)[0],
|
||||||
);
|
);
|
||||||
logMetadata = {
|
logMetadata = {
|
||||||
@@ -152,6 +222,5 @@ const loggerMiddleware = store => next => {
|
|||||||
logMessageQueue.append(eventData);
|
logMessageQueue.append(eventData);
|
||||||
return eventData;
|
return eventData;
|
||||||
};
|
};
|
||||||
};
|
|
||||||
|
|
||||||
export default loggerMiddleware;
|
export default loggerMiddleware;
|
||||||
@@ -23,6 +23,8 @@ import {
|
|||||||
NULL_STRING,
|
NULL_STRING,
|
||||||
TRUE_STRING,
|
TRUE_STRING,
|
||||||
FALSE_STRING,
|
FALSE_STRING,
|
||||||
|
TabularDataRow,
|
||||||
|
ColumnDefinition,
|
||||||
} from 'src/utils/common';
|
} from 'src/utils/common';
|
||||||
|
|
||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
@@ -53,26 +55,30 @@ describe('utils/common', () => {
|
|||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('prepareCopyToClipboardTabularData', () => {
|
describe('prepareCopyToClipboardTabularData', () => {
|
||||||
test('converts empty array', () => {
|
test('converts empty array', () => {
|
||||||
const data = [];
|
const data: TabularDataRow[] = [];
|
||||||
const columns = [];
|
const columns: string[] = [];
|
||||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||||
});
|
});
|
||||||
test('converts non empty array', () => {
|
test('converts non empty array', () => {
|
||||||
const data = [
|
const data: TabularDataRow[] = [
|
||||||
{ column1: 'lorem', column2: 'ipsum' },
|
{ column1: 'lorem', column2: 'ipsum' },
|
||||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||||
];
|
];
|
||||||
const columns = ['column1', 'column2', 'column3'];
|
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
test('includes 0 values and handle column objects', () => {
|
test('includes 0 values and handle column objects', () => {
|
||||||
const data = [
|
const data: TabularDataRow[] = [
|
||||||
{ column1: 0, column2: 0 },
|
{ column1: 0, column2: 0 },
|
||||||
{ column1: 1, column2: -1, 0: 0 },
|
{ column1: 1, column2: -1, 0: 0 },
|
||||||
];
|
];
|
||||||
const columns = [{ name: 'column1' }, { name: 'column2' }, { name: '0' }];
|
const columns: ColumnDefinition[] = [
|
||||||
|
{ name: 'column1' },
|
||||||
|
{ name: 'column2' },
|
||||||
|
{ name: '0' },
|
||||||
|
];
|
||||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||||
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
'column1\tcolumn2\t0\n0\t0\t\n1\t-1\t0\n',
|
||||||
);
|
);
|
||||||
@@ -81,18 +87,18 @@ describe('utils/common', () => {
|
|||||||
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks
|
||||||
describe('applyFormattingToTabularData', () => {
|
describe('applyFormattingToTabularData', () => {
|
||||||
test('does not mutate empty array', () => {
|
test('does not mutate empty array', () => {
|
||||||
const data = [];
|
const data: TabularDataRow[] = [];
|
||||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||||
});
|
});
|
||||||
test('does not mutate array without temporal column', () => {
|
test('does not mutate array without temporal column', () => {
|
||||||
const data = [
|
const data: TabularDataRow[] = [
|
||||||
{ column1: 'lorem', column2: 'ipsum' },
|
{ column1: 'lorem', column2: 'ipsum' },
|
||||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||||
];
|
];
|
||||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||||
});
|
});
|
||||||
test('changes formatting of columns selected for formatting', () => {
|
test('changes formatting of columns selected for formatting', () => {
|
||||||
const originalData = [
|
const originalData: TabularDataRow[] = [
|
||||||
{
|
{
|
||||||
__timestamp: null,
|
__timestamp: null,
|
||||||
column1: 'lorem',
|
column1: 'lorem',
|
||||||
@@ -118,8 +124,8 @@ describe('utils/common', () => {
|
|||||||
column3: 1518566400000,
|
column3: 1518566400000,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const timeFormattedColumns = ['__timestamp', 'column3'];
|
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||||
const expectedData = [
|
const expectedData: TabularDataRow[] = [
|
||||||
{
|
{
|
||||||
__timestamp: null,
|
__timestamp: null,
|
||||||
column1: 'lorem',
|
column1: 'lorem',
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
getTimeFormatter,
|
getTimeFormatter,
|
||||||
TimeFormats,
|
TimeFormats,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
|
JsonObject,
|
||||||
} from '@superset-ui/core';
|
} from '@superset-ui/core';
|
||||||
|
|
||||||
// ATTENTION: If you change any constants, make sure to also change constants.py
|
// ATTENTION: If you change any constants, make sure to also change constants.py
|
||||||
@@ -36,7 +37,22 @@ export const SHORT_TIME = 'h:m a';
|
|||||||
|
|
||||||
const DATETIME_FORMATTER = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
const DATETIME_FORMATTER = getTimeFormatter(TimeFormats.DATABASE_DATETIME);
|
||||||
|
|
||||||
export function storeQuery(query) {
|
export type OptionValue = string | number | boolean | null;
|
||||||
|
|
||||||
|
export interface OptionItem {
|
||||||
|
value: OptionValue | typeof NULL_STRING;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ColumnDefinition {
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabularDataRow = Record<string, unknown>;
|
||||||
|
|
||||||
|
export type OSType = 'Windows' | 'MacOS' | 'UNIX' | 'Linux' | 'Unknown OS';
|
||||||
|
|
||||||
|
export function storeQuery(query: JsonObject): Promise<string> {
|
||||||
return SupersetClient.post({
|
return SupersetClient.post({
|
||||||
endpoint: '/kv/store/',
|
endpoint: '/kv/store/',
|
||||||
postPayload: { data: query },
|
postPayload: { data: query },
|
||||||
@@ -47,7 +63,7 @@ export function storeQuery(query) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionLabel(opt) {
|
export function optionLabel(opt: OptionValue): string {
|
||||||
if (opt === null) {
|
if (opt === null) {
|
||||||
return NULL_STRING;
|
return NULL_STRING;
|
||||||
}
|
}
|
||||||
@@ -60,34 +76,42 @@ export function optionLabel(opt) {
|
|||||||
if (opt === false) {
|
if (opt === false) {
|
||||||
return FALSE_STRING;
|
return FALSE_STRING;
|
||||||
}
|
}
|
||||||
if (typeof opt !== 'string' && opt.toString) {
|
if (typeof opt !== 'string' && typeof opt === 'number') {
|
||||||
return opt.toString();
|
return opt.toString();
|
||||||
}
|
}
|
||||||
return opt;
|
return opt as string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionValue(opt) {
|
export function optionValue(
|
||||||
|
opt: OptionValue,
|
||||||
|
): OptionValue | typeof NULL_STRING {
|
||||||
if (opt === null) {
|
if (opt === null) {
|
||||||
return NULL_STRING;
|
return NULL_STRING;
|
||||||
}
|
}
|
||||||
return opt;
|
return opt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function optionFromValue(opt) {
|
export function optionFromValue(opt: OptionValue): OptionItem {
|
||||||
// From a list of options, handles special values & labels
|
// From a list of options, handles special values & labels
|
||||||
return { value: optionValue(opt), label: optionLabel(opt) };
|
return { value: optionValue(opt), label: optionLabel(opt) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function getColumnName(column) {
|
function getColumnName(column: string | ColumnDefinition): string {
|
||||||
return column.name || column;
|
if (typeof column === 'string') {
|
||||||
|
return column;
|
||||||
|
}
|
||||||
|
return column.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function prepareCopyToClipboardTabularData(data, columns) {
|
export function prepareCopyToClipboardTabularData(
|
||||||
|
data: TabularDataRow[],
|
||||||
|
columns: (string | ColumnDefinition)[],
|
||||||
|
): string {
|
||||||
let result = columns.length
|
let result = columns.length
|
||||||
? `${columns.map(getColumnName).join('\t')}\n`
|
? `${columns.map(getColumnName).join('\t')}\n`
|
||||||
: '';
|
: '';
|
||||||
for (let i = 0; i < data.length; i += 1) {
|
for (let i = 0; i < data.length; i += 1) {
|
||||||
const row = {};
|
const row: Record<number, unknown> = {};
|
||||||
for (let j = 0; j < columns.length; j += 1) {
|
for (let j = 0; j < columns.length; j += 1) {
|
||||||
// JavaScript does not maintain the order of a mixed set of keys (i.e integers and strings)
|
// JavaScript does not maintain the order of a mixed set of keys (i.e integers and strings)
|
||||||
// the below function orders the keys based on the column names.
|
// the below function orders the keys based on the column names.
|
||||||
@@ -103,7 +127,10 @@ export function prepareCopyToClipboardTabularData(data, columns) {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
export function applyFormattingToTabularData(
|
||||||
|
data: TabularDataRow[],
|
||||||
|
timeFormattedColumns: string | string[],
|
||||||
|
): TabularDataRow[] {
|
||||||
if (
|
if (
|
||||||
!data ||
|
!data ||
|
||||||
data.length === 0 ||
|
data.length === 0 ||
|
||||||
@@ -115,19 +142,22 @@ export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
|||||||
return data.map(row => ({
|
return data.map(row => ({
|
||||||
...row,
|
...row,
|
||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
...timeFormattedColumns.reduce((acc, colName) => {
|
...ensureIsArray(timeFormattedColumns).reduce(
|
||||||
if (row[colName] !== null && row[colName] !== undefined) {
|
(acc: Record<string, string>, colName: string) => {
|
||||||
acc[colName] = DATETIME_FORMATTER(row[colName]);
|
if (row[colName] !== null && row[colName] !== undefined) {
|
||||||
}
|
acc[colName] = DATETIME_FORMATTER(row[colName] as Date | number);
|
||||||
return acc;
|
}
|
||||||
}, {}),
|
return acc;
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const noOp = () => undefined;
|
export const noOp = (): undefined => undefined;
|
||||||
|
|
||||||
// Detects the user's OS through the browser
|
// Detects the user's OS through the browser
|
||||||
export const detectOS = () => {
|
export const detectOS = (): OSType => {
|
||||||
const { appVersion } = navigator;
|
const { appVersion } = navigator;
|
||||||
|
|
||||||
// Leveraging this condition because of stackOverflow
|
// Leveraging this condition because of stackOverflow
|
||||||
@@ -140,8 +170,8 @@ export const detectOS = () => {
|
|||||||
return 'Unknown OS';
|
return 'Unknown OS';
|
||||||
};
|
};
|
||||||
|
|
||||||
export const isSafari = () => {
|
export const isSafari = (): boolean => {
|
||||||
const { userAgent } = navigator;
|
const { userAgent } = navigator;
|
||||||
|
|
||||||
return userAgent && /^((?!chrome|android).)*safari/i.test(userAgent);
|
return Boolean(userAgent && /^((?!chrome|android).)*safari/i.test(userAgent));
|
||||||
};
|
};
|
||||||
Reference in New Issue
Block a user