mirror of
https://github.com/apache/superset.git
synced 2026-05-02 14:34:22 +00:00
Compare commits
47 Commits
fix/postgr
...
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',
|
||||
},
|
||||
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'],
|
||||
snapshotSerializers: ['@emotion/jest/serializer'],
|
||||
testEnvironmentOptions: {
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
import { t } from '@apache-superset/core';
|
||||
import { SupersetTheme } from '@apache-superset/core/ui';
|
||||
import { FallbackPropsWithDimension } from './SuperChart';
|
||||
import { getErrorMessage } from 'react-error-boundary';
|
||||
|
||||
export type Props = Partial<FallbackPropsWithDimension>;
|
||||
|
||||
@@ -39,7 +38,13 @@ export default function FallbackComponent({ error, height, width }: Props) {
|
||||
<div>
|
||||
<b>{t('Oops! An error occurred!')}</b>
|
||||
</div>
|
||||
<code>{error ? getErrorMessage(error) : 'Unknown Error'}</code>
|
||||
<code>
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: error
|
||||
? String(error)
|
||||
: 'Unknown Error'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -298,7 +298,7 @@ const ResultSet = ({
|
||||
const force = false;
|
||||
const includeAppRoot = openInNewWindow;
|
||||
const url = mountExploreUrl(
|
||||
null,
|
||||
'base',
|
||||
{
|
||||
[URL_PARAMS.formDataKey.name]: key,
|
||||
},
|
||||
@@ -428,7 +428,10 @@ const ResultSet = ({
|
||||
)}
|
||||
{canExportData && (
|
||||
<CopyToClipboard
|
||||
text={prepareCopyToClipboardTabularData(data, columns)}
|
||||
text={prepareCopyToClipboardTabularData(
|
||||
data,
|
||||
columns.map(c => c.column_name),
|
||||
)}
|
||||
wrapped={false}
|
||||
copyNode={
|
||||
<Button
|
||||
|
||||
@@ -258,7 +258,7 @@ export const SaveDatasetModal = ({
|
||||
]);
|
||||
setLoading(false);
|
||||
|
||||
const url = mountExploreUrl(null, {
|
||||
const url = mountExploreUrl('base', {
|
||||
[URL_PARAMS.formDataKey.name]: key,
|
||||
});
|
||||
createWindow(url);
|
||||
@@ -364,7 +364,7 @@ export const SaveDatasetModal = ({
|
||||
})
|
||||
.then((key: string) => {
|
||||
setLoading(false);
|
||||
const url = mountExploreUrl(null, {
|
||||
const url = mountExploreUrl('base', {
|
||||
[URL_PARAMS.formDataKey.name]: key,
|
||||
});
|
||||
createWindow(url);
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
QueryFormData,
|
||||
SqlaFormData,
|
||||
ClientErrorObject,
|
||||
DataRecordFilters,
|
||||
type JsonObject,
|
||||
type AgGridChartState,
|
||||
} from '@superset-ui/core';
|
||||
@@ -51,13 +52,13 @@ export interface ChartProps {
|
||||
chartId: number;
|
||||
datasource?: Datasource;
|
||||
dashboardId?: number;
|
||||
initialValues?: object;
|
||||
initialValues?: DataRecordFilters;
|
||||
formData: QueryFormData;
|
||||
labelColors?: string;
|
||||
sharedLabelColors?: string;
|
||||
width: number;
|
||||
height: number;
|
||||
setControlValue: Function;
|
||||
setControlValue: (name: string, value: unknown) => void;
|
||||
timeout?: number;
|
||||
vizType: string;
|
||||
triggerRender?: boolean;
|
||||
@@ -76,7 +77,7 @@ export interface ChartProps {
|
||||
onFilterMenuOpen?: (chartId: number, column: string) => void;
|
||||
onFilterMenuClose?: (chartId: number, column: string) => void;
|
||||
ownState?: JsonObject;
|
||||
postTransformProps?: Function;
|
||||
postTransformProps?: (props: JsonObject) => JsonObject;
|
||||
datasetsStatus?: 'loading' | 'error' | 'complete';
|
||||
isInView?: boolean;
|
||||
emitCrossFilters?: boolean;
|
||||
@@ -100,6 +101,7 @@ export type Actions = {
|
||||
chartId: number,
|
||||
arg2: string | null,
|
||||
): Dispatch;
|
||||
chartRenderingSucceeded(chartId: number): Dispatch;
|
||||
postChartFormData(
|
||||
formData: SqlaFormData,
|
||||
arg1: boolean,
|
||||
@@ -300,7 +302,11 @@ class Chart extends PureComponent<ChartProps, {}> {
|
||||
isCurrentUserBot() ? (
|
||||
<ChartRenderer
|
||||
{...this.props}
|
||||
source={this.props.dashboardId ? 'dashboard' : 'explore'}
|
||||
source={
|
||||
this.props.dashboardId
|
||||
? ChartSource.Dashboard
|
||||
: ChartSource.Explore
|
||||
}
|
||||
data-test={this.props.vizType}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -21,13 +21,27 @@ import {
|
||||
ChartMetadata,
|
||||
getChartMetadataRegistry,
|
||||
VizType,
|
||||
JsonObject,
|
||||
FeatureFlagMap,
|
||||
} 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 type { Dispatch } from 'redux';
|
||||
|
||||
interface MockSuperChartProps {
|
||||
postTransformProps?: (props: JsonObject) => JsonObject;
|
||||
formData?: JsonObject;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
jest.mock('@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">
|
||||
{JSON.stringify(postTransformProps(props).formData)}
|
||||
</div>
|
||||
@@ -39,42 +53,83 @@ jest.mock(
|
||||
() => () => <div data-test="mock-chart-context-menu" />,
|
||||
);
|
||||
|
||||
const requiredProps = {
|
||||
chartId: 1,
|
||||
datasource: {},
|
||||
formData: { testControl: 'foo' },
|
||||
latestQueryFormData: {
|
||||
testControl: 'bar',
|
||||
},
|
||||
vizType: VizType.Table,
|
||||
source: ChartSource.Dashboard,
|
||||
interface MockActions {
|
||||
chartRenderingSucceeded: (chartId: number) => Dispatch;
|
||||
chartRenderingFailed: (
|
||||
error: string,
|
||||
chartId: number,
|
||||
componentStack: string | null,
|
||||
) => Dispatch;
|
||||
logEvent: (eventName: string, payload: JsonObject) => Dispatch;
|
||||
}
|
||||
|
||||
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(() => {
|
||||
window.featureFlags = { DRILL_TO_DETAIL: true };
|
||||
window.featureFlags = { DRILL_TO_DETAIL: true } as FeatureFlagMap;
|
||||
});
|
||||
afterAll(() => {
|
||||
window.featureFlags = {};
|
||||
window.featureFlags = {} as FeatureFlagMap;
|
||||
});
|
||||
|
||||
test('should render SuperChart', () => {
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...requiredProps} chartIsStale={false} />,
|
||||
<ChartRenderer
|
||||
{...(requiredProps as ChartRendererProps)}
|
||||
chartIsStale={false}
|
||||
/>,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should use latestQueryFormData instead of formData when chartIsStale is true', () => {
|
||||
const { getByTestId } = render(
|
||||
<ChartRenderer {...requiredProps} chartIsStale />,
|
||||
<ChartRenderer {...(requiredProps as ChartRendererProps)} chartIsStale />,
|
||||
);
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify({ testControl: 'bar' }),
|
||||
JSON.stringify({
|
||||
testControl: 'bar',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -89,78 +144,92 @@ test('should not render chart context menu if the context menu is suppressed for
|
||||
}),
|
||||
);
|
||||
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();
|
||||
});
|
||||
|
||||
test('should detect changes in matrixify properties', () => {
|
||||
const initialProps = {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
...requiredProps.formData,
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
||||
matrixify_dimension_y: { dimension: 'category', values: ['Tech'] },
|
||||
matrixify_charts_per_row: 3,
|
||||
matrixify_show_row_labels: true,
|
||||
},
|
||||
queriesResponse: [{ data: 'initial' }],
|
||||
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const wrapper = render(<ChartRenderer {...initialProps} />);
|
||||
render(<ChartRenderer {...(initialProps as ChartRendererProps)} />);
|
||||
|
||||
// Since we can't directly test shouldComponentUpdate, we verify the component
|
||||
// correctly identifies matrixify-related properties by checking the implementation
|
||||
expect(initialProps.formData.matrixify_enable_vertical_layout).toBe(true);
|
||||
expect(initialProps.formData.matrixify_dimension_x).toEqual({
|
||||
expect(
|
||||
(initialProps.formData as JsonObject).matrixify_enable_vertical_layout,
|
||||
).toBe(true);
|
||||
expect((initialProps.formData as JsonObject).matrixify_dimension_x).toEqual({
|
||||
dimension: 'country',
|
||||
values: ['USA'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should detect changes in postTransformProps', () => {
|
||||
const postTransformProps = jest.fn(x => x);
|
||||
const initialProps = {
|
||||
const postTransformProps = jest.fn((x: JsonObject) => x);
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
queriesResponse: [{ data: 'initial' }],
|
||||
queriesResponse: [{ data: 'initial' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
const { rerender } = render(<ChartRenderer {...initialProps} />);
|
||||
const updatedProps = {
|
||||
const { rerender } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
...initialProps,
|
||||
postTransformProps,
|
||||
};
|
||||
expect(postTransformProps).toHaveBeenCalledTimes(0);
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
expect(postTransformProps).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should identify matrixify property changes correctly', () => {
|
||||
// Test that formData with different matrixify properties triggers updates
|
||||
const initialProps = {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
matrixify_dimension_x: { dimension: 'country', values: ['USA'] },
|
||||
matrixify_charts_per_row: 3,
|
||||
},
|
||||
queriesResponse: [{ data: 'current' }],
|
||||
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Update with changed matrixify_dimension_x values
|
||||
const updatedProps = {
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
matrixify_dimension_x: {
|
||||
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
|
||||
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', () => {
|
||||
const initialProps = {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
regular_control: 'value1',
|
||||
},
|
||||
queriesResponse: [{ data: 'current' }],
|
||||
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Enable matrixify
|
||||
const updatedProps = {
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true, // This is a significant change
|
||||
regular_control: 'value1',
|
||||
},
|
||||
};
|
||||
|
||||
rerender(<ChartRenderer {...updatedProps} />);
|
||||
rerender(<ChartRenderer {...(updatedProps as ChartRendererProps)} />);
|
||||
|
||||
// Verify the component re-rendered with matrixify enabled
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -212,32 +287,38 @@ test('should handle matrixify-related form data changes', () => {
|
||||
});
|
||||
|
||||
test('should detect matrixify property addition', () => {
|
||||
const initialProps = {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
// No matrixify_dimension_x initially
|
||||
},
|
||||
queriesResponse: [{ data: 'current' }],
|
||||
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Add matrixify_dimension_x
|
||||
const updatedProps = {
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
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
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -246,9 +327,11 @@ test('should detect matrixify property addition', () => {
|
||||
});
|
||||
|
||||
test('should detect nested matrixify property changes', () => {
|
||||
const initialProps = {
|
||||
const initialProps: Partial<ChartRendererProps> = {
|
||||
...requiredProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
matrixify_dimension_x: {
|
||||
dimension: 'country',
|
||||
@@ -256,20 +339,24 @@ test('should detect nested matrixify property changes', () => {
|
||||
topN: { metric: 'sales', value: 10 },
|
||||
},
|
||||
},
|
||||
queriesResponse: [{ data: 'current' }],
|
||||
queriesResponse: [{ data: 'current' } as unknown as JsonObject],
|
||||
chartStatus: 'success',
|
||||
};
|
||||
|
||||
const { rerender, getByTestId } = render(<ChartRenderer {...initialProps} />);
|
||||
const { rerender, getByTestId } = render(
|
||||
<ChartRenderer {...(initialProps as ChartRendererProps)} />,
|
||||
);
|
||||
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
JSON.stringify(initialProps.formData),
|
||||
);
|
||||
|
||||
// Change nested topN value
|
||||
const updatedProps = {
|
||||
const updatedProps: Partial<ChartRendererProps> = {
|
||||
...initialProps,
|
||||
formData: {
|
||||
datasource: '',
|
||||
viz_type: VizType.Table,
|
||||
matrixify_enable_vertical_layout: true,
|
||||
matrixify_dimension_x: {
|
||||
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
|
||||
expect(getByTestId('mock-super-chart')).toHaveTextContent(
|
||||
@@ -17,8 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { snakeCase, isEqual, cloneDeep } from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createRef, Component } from 'react';
|
||||
import { createRef, Component, RefObject, MouseEvent, ReactNode } from 'react';
|
||||
import {
|
||||
SuperChart,
|
||||
Behavior,
|
||||
@@ -26,46 +25,147 @@ import {
|
||||
VizType,
|
||||
isFeatureEnabled,
|
||||
FeatureFlag,
|
||||
QueryFormData,
|
||||
AnnotationData,
|
||||
DataMask,
|
||||
QueryData,
|
||||
JsonObject,
|
||||
LatestQueryFormData,
|
||||
AgGridChartState,
|
||||
ContextMenuFilters,
|
||||
DataRecordFilters,
|
||||
} from '@superset-ui/core';
|
||||
import { logging } from '@apache-superset/core';
|
||||
import { t } from '@apache-superset/core/ui';
|
||||
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
||||
import { EmptyState } from '@superset-ui/core/components';
|
||||
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 = {
|
||||
annotationData: PropTypes.object,
|
||||
actions: PropTypes.object,
|
||||
chartId: PropTypes.number.isRequired,
|
||||
datasource: PropTypes.object,
|
||||
initialValues: PropTypes.object,
|
||||
formData: PropTypes.object.isRequired,
|
||||
latestQueryFormData: PropTypes.object,
|
||||
labelsColor: PropTypes.object,
|
||||
labelsColorMap: PropTypes.object,
|
||||
height: PropTypes.number,
|
||||
width: PropTypes.number,
|
||||
setControlValue: PropTypes.func,
|
||||
vizType: PropTypes.string.isRequired,
|
||||
triggerRender: PropTypes.bool,
|
||||
// state
|
||||
chartAlert: PropTypes.string,
|
||||
chartStatus: PropTypes.string,
|
||||
queriesResponse: PropTypes.arrayOf(PropTypes.object),
|
||||
triggerQuery: PropTypes.bool,
|
||||
chartIsStale: PropTypes.bool,
|
||||
// dashboard callbacks
|
||||
addFilter: PropTypes.func,
|
||||
setDataMask: PropTypes.func,
|
||||
onFilterMenuOpen: PropTypes.func,
|
||||
onFilterMenuClose: PropTypes.func,
|
||||
ownState: PropTypes.object,
|
||||
postTransformProps: PropTypes.func,
|
||||
source: PropTypes.oneOf([ChartSource.Dashboard, ChartSource.Explore]),
|
||||
emitCrossFilters: PropTypes.bool,
|
||||
onChartStateChange: PropTypes.func,
|
||||
};
|
||||
// Types for filter values
|
||||
type FilterValue = string | number | boolean | null | undefined;
|
||||
|
||||
// LegendState type based on ECharts
|
||||
interface LegendState {
|
||||
[name: string]: boolean;
|
||||
}
|
||||
|
||||
// Webpack globals declaration
|
||||
declare const __webpack_require__:
|
||||
| {
|
||||
h?: () => string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
// Types for chart actions
|
||||
interface ChartActions {
|
||||
chartRenderingSucceeded: (chartId: number) => Dispatch;
|
||||
chartRenderingFailed: (
|
||||
error: string,
|
||||
chartId: number,
|
||||
componentStack: string | null,
|
||||
) => Dispatch;
|
||||
logEvent: (
|
||||
eventName: string,
|
||||
payload: {
|
||||
slice_id: number;
|
||||
viz_type?: string;
|
||||
start_offset: number;
|
||||
ts: number;
|
||||
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 = {};
|
||||
|
||||
@@ -74,17 +174,29 @@ const BIG_NO_RESULT_MIN_HEIGHT = 220;
|
||||
|
||||
const behaviors = [Behavior.InteractiveChart];
|
||||
|
||||
const defaultProps = {
|
||||
const defaultProps: Partial<ChartRendererProps> = {
|
||||
addFilter: () => BLANK,
|
||||
onFilterMenuOpen: () => BLANK,
|
||||
onFilterMenuClose: () => BLANK,
|
||||
initialValues: BLANK,
|
||||
setControlValue() {},
|
||||
setControlValue: () => {},
|
||||
triggerRender: false,
|
||||
};
|
||||
|
||||
class ChartRenderer extends Component {
|
||||
constructor(props) {
|
||||
class ChartRenderer extends Component<ChartRendererProps, ChartRendererState> {
|
||||
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);
|
||||
const suppressContextMenu = getChartMetadataRegistry().get(
|
||||
props.formData.viz_type ?? props.vizType,
|
||||
@@ -99,8 +211,9 @@ class ChartRenderer extends Component {
|
||||
legendIndex: 0,
|
||||
};
|
||||
this.hasQueryResponseChange = false;
|
||||
this.renderStartTime = 0;
|
||||
|
||||
this.contextMenuRef = createRef();
|
||||
this.contextMenuRef = createRef<ChartContextMenuRef>();
|
||||
|
||||
this.handleAddFilter = this.handleAddFilter.bind(this);
|
||||
this.handleRenderSuccess = this.handleRenderSuccess.bind(this);
|
||||
@@ -123,8 +236,8 @@ class ChartRenderer extends Component {
|
||||
onFilterMenuOpen: this.props.onFilterMenuOpen,
|
||||
onFilterMenuClose: this.props.onFilterMenuClose,
|
||||
onLegendStateChanged: this.handleLegendStateChanged,
|
||||
setDataMask: dataMask => {
|
||||
this.props.actions?.updateDataMask(this.props.chartId, dataMask);
|
||||
setDataMask: (dataMask: DataMask) => {
|
||||
this.props.actions?.updateDataMask?.(this.props.chartId, dataMask);
|
||||
},
|
||||
onLegendScroll: this.handleLegendScroll,
|
||||
onChartStateChange: this.props.onChartStateChange,
|
||||
@@ -136,10 +249,13 @@ class ChartRenderer extends Component {
|
||||
this.mutableQueriesResponse = cloneDeep(this.props.queriesResponse);
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
shouldComponentUpdate(
|
||||
nextProps: ChartRendererProps,
|
||||
nextState: ChartRendererState,
|
||||
): boolean {
|
||||
const resultsReady =
|
||||
nextProps.queriesResponse &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
|
||||
['success', 'rendered'].indexOf(nextProps.chartStatus as string) > -1 &&
|
||||
!nextProps.queriesResponse?.[0]?.error;
|
||||
|
||||
if (resultsReady) {
|
||||
@@ -154,22 +270,27 @@ class ChartRenderer extends Component {
|
||||
}
|
||||
|
||||
// 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 =
|
||||
nextProps.formData.matrixify_enable_vertical_layout === true ||
|
||||
nextProps.formData.matrixify_enable_horizontal_layout === true;
|
||||
nextFormData.matrixify_enable_vertical_layout === true ||
|
||||
nextFormData.matrixify_enable_horizontal_layout === true;
|
||||
if (!isMatrixifyEnabled) return false;
|
||||
|
||||
// Check all matrixify-related properties
|
||||
const matrixifyKeys = Object.keys(nextProps.formData).filter(key =>
|
||||
const matrixifyKeys = Object.keys(nextFormData).filter(key =>
|
||||
key.startsWith('matrixify_'),
|
||||
);
|
||||
|
||||
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 (
|
||||
this.hasQueryResponseChange ||
|
||||
!isEqual(nextProps.datasource, this.props.datasource) ||
|
||||
@@ -178,13 +299,12 @@ class ChartRenderer extends Component {
|
||||
nextProps.filterState !== this.props.filterState ||
|
||||
nextProps.height !== this.props.height ||
|
||||
nextProps.width !== this.props.width ||
|
||||
nextProps.triggerRender ||
|
||||
nextProps.triggerRender === true ||
|
||||
nextProps.labelsColor !== this.props.labelsColor ||
|
||||
nextProps.labelsColorMap !== this.props.labelsColorMap ||
|
||||
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
|
||||
nextProps.formData.stack !== this.props.formData.stack ||
|
||||
nextProps.formData.subcategories !==
|
||||
this.props.formData.subcategories ||
|
||||
nextFormData.color_scheme !== currentFormData.color_scheme ||
|
||||
nextFormData.stack !== currentFormData.stack ||
|
||||
nextFormData.subcategories !== currentFormData.subcategories ||
|
||||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
|
||||
nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
|
||||
nextProps.postTransformProps !== this.props.postTransformProps ||
|
||||
@@ -194,13 +314,18 @@ class ChartRenderer extends Component {
|
||||
return false;
|
||||
}
|
||||
|
||||
handleAddFilter(col, vals, merge = true, refresh = true) {
|
||||
this.props.addFilter(col, vals, merge, refresh);
|
||||
handleAddFilter(
|
||||
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;
|
||||
if (['loading', 'rendered'].indexOf(chartStatus) < 0) {
|
||||
if (['loading', 'rendered'].indexOf(chartStatus as string) < 0) {
|
||||
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;
|
||||
logging.warn(error);
|
||||
actions.chartRenderingFailed(
|
||||
@@ -239,44 +367,48 @@ class ChartRenderer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
handleSetControlValue(...args) {
|
||||
handleSetControlValue(name: string, value: unknown): void {
|
||||
const { setControlValue } = this.props;
|
||||
if (setControlValue) {
|
||||
setControlValue(...args);
|
||||
setControlValue(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
handleOnContextMenu(offsetX, offsetY, filters) {
|
||||
this.contextMenuRef.current.open(offsetX, offsetY, filters);
|
||||
handleOnContextMenu(
|
||||
offsetX: number,
|
||||
offsetY: number,
|
||||
filters?: ContextMenuFilters,
|
||||
): void {
|
||||
this.contextMenuRef.current?.open(offsetX, offsetY, filters);
|
||||
this.setState({ inContextMenu: true });
|
||||
}
|
||||
|
||||
handleContextMenuSelected() {
|
||||
handleContextMenuSelected(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
handleContextMenuClosed() {
|
||||
handleContextMenuClosed(): void {
|
||||
this.setState({ inContextMenu: false });
|
||||
}
|
||||
|
||||
handleLegendStateChanged(legendState) {
|
||||
handleLegendStateChanged(legendState: LegendState): void {
|
||||
this.setState({ legendState });
|
||||
}
|
||||
|
||||
// When viz plugins don't handle `contextmenu` event, fallback handler
|
||||
// calls `handleOnContextMenu` with no `filters` param.
|
||||
onContextMenuFallback(event) {
|
||||
onContextMenuFallback(event: MouseEvent<HTMLDivElement>): void {
|
||||
if (!this.state.inContextMenu) {
|
||||
event.preventDefault();
|
||||
this.handleOnContextMenu(event.clientX, event.clientY);
|
||||
}
|
||||
}
|
||||
|
||||
handleLegendScroll(legendIndex) {
|
||||
handleLegendScroll(legendIndex: number): void {
|
||||
this.setState({ legendIndex });
|
||||
}
|
||||
|
||||
render() {
|
||||
render(): ReactNode {
|
||||
const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props;
|
||||
|
||||
// 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 noResultDescription =
|
||||
this.props.source === ChartSource.Explore
|
||||
@@ -335,7 +467,10 @@ class ChartRenderer extends Component {
|
||||
)
|
||||
: undefined;
|
||||
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 = (
|
||||
<EmptyState
|
||||
size="large"
|
||||
@@ -353,7 +488,7 @@ class ChartRenderer extends Component {
|
||||
// Check for Behavior.DRILL_TO_DETAIL to tell if chart can receive Drill to
|
||||
// Detail props or if it'll cause side-effects (e.g. excessive re-renders).
|
||||
const drillToDetailProps = getChartMetadataRegistry()
|
||||
.get(formData.viz_type)
|
||||
.get(vizType)
|
||||
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
|
||||
? { inContextMenu: this.state.inContextMenu }
|
||||
: {};
|
||||
@@ -365,8 +500,9 @@ class ChartRenderer extends Component {
|
||||
ownState?.agGridFilterModel &&
|
||||
Object.keys(ownState.agGridFilterModel).length > 0;
|
||||
|
||||
const currentFormDataExtended = currentFormData as JsonObject;
|
||||
const bypassNoResult = !(
|
||||
formData?.server_pagination &&
|
||||
currentFormDataExtended?.server_pagination &&
|
||||
(hasSearchText || hasAgGridFilters)
|
||||
);
|
||||
|
||||
@@ -376,7 +512,7 @@ class ChartRenderer extends Component {
|
||||
<ChartContextMenu
|
||||
ref={this.contextMenuRef}
|
||||
id={chartId}
|
||||
formData={currentFormData}
|
||||
formData={currentFormData as QueryFormData}
|
||||
onSelection={this.handleContextMenuSelected}
|
||||
onClose={this.handleContextMenuClosed}
|
||||
/>
|
||||
@@ -400,9 +536,10 @@ class ChartRenderer extends Component {
|
||||
formData={currentFormData}
|
||||
ownState={ownState}
|
||||
filterState={filterState}
|
||||
hooks={this.hooks}
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
hooks={this.hooks as any}
|
||||
behaviors={behaviors}
|
||||
queriesData={this.mutableQueriesResponse}
|
||||
queriesData={this.mutableQueriesResponse ?? undefined}
|
||||
onRenderSuccess={this.handleRenderSuccess}
|
||||
onRenderFailure={this.handleRenderFailure}
|
||||
noResults={noResultsComponent}
|
||||
@@ -419,7 +556,4 @@ class ChartRenderer extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
ChartRenderer.propTypes = propTypes;
|
||||
ChartRenderer.defaultProps = defaultProps;
|
||||
|
||||
export default ChartRenderer;
|
||||
@@ -29,6 +29,7 @@ import { useSelector } from 'react-redux';
|
||||
import { t } from '@apache-superset/core';
|
||||
import {
|
||||
BinaryQueryObjectFilterClause,
|
||||
DatasourceType,
|
||||
ensureIsArray,
|
||||
JsonObject,
|
||||
QueryFormData,
|
||||
@@ -237,8 +238,8 @@ export default function DrillDetailPane({
|
||||
const jsonPayload = getDrillPayload(formData, filters) ?? {};
|
||||
const cachePageLimit = Math.ceil(SAMPLES_ROW_LIMIT / PAGE_SIZE);
|
||||
getDatasourceSamples(
|
||||
datasourceType,
|
||||
datasourceId,
|
||||
datasourceType as DatasourceType,
|
||||
Number(datasourceId),
|
||||
false,
|
||||
jsonPayload,
|
||||
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 fetchMock from 'fetch-mock';
|
||||
import sinon from 'sinon';
|
||||
import sinon, { SinonSpy, SinonStub } from 'sinon';
|
||||
|
||||
import {
|
||||
FeatureFlag,
|
||||
SupersetClient,
|
||||
getChartMetadataRegistry,
|
||||
getChartBuildQueryRegistry,
|
||||
QueryFormData,
|
||||
JsonObject,
|
||||
AnnotationLayer,
|
||||
AnnotationType,
|
||||
AnnotationSourceType,
|
||||
AnnotationStyle,
|
||||
} from '@superset-ui/core';
|
||||
import { LOG_EVENT } from 'src/logger/actions';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
@@ -37,10 +43,27 @@ import configureMockStore from 'redux-mock-store';
|
||||
import thunk from 'redux-thunk';
|
||||
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 mockStore = configureMockStore(middlewares);
|
||||
|
||||
const mockGetState = () => ({
|
||||
const mockGetState = (): MockState => ({
|
||||
charts: {
|
||||
chartKey: {
|
||||
latestQueryFormData: {
|
||||
@@ -60,21 +83,30 @@ jest.mock('@superset-ui/core', () => ({
|
||||
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
|
||||
describe('chart actions', () => {
|
||||
const MOCK_URL = '/mockURL';
|
||||
let dispatch;
|
||||
let getExploreUrlStub;
|
||||
let getChartDataUriStub;
|
||||
let buildV1ChartDataPayloadStub;
|
||||
let waitForAsyncDataStub;
|
||||
let fakeMetadata;
|
||||
let dispatch: SinonSpy;
|
||||
let getExploreUrlStub: SinonStub;
|
||||
let getChartDataUriStub: SinonStub;
|
||||
let buildV1ChartDataPayloadStub: SinonStub;
|
||||
let waitForAsyncDataStub: SinonStub;
|
||||
let fakeMetadata: { useLegacyApi?: boolean; viz_type?: string };
|
||||
|
||||
beforeAll(() => {
|
||||
fetchMock.get('glob:*api/v1/security/csrf_token/*', { result: '1234' });
|
||||
});
|
||||
|
||||
const setupDefaultFetchMock = () => {
|
||||
const setupDefaultFetchMock = (): void => {
|
||||
fetchMock.post(`glob:*${MOCK_URL}*`, { json: {} }, { name: MOCK_URL });
|
||||
};
|
||||
|
||||
@@ -91,41 +123,51 @@ describe('chart actions', () => {
|
||||
.callsFake(() => MOCK_URL);
|
||||
getChartDataUriStub = sinon
|
||||
.stub(exploreUtils, 'getChartDataUri')
|
||||
.callsFake(({ qs }) => URI(MOCK_URL).query(qs));
|
||||
.callsFake(({ qs }: { qs?: Record<string, unknown> }) =>
|
||||
URI(MOCK_URL).query(qs || {}),
|
||||
);
|
||||
buildV1ChartDataPayloadStub = sinon
|
||||
.stub(exploreUtils, 'buildV1ChartDataPayload')
|
||||
.resolves({
|
||||
some_param: 'fake query!',
|
||||
result_type: 'full',
|
||||
result_format: 'json',
|
||||
});
|
||||
} as unknown as Awaited<
|
||||
ReturnType<typeof exploreUtils.buildV1ChartDataPayload>
|
||||
>);
|
||||
fakeMetadata = { useLegacyApi: true };
|
||||
getChartMetadataRegistry.mockImplementation(() => ({
|
||||
get: () => fakeMetadata,
|
||||
}));
|
||||
getChartBuildQueryRegistry.mockImplementation(() => ({
|
||||
get: () => () => ({
|
||||
some_param: 'fake query!',
|
||||
result_type: 'full',
|
||||
result_format: 'json',
|
||||
}),
|
||||
}));
|
||||
mockedGetChartMetadataRegistry.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
get: () => fakeMetadata,
|
||||
}) as unknown as ReturnType<typeof getChartMetadataRegistry>,
|
||||
);
|
||||
mockedGetChartBuildQueryRegistry.mockImplementation(
|
||||
() =>
|
||||
({
|
||||
get: () => () => ({
|
||||
some_param: 'fake query!',
|
||||
result_type: 'full',
|
||||
result_format: 'json',
|
||||
}),
|
||||
}) as unknown as ReturnType<typeof getChartBuildQueryRegistry>,
|
||||
);
|
||||
waitForAsyncDataStub = sinon
|
||||
.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 () => {
|
||||
jest.useFakeTimers();
|
||||
const chartKey = 'defer_abort_test';
|
||||
const formData = {
|
||||
const formData: Partial<QueryFormData> = {
|
||||
slice_id: 123,
|
||||
datasource: 'table__1',
|
||||
viz_type: 'table',
|
||||
};
|
||||
const oldController = new AbortController();
|
||||
const abortSpy = jest.spyOn(oldController, 'abort');
|
||||
const state = {
|
||||
const state: MockState = {
|
||||
charts: {
|
||||
[chartKey]: {
|
||||
queryController: oldController,
|
||||
@@ -142,7 +184,7 @@ describe('chart actions', () => {
|
||||
const getChartDataRequestSpy = jest
|
||||
.spyOn(actions, 'getChartDataRequest')
|
||||
.mockResolvedValue({
|
||||
response: { status: 200 },
|
||||
response: { status: 200 } as Response,
|
||||
json: { result: [] },
|
||||
});
|
||||
const handleChartDataResponseSpy = jest
|
||||
@@ -150,14 +192,27 @@ describe('chart actions', () => {
|
||||
.mockResolvedValue([]);
|
||||
const updateDataMaskSpy = jest
|
||||
.spyOn(dataMaskActions, 'updateDataMask')
|
||||
.mockReturnValue({ type: 'UPDATE_DATA_MASK' });
|
||||
.mockReturnValue({ type: 'UPDATE_DATA_MASK' } as ReturnType<
|
||||
typeof dataMaskActions.updateDataMask
|
||||
>);
|
||||
const getQuerySettingsStub = sinon
|
||||
.stub(exploreUtils, 'getQuerySettings')
|
||||
.returns([false, () => {}]);
|
||||
.returns([false, () => {}] as unknown as ReturnType<
|
||||
typeof exploreUtils.getQuerySettings
|
||||
>);
|
||||
|
||||
try {
|
||||
const thunk = actions.exploreJSON(formData, false, undefined, chartKey);
|
||||
const promise = thunk(dispatchMock, getState);
|
||||
const thunkAction = actions.exploreJSON(
|
||||
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(oldController.signal.aborted).toBe(false);
|
||||
@@ -185,7 +240,9 @@ describe('chart actions', () => {
|
||||
fetchMock.clearHistory();
|
||||
waitForAsyncDataStub.restore();
|
||||
|
||||
global.featureFlags = {
|
||||
(
|
||||
global as unknown as { featureFlags: Record<string, boolean> }
|
||||
).featureFlags = {
|
||||
[FeatureFlag.GlobalAsyncQueries]: false,
|
||||
};
|
||||
});
|
||||
@@ -197,8 +254,17 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
test('should query with the built query', async () => {
|
||||
const actionThunk = actions.postChartFormData({}, null);
|
||||
await actionThunk(dispatch, mockGetState);
|
||||
const actionThunk = actions.postChartFormData(
|
||||
{} 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)[0].options.body).toBe(
|
||||
@@ -223,39 +289,55 @@ describe('chart actions', () => {
|
||||
.callsFake(() => URI(mockBigIntUrl));
|
||||
|
||||
const { json } = await actions.getChartDataRequest({
|
||||
formData: fakeMetadata,
|
||||
formData: fakeMetadata as QueryFormData,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const result = await handleChartDataResponse(
|
||||
{ status: 200 },
|
||||
{ result: [1, 2, 3] },
|
||||
{ status: 200 } as Response,
|
||||
{
|
||||
result: [
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
const result = await handleChartDataResponse(
|
||||
{ status: 200 },
|
||||
{ result: [1, 2, 3] },
|
||||
{ status: 200 } as Response,
|
||||
{
|
||||
result: [
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
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,
|
||||
};
|
||||
const result = await handleChartDataResponse(
|
||||
{ status: 202 },
|
||||
{ result: [1, 2, 3] },
|
||||
{ status: 202 } as Response,
|
||||
{
|
||||
result: [
|
||||
1, 2, 3,
|
||||
] as unknown as actions.ChartDataRequestResponse['json']['result'],
|
||||
},
|
||||
);
|
||||
expect(result).toEqual([1, 2, 3]);
|
||||
});
|
||||
@@ -268,9 +350,15 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||
@@ -279,8 +367,14 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
test('should dispatch TRIGGER_QUERY action with the query', () => {
|
||||
const actionThunk = actions.postChartFormData({});
|
||||
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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
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', () => {
|
||||
const actionThunk = actions.postChartFormData({});
|
||||
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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||
@@ -299,8 +399,14 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
test('should dispatch logEvent async action', () => {
|
||||
const actionThunk = actions.postChartFormData({});
|
||||
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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||
@@ -313,8 +419,15 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
test('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
|
||||
const actionThunk = actions.postChartFormData({});
|
||||
return actionThunk(dispatch, mockGetState).then(() => {
|
||||
// Pass a viz_type so getQuerySettings returns useLegacyApi from the mocked registry
|
||||
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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||
@@ -330,9 +443,17 @@ describe('chart actions', () => {
|
||||
});
|
||||
|
||||
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
|
||||
expect(fetchMock.callHistory.calls(MOCK_URL)).toHaveLength(1);
|
||||
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 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
|
||||
expect(dispatch.callCount).toBe(5);
|
||||
const updateFailedAction = dispatch.args[4][0];
|
||||
@@ -375,11 +504,19 @@ describe('chart actions', () => {
|
||||
);
|
||||
|
||||
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
|
||||
.map(call => call[0] && call[0].type)
|
||||
.map((call: [{ type?: string }]) => call[0] && call[0].type)
|
||||
.filter(Boolean);
|
||||
|
||||
expect(types).toContain(actions.CHART_UPDATE_STOPPED);
|
||||
@@ -401,12 +538,15 @@ describe('chart actions', () => {
|
||||
.stub(exploreUtils, 'getExploreUrl')
|
||||
.callsFake(() => mockBigIntUrl);
|
||||
|
||||
// Need viz_type to trigger the mocked getChartMetadataRegistry for legacy API
|
||||
const { json } = await actions.getChartDataRequest({
|
||||
formData: fakeMetadata,
|
||||
formData: { ...fakeMetadata, viz_type: 'table' } as QueryFormData,
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const annotation = {
|
||||
name: 'Holidays',
|
||||
annotationType: 'EVENT',
|
||||
sourceType: 'NATIVE',
|
||||
annotationType: AnnotationType.Event,
|
||||
sourceType: AnnotationSourceType.Native,
|
||||
color: null,
|
||||
opacity: '',
|
||||
style: 'solid',
|
||||
opacity: undefined,
|
||||
style: AnnotationStyle.Solid,
|
||||
width: 1,
|
||||
showMarkers: false,
|
||||
hideLine: false,
|
||||
@@ -438,12 +578,15 @@ describe('chart actions', () => {
|
||||
descriptionColumns: [],
|
||||
timeColumn: '',
|
||||
intervalEndColumn: '',
|
||||
};
|
||||
} as AnnotationLayer;
|
||||
const key = undefined;
|
||||
|
||||
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 buildV1ChartDataPayloadSpy = jest.spyOn(
|
||||
exploreUtils,
|
||||
@@ -451,7 +594,11 @@ describe('chart actions', () => {
|
||||
);
|
||||
|
||||
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({
|
||||
formData: {
|
||||
@@ -475,9 +622,14 @@ describe('chart actions timeout', () => {
|
||||
|
||||
test('should use the timeout from arguments when given', async () => {
|
||||
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 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 store = mockStore(initialState);
|
||||
@@ -485,21 +637,22 @@ describe('chart actions timeout', () => {
|
||||
actions.runAnnotationQuery({
|
||||
annotation: {
|
||||
value: 'annotationValue',
|
||||
sourceType: 'Event',
|
||||
sourceType: AnnotationSourceType.Native,
|
||||
overrides: {},
|
||||
},
|
||||
} as unknown as AnnotationLayer,
|
||||
timeout,
|
||||
formData,
|
||||
formData: formData as QueryFormData,
|
||||
key,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const expectedPayload = {
|
||||
url: expect.any(String),
|
||||
signal: expect.any(AbortSignal),
|
||||
url: expect.any(String) as string,
|
||||
signal: expect.any(AbortSignal) as AbortSignal,
|
||||
timeout: timeout * 1000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
jsonPayload: expect.any(Object),
|
||||
jsonPayload: expect.any(Object) as JsonObject,
|
||||
};
|
||||
|
||||
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 () => {
|
||||
const postSpy = jest.spyOn(SupersetClient, 'post');
|
||||
postSpy.mockImplementation(() => Promise.resolve({ json: { result: [] } }));
|
||||
const formData = { datasource: 'table__1' }; // Set the formData here
|
||||
postSpy.mockImplementation(
|
||||
() =>
|
||||
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 store = mockStore(initialState);
|
||||
@@ -516,21 +674,27 @@ describe('chart actions timeout', () => {
|
||||
actions.runAnnotationQuery({
|
||||
annotation: {
|
||||
value: 'annotationValue',
|
||||
sourceType: 'Event',
|
||||
sourceType: AnnotationSourceType.Native,
|
||||
overrides: {},
|
||||
},
|
||||
undefined,
|
||||
formData,
|
||||
} as unknown as AnnotationLayer,
|
||||
timeout: undefined,
|
||||
formData: formData as QueryFormData,
|
||||
key,
|
||||
}),
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
}) as any,
|
||||
);
|
||||
|
||||
const expectedPayload = {
|
||||
url: expect.any(String),
|
||||
signal: expect.any(AbortSignal),
|
||||
timeout: initialState.common.conf.SUPERSET_WEBSERVER_TIMEOUT * 1000,
|
||||
url: expect.any(String) as string,
|
||||
signal: expect.any(AbortSignal) as AbortSignal,
|
||||
timeout:
|
||||
(
|
||||
initialState.common.conf as unknown as {
|
||||
SUPERSET_WEBSERVER_TIMEOUT: number;
|
||||
}
|
||||
).SUPERSET_WEBSERVER_TIMEOUT * 1000,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
jsonPayload: expect.any(Object),
|
||||
jsonPayload: expect.any(Object) as JsonObject,
|
||||
};
|
||||
|
||||
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 => {
|
||||
const updatedCollection = {
|
||||
...prevState.collection,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,16 +26,16 @@ import {
|
||||
FormLabel,
|
||||
} from '@superset-ui/core/components';
|
||||
|
||||
interface FieldProps<V> {
|
||||
export interface FieldProps<V> {
|
||||
fieldKey: string;
|
||||
value?: V;
|
||||
label: string;
|
||||
description?: ReactNode;
|
||||
control: ReactElement;
|
||||
additionalControl?: ReactElement;
|
||||
onChange: (fieldKey: string, newValue: V) => void;
|
||||
compact: boolean;
|
||||
inline: boolean;
|
||||
onChange?: (fieldKey: string, newValue: V) => void;
|
||||
compact?: boolean;
|
||||
inline?: boolean;
|
||||
errorMessage?: string | ReactElement;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ export default function Field<V>({
|
||||
additionalControl,
|
||||
onChange = () => {},
|
||||
compact = false,
|
||||
inline,
|
||||
inline = false,
|
||||
errorMessage,
|
||||
}: FieldProps<V>) {
|
||||
const onControlChange = useCallback(
|
||||
|
||||
@@ -22,10 +22,10 @@ import { css } from '@apache-superset/core/ui';
|
||||
import { recurseReactClone } from '../../utils';
|
||||
import Field from '../Field';
|
||||
|
||||
interface FieldsetProps {
|
||||
export interface FieldsetProps {
|
||||
children: ReactNode;
|
||||
onChange: (updatedItem: Record<string, any>) => void;
|
||||
item: Record<string, any>;
|
||||
onChange?: (updatedItem: Record<string, any>) => void;
|
||||
item?: Record<string, any>;
|
||||
title?: ReactNode;
|
||||
compact?: boolean;
|
||||
}
|
||||
@@ -35,13 +35,13 @@ type fieldKeyType = string | number;
|
||||
export default function Fieldset({
|
||||
children,
|
||||
onChange,
|
||||
item,
|
||||
item = {},
|
||||
title = null,
|
||||
compact = false,
|
||||
}: FieldsetProps) {
|
||||
const handleChange = useCallback(
|
||||
(fieldKey: fieldKeyType, val: any) => {
|
||||
onChange({
|
||||
onChange?.({
|
||||
...item,
|
||||
[fieldKey]: val,
|
||||
});
|
||||
@@ -51,7 +51,7 @@ export default function Fieldset({
|
||||
|
||||
const propExtender = (field: { props: { fieldKey: fieldKeyType } }) => ({
|
||||
onChange: handleChange,
|
||||
value: item[field.props.fieldKey],
|
||||
value: item?.[field.props.fieldKey],
|
||||
compact,
|
||||
});
|
||||
|
||||
|
||||
@@ -54,20 +54,26 @@ export interface CRUDCollectionProps {
|
||||
expandFieldset?: ReactNode;
|
||||
extraButtons?: ReactNode;
|
||||
itemGenerator?: () => any;
|
||||
itemCellProps?: ((
|
||||
val: unknown,
|
||||
label: string,
|
||||
record: any,
|
||||
) => DetailedHTMLProps<
|
||||
TdHTMLAttributes<HTMLTableCellElement>,
|
||||
HTMLTableCellElement
|
||||
>)[];
|
||||
itemRenderers?: ((
|
||||
val: unknown,
|
||||
onChange: () => void,
|
||||
label: string,
|
||||
record: any,
|
||||
) => ReactNode)[];
|
||||
itemCellProps?: Record<
|
||||
string,
|
||||
(
|
||||
val: unknown,
|
||||
label: string,
|
||||
record: any,
|
||||
) => DetailedHTMLProps<
|
||||
TdHTMLAttributes<HTMLTableCellElement>,
|
||||
HTMLTableCellElement
|
||||
>
|
||||
>;
|
||||
itemRenderers?: Record<
|
||||
string,
|
||||
(
|
||||
val: unknown,
|
||||
onChange: (value: unknown) => void,
|
||||
label: string,
|
||||
record: any,
|
||||
) => ReactNode
|
||||
>;
|
||||
onChange?: (arg0: any) => void;
|
||||
tableColumns: any[];
|
||||
tableLayout?: 'fixed' | 'auto';
|
||||
|
||||
@@ -16,44 +16,125 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Children, cloneElement } from 'react';
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
ReactNode,
|
||||
ReactElement,
|
||||
isValidElement,
|
||||
} from 'react';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import { tn } from '@apache-superset/core/ui';
|
||||
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
|
||||
* where the type specified is matched.
|
||||
*/
|
||||
return Children.map(children, child => {
|
||||
let newChild = child;
|
||||
if (child && child.type && child.type.name === type.name) {
|
||||
newChild = cloneElement(child, propExtender(child));
|
||||
if (
|
||||
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) {
|
||||
newChild = cloneElement(newChild, {
|
||||
children: recurseReactClone(
|
||||
newChild.props.children,
|
||||
type,
|
||||
propExtender,
|
||||
),
|
||||
});
|
||||
if (
|
||||
isValidElement(newChild) &&
|
||||
newChild.props &&
|
||||
(newChild.props as { children?: ReactNode }).children
|
||||
) {
|
||||
newChild = cloneElement(
|
||||
newChild as ReactElement<{ children: ReactNode }>,
|
||||
{
|
||||
children: recurseReactClone(
|
||||
(newChild.props as { children: ReactNode }).children,
|
||||
type,
|
||||
propExtender,
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
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;}>
|
||||
const databaseColumnNames = newCols.map(col => col.column_name);
|
||||
const currentCols = prevCols.reduce((agg, col) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
agg[col.column_name] = col;
|
||||
return agg;
|
||||
}, {});
|
||||
const columnChanges = {
|
||||
const currentCols = prevCols.reduce<Record<string, ColumnMetadata>>(
|
||||
(agg, col) => {
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
agg[col.column_name] = col;
|
||||
return agg;
|
||||
},
|
||||
{},
|
||||
);
|
||||
const columnChanges: ColumnChanges = {
|
||||
added: [],
|
||||
modified: [],
|
||||
removed: prevCols
|
||||
@@ -137,12 +218,15 @@ export function updateColumns(prevCols, newCols, addSuccessToast) {
|
||||
* Fetches column metadata from the datasource's underlying table/view.
|
||||
* Used to sync dataset columns with the database schema.
|
||||
*
|
||||
* @param {Object} datasource - The datasource object
|
||||
* @param {AbortSignal} [signal] - Optional AbortSignal to cancel the request
|
||||
* @returns {Promise<Array>} Array of column metadata objects
|
||||
* @param datasource - The datasource object
|
||||
* @param signal - Optional AbortSignal to cancel the request
|
||||
* @returns Promise Array of column metadata objects
|
||||
*/
|
||||
export async function fetchSyncedColumns(datasource, signal) {
|
||||
const params = {
|
||||
export async function fetchSyncedColumns(
|
||||
datasource: DatasourceForSync,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ColumnMetadata[]> {
|
||||
const params: SyncParams = {
|
||||
datasource_type: datasource.type || datasource.datasource_type,
|
||||
database_name:
|
||||
datasource.database?.database_name || datasource.database?.name,
|
||||
@@ -162,5 +246,5 @@ export async function fetchSyncedColumns(datasource, signal) {
|
||||
params,
|
||||
)}`;
|
||||
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
|
||||
const result = 'result' in json ? json.result[0] : json;
|
||||
if (response.status === 200) {
|
||||
setState([result]);
|
||||
setState([result as ChartDataResponseResult]);
|
||||
handleFilterLoadFinish();
|
||||
} else if (response.status === 202) {
|
||||
waitForAsyncData(result)
|
||||
waitForAsyncData(result as Parameters<typeof waitForAsyncData>[0])
|
||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||
setState(asyncResult);
|
||||
handleFilterLoadFinish();
|
||||
@@ -258,7 +258,7 @@ const FilterValue: FC<FilterValueProps> = ({
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setState(json.result);
|
||||
setState(json.result as ChartDataResponseResult[]);
|
||||
setError(undefined);
|
||||
handleFilterLoadFinish();
|
||||
}
|
||||
|
||||
@@ -499,10 +499,10 @@ const FiltersConfigForm = (
|
||||
|
||||
if (response.status === 200) {
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
defaultValueQueriesData: [result],
|
||||
defaultValueQueriesData: [result as ChartDataResponseResult],
|
||||
});
|
||||
} else if (response.status === 202) {
|
||||
waitForAsyncData(result)
|
||||
waitForAsyncData(result as Parameters<typeof waitForAsyncData>[0])
|
||||
.then((asyncResult: ChartDataResponseResult[]) => {
|
||||
setNativeFilterFieldValuesWrapper({
|
||||
defaultValueQueriesData: asyncResult,
|
||||
|
||||
@@ -181,7 +181,8 @@ test('getChartDataPayloads generates payloads for charts with state converters',
|
||||
|
||||
jest
|
||||
.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);
|
||||
|
||||
@@ -211,7 +212,8 @@ test('getChartDataPayloads filters by specific chartId when provided', async ()
|
||||
|
||||
jest
|
||||
.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, {
|
||||
chartId: 123,
|
||||
@@ -254,11 +256,12 @@ test('getChartDataPayloads handles errors during payload generation gracefully',
|
||||
|
||||
jest
|
||||
.spyOn(exploreUtils, 'buildV1ChartDataPayload')
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.mockImplementation((params: any) => {
|
||||
if (params.formData.viz_type === 'ag-grid-table') {
|
||||
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);
|
||||
@@ -287,7 +290,8 @@ test('getChartDataPayloads merges baseOwnState with converted chart state', asyn
|
||||
|
||||
const mockBuildPayload = jest
|
||||
.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 });
|
||||
|
||||
|
||||
@@ -17,7 +17,11 @@
|
||||
* 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 { isEmpty, isEqual } from 'lodash';
|
||||
import { NATIVE_FILTER_PREFIX } from 'src/dashboard/components/nativeFilters/FiltersConfigModal/utils';
|
||||
@@ -138,11 +142,11 @@ export const getChartDataPayloads = async (
|
||||
};
|
||||
|
||||
const payload = await buildV1ChartDataPayload({
|
||||
formData,
|
||||
formData: formData as unknown as QueryFormData,
|
||||
resultFormat: 'json',
|
||||
resultType: 'results',
|
||||
ownState,
|
||||
setDataMask: null,
|
||||
setDataMask: undefined,
|
||||
force: false,
|
||||
});
|
||||
|
||||
|
||||
@@ -164,8 +164,8 @@ export const getSlicePayload = async (
|
||||
force: false,
|
||||
resultFormat: 'json',
|
||||
resultType: 'full',
|
||||
setDataMask: null,
|
||||
ownState: null,
|
||||
setDataMask: undefined,
|
||||
ownState: undefined,
|
||||
});
|
||||
|
||||
const payload: Partial<PayloadSlice> = {
|
||||
|
||||
@@ -95,7 +95,8 @@ const MATRIXIFY_INCOMPATIBLE_CHARTS = new Set([
|
||||
|
||||
export type ControlPanelsContainerProps = {
|
||||
exploreState: ExplorePageState['explore'];
|
||||
actions: ExploreActions;
|
||||
// Only setControlValue is used from actions in this component
|
||||
actions: Pick<ExploreActions, 'setControlValue'>;
|
||||
datasource_type: DatasourceType;
|
||||
chart: ChartState;
|
||||
controls: Record<string, ControlState>;
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { CopyToClipboardButton } from '.';
|
||||
|
||||
test('Render a button', () => {
|
||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
||||
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
expect(screen.getByRole('button')).toBeInTheDocument();
|
||||
@@ -39,7 +39,7 @@ test('Should copy to clipboard', async () => {
|
||||
// @ts-ignore
|
||||
global.navigator.clipboard = { write: callback, writeText: callback };
|
||||
|
||||
render(<CopyToClipboardButton data={{ copy: 'data', data: 'copy' }} />, {
|
||||
render(<CopyToClipboardButton data={[{ copy: 'data', data: 'copy' }]} />, {
|
||||
useRedux: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -32,7 +32,10 @@ import {
|
||||
Radio,
|
||||
} from '@superset-ui/core/components';
|
||||
import { CopyToClipboard } from 'src/components';
|
||||
import { prepareCopyToClipboardTabularData } from 'src/utils/common';
|
||||
import {
|
||||
prepareCopyToClipboardTabularData,
|
||||
TabularDataRow,
|
||||
} from 'src/utils/common';
|
||||
import { getTimeColumns, setTimeColumns } from './utils';
|
||||
|
||||
export const CellNull = styled('span')`
|
||||
@@ -56,7 +59,7 @@ export const CopyToClipboardButton = ({
|
||||
data,
|
||||
columns,
|
||||
}: {
|
||||
data?: Record<string, any>;
|
||||
data?: TabularDataRow[];
|
||||
columns?: string[];
|
||||
}) => (
|
||||
<CopyToClipboard
|
||||
|
||||
@@ -63,7 +63,8 @@ export const TableControls = ({
|
||||
name &&
|
||||
!originalTimeColumns.includes(name),
|
||||
)
|
||||
.map(([colname]) => colname);
|
||||
.map(([colname]) => colname)
|
||||
.filter((x): x is string => x !== undefined);
|
||||
const formattedData = useMemo(
|
||||
() => applyFormattingToTabularData(data, formattedTimeColumns),
|
||||
[data, formattedTimeColumns],
|
||||
|
||||
@@ -73,7 +73,9 @@ export const useResultsPane = ({
|
||||
// it's an invalid formData when gets a errorMessage
|
||||
if (errorMessage) return;
|
||||
if (isRequest && cache.has(queryFormData)) {
|
||||
setResultResp(ensureIsArray(cache.get(queryFormData)));
|
||||
setResultResp(
|
||||
ensureIsArray(cache.get(queryFormData)) as QueryResultInterface[],
|
||||
);
|
||||
setResponseError('');
|
||||
if (queryForce) {
|
||||
setForceQuery?.(false);
|
||||
@@ -90,7 +92,7 @@ export const useResultsPane = ({
|
||||
ownState,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setResultResp(ensureIsArray(json.result));
|
||||
setResultResp(ensureIsArray(json.result) as QueryResultInterface[]);
|
||||
setResponseError('');
|
||||
cache.set(queryFormData, json.result);
|
||||
if (queryForce) {
|
||||
|
||||
@@ -24,6 +24,11 @@ import EmbedCodeContent from 'src/explore/components/EmbedCodeContent';
|
||||
const url = 'http://localhost/explore/p/100';
|
||||
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
|
||||
describe('EmbedCodeButton', () => {
|
||||
test('renders', () => {
|
||||
@@ -31,7 +36,7 @@ describe('EmbedCodeButton', () => {
|
||||
});
|
||||
|
||||
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(
|
||||
@@ -16,7 +16,15 @@
|
||||
* specific language governing permissions and limitations
|
||||
* 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 { Input, Space, Typography } from '@superset-ui/core/components';
|
||||
import { CopyToClipboard } from 'src/components';
|
||||
@@ -24,13 +32,21 @@ import { URL_PARAMS } from 'src/constants';
|
||||
import { getChartPermalink } from 'src/utils/urlUtils';
|
||||
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 [width, setWidth] = useState('600');
|
||||
const [url, setUrl] = useState('');
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
|
||||
const handleInputChange = useCallback(e => {
|
||||
const handleInputChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
|
||||
const { value, name } = e.currentTarget;
|
||||
if (name === 'width') {
|
||||
setWidth(value);
|
||||
@@ -42,7 +58,8 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
||||
|
||||
const updateUrl = useCallback(() => {
|
||||
setUrl('');
|
||||
getChartPermalink(formData)
|
||||
if (!formData?.datasource) return;
|
||||
getChartPermalink(formData as { datasource: string })
|
||||
.then(result => {
|
||||
if (result?.url) {
|
||||
setUrl(result.url);
|
||||
@@ -51,7 +68,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
||||
})
|
||||
.catch(() => {
|
||||
setErrorMessage(t('Error'));
|
||||
addDangerToast(t('Sorry, something went wrong. Try again later.'));
|
||||
addDangerToast?.(t('Sorry, something went wrong. Try again later.'));
|
||||
});
|
||||
}, [addDangerToast, formData]);
|
||||
|
||||
@@ -98,7 +115,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
||||
name="embedCode"
|
||||
disabled={!html}
|
||||
value={text}
|
||||
rows="4"
|
||||
rows={4}
|
||||
readOnly
|
||||
css={theme => css`
|
||||
resize: vertical;
|
||||
@@ -111,7 +128,7 @@ const EmbedCodeContent = ({ formData, addDangerToast }) => {
|
||||
/>
|
||||
</div>
|
||||
<Space
|
||||
direction="horizzontal"
|
||||
direction="horizontal"
|
||||
css={theme => css`
|
||||
margin-top: ${theme.margin}px;
|
||||
`}
|
||||
@@ -32,7 +32,7 @@ import * as downloadAsImage from 'src/utils/downloadAsImage';
|
||||
import * as exploreUtils from 'src/explore/exploreUtils';
|
||||
import { FeatureFlag, VizType } from '@superset-ui/core';
|
||||
import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
||||
import ExploreHeader from '.';
|
||||
import ExploreHeader, { ExploreChartHeaderProps } from '.';
|
||||
import { getChartMetadataRegistry } from '@superset-ui/core';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
@@ -56,90 +56,99 @@ const mockExportCurrentViewBehavior = () => {
|
||||
} as any);
|
||||
};
|
||||
|
||||
const createProps = (additionalProps = {}) => ({
|
||||
chart: {
|
||||
id: 1,
|
||||
latestQueryFormData: {
|
||||
viz_type: VizType.Histogram,
|
||||
datasource: '49__table',
|
||||
slice_id: 318,
|
||||
url_params: {},
|
||||
granularity_sqla: 'time_start',
|
||||
time_range: 'No filter',
|
||||
all_columns_x: ['age'],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10000,
|
||||
groupby: null,
|
||||
color_scheme: 'supersetColors',
|
||||
label_colors: {},
|
||||
link_length: '25',
|
||||
x_axis_label: 'age',
|
||||
y_axis_label: 'count',
|
||||
server_pagination: false as any,
|
||||
},
|
||||
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,
|
||||
const createProps = (additionalProps = {}) =>
|
||||
({
|
||||
chart: {
|
||||
id: 1,
|
||||
latestQueryFormData: {
|
||||
viz_type: VizType.Histogram,
|
||||
datasource: '49__table',
|
||||
slice_id: 318,
|
||||
url_params: {},
|
||||
granularity_sqla: 'time_start',
|
||||
time_range: 'No filter',
|
||||
all_columns_x: ['age'],
|
||||
adhoc_filters: [],
|
||||
row_limit: 10000,
|
||||
groupby: null,
|
||||
color_scheme: 'supersetColors',
|
||||
label_colors: {},
|
||||
link_length: '25',
|
||||
x_axis_label: 'age',
|
||||
y_axis_label: 'count',
|
||||
server_pagination: false,
|
||||
},
|
||||
],
|
||||
slice_id: 318,
|
||||
slice_name: 'Age distribution of respondents',
|
||||
slice_url: '/explore/?form_data=%7B%22slice_id%22%3A%20318%7D',
|
||||
},
|
||||
slice_name: '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,
|
||||
});
|
||||
chartStatus: 'rendered' as const,
|
||||
chartAlert: null,
|
||||
chartUpdateEndTime: null,
|
||||
chartUpdateStartTime: 0,
|
||||
lastRendered: 0,
|
||||
sliceFormData: null,
|
||||
queryController: null,
|
||||
queriesResponse: null,
|
||||
triggerQuery: false,
|
||||
},
|
||||
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,
|
||||
},
|
||||
],
|
||||
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(
|
||||
'http://api/v1/chart/data?form_data=%7B%22slice_id%22%3A318%7D',
|
||||
@@ -165,27 +174,40 @@ describe('ExploreChartHeader', () => {
|
||||
const props = createProps();
|
||||
render(<ExploreHeader {...props} />, { useRedux: true });
|
||||
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(
|
||||
await screen.findByText(/add the name of the chart/i),
|
||||
await screen.findByDisplayValue(prevChartName ?? ''),
|
||||
).toBeInTheDocument();
|
||||
|
||||
userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
userEvent.click(screen.getByText('Edit chart properties'));
|
||||
await userEvent.click(screen.getByLabelText('Menu actions trigger'));
|
||||
await userEvent.click(screen.getByText('Edit chart properties'));
|
||||
|
||||
const nameInput = await screen.findByRole('textbox', { name: 'Name' });
|
||||
|
||||
userEvent.clear(nameInput);
|
||||
userEvent.type(nameInput, newChartName);
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, newChartName);
|
||||
|
||||
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'));
|
||||
userEvent.click(screen.getByText('Edit chart properties'));
|
||||
// Wait for the modal to close
|
||||
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 () => {
|
||||
@@ -203,7 +225,7 @@ describe('ExploreChartHeader', () => {
|
||||
<ExploreHeader
|
||||
{...props}
|
||||
metadata={{
|
||||
...props.metadata,
|
||||
...props.metadata!,
|
||||
dashboards: [
|
||||
{ id: 1, dashboard_title: 'Test' },
|
||||
{ id: 2, dashboard_title: 'Test2' },
|
||||
|
||||
@@ -16,10 +16,10 @@
|
||||
* specific language governing permissions and limitations
|
||||
* 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 { useDispatch } from 'react-redux';
|
||||
import PropTypes from 'prop-types';
|
||||
import { QueryFormData, JsonObject } from '@superset-ui/core';
|
||||
import {
|
||||
Tooltip,
|
||||
Button,
|
||||
@@ -27,10 +27,13 @@ import {
|
||||
UnsavedChangesModal,
|
||||
} from '@superset-ui/core/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 { css, t } from '@apache-superset/core/ui';
|
||||
import { chartPropShape } from 'src/dashboard/util/propShapes';
|
||||
import { css, t, SupersetTheme } from '@apache-superset/core/ui';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import PropertiesModal from 'src/explore/components/PropertiesModal';
|
||||
import { sliceUpdated } from 'src/explore/actions/exploreActions';
|
||||
@@ -43,35 +46,52 @@ import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt';
|
||||
import { getChartFormDiffs } from 'src/utils/getChartFormDiffs';
|
||||
import { StreamingExportModal } from 'src/components/StreamingExportModal';
|
||||
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 { useExploreMetadataBar } from './useExploreMetadataBar';
|
||||
|
||||
const propTypes = {
|
||||
actions: PropTypes.object.isRequired,
|
||||
canOverwrite: PropTypes.bool.isRequired,
|
||||
canDownload: PropTypes.bool.isRequired,
|
||||
dashboardId: PropTypes.number,
|
||||
colorScheme: PropTypes.string,
|
||||
isStarred: PropTypes.bool.isRequired,
|
||||
slice: PropTypes.object,
|
||||
sliceName: PropTypes.string,
|
||||
table_name: PropTypes.string,
|
||||
formData: PropTypes.object,
|
||||
ownState: PropTypes.object,
|
||||
timeout: PropTypes.number,
|
||||
chart: chartPropShape,
|
||||
saveDisabled: PropTypes.bool,
|
||||
isSaveModalVisible: PropTypes.bool,
|
||||
};
|
||||
interface ExploreActions {
|
||||
updateChartTitle: (title: string) => void;
|
||||
fetchFaveStar: (sliceId: number) => void;
|
||||
saveFaveStar: (sliceId: number, isStarred: boolean) => void;
|
||||
redirectSQLLab: (
|
||||
formData: QueryFormData,
|
||||
history?: ReturnType<typeof useHistory> | false,
|
||||
) => void;
|
||||
}
|
||||
|
||||
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};
|
||||
& > span[role='img'] {
|
||||
margin-right: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
const additionalItemsStyles = theme => css`
|
||||
const additionalItemsStyles = (theme: SupersetTheme) => css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: ${theme.sizeUnit}px;
|
||||
@@ -80,7 +100,7 @@ const additionalItemsStyles = theme => css`
|
||||
}
|
||||
`;
|
||||
|
||||
export const ExploreChartHeader = ({
|
||||
export const ExploreChartHeader: FC<ExploreChartHeaderProps> = ({
|
||||
dashboardId,
|
||||
colorScheme: dashboardColorScheme,
|
||||
slice,
|
||||
@@ -101,7 +121,8 @@ export const ExploreChartHeader = ({
|
||||
const { latestQueryFormData, sliceFormData } = chart;
|
||||
const [isPropertiesModalOpen, setIsPropertiesModalOpen] = 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 updateCategoricalNamespace = useCallback(async () => {
|
||||
@@ -155,14 +176,14 @@ export const ExploreChartHeader = ({
|
||||
};
|
||||
|
||||
const updateSlice = useCallback(
|
||||
slice => {
|
||||
dispatch(sliceUpdated(slice));
|
||||
(updatedSlice: Slice) => {
|
||||
dispatch(sliceUpdated(updatedSlice));
|
||||
},
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
const handleReportDelete = async report => {
|
||||
await dispatch(deleteActiveReport(report));
|
||||
const handleReportDelete = async (report: AlertObject) => {
|
||||
await dispatch(deleteActiveReport(report as unknown as ReportObject));
|
||||
setCurrentReportDeleting(null);
|
||||
};
|
||||
|
||||
@@ -170,8 +191,8 @@ export const ExploreChartHeader = ({
|
||||
const { redirectSQLLab } = actions;
|
||||
|
||||
const redirectToSQLLab = useCallback(
|
||||
(formData, openNewWindow = false) => {
|
||||
redirectSQLLab(formData, !openNewWindow && history);
|
||||
(redirectFormData: QueryFormData, openNewWindow = false) => {
|
||||
redirectSQLLab(redirectFormData, !openNewWindow && history);
|
||||
},
|
||||
[redirectSQLLab, history],
|
||||
);
|
||||
@@ -189,7 +210,7 @@ export const ExploreChartHeader = ({
|
||||
setCurrentReportDeleting,
|
||||
);
|
||||
|
||||
const metadataBar = useExploreMetadataBar(metadata, slice);
|
||||
const metadataBar = useExploreMetadataBar(metadata, slice ?? null);
|
||||
const oldSliceName = slice?.slice_name;
|
||||
|
||||
const originalFormData = useMemo(() => {
|
||||
@@ -218,7 +239,9 @@ export const ExploreChartHeader = ({
|
||||
triggerManualSave,
|
||||
} = useUnsavedChangesPrompt({
|
||||
hasUnsavedChanges: Object.keys(formDiffs).length > 0,
|
||||
onSave: () => dispatch(setSaveChartModalVisibility(true)),
|
||||
onSave: () => {
|
||||
dispatch(setSaveChartModalVisibility(true));
|
||||
},
|
||||
isSaveModalVisible,
|
||||
manualSaveOnUnsavedChanges: true,
|
||||
});
|
||||
@@ -245,7 +268,8 @@ export const ExploreChartHeader = ({
|
||||
canEdit:
|
||||
!slice ||
|
||||
canOverwrite ||
|
||||
(slice?.owners || []).includes(user?.userId),
|
||||
(user?.userId !== undefined &&
|
||||
(slice?.owners || []).includes(user.userId)),
|
||||
onSave: actions.updateChartTitle,
|
||||
placeholder: t('Add the name of the chart'),
|
||||
label: t('Chart title'),
|
||||
@@ -255,9 +279,9 @@ export const ExploreChartHeader = ({
|
||||
certifiedBy: slice?.certified_by,
|
||||
details: slice?.certification_details,
|
||||
}}
|
||||
showFaveStar={!!user?.userId}
|
||||
showFaveStar={!!user?.userId && slice?.slice_id !== undefined}
|
||||
faveStarProps={{
|
||||
itemId: slice?.slice_id,
|
||||
itemId: slice?.slice_id ?? 0,
|
||||
fetchFaveStar: actions.fetchFaveStar,
|
||||
saveFaveStar: actions.saveFaveStar,
|
||||
isStarred,
|
||||
@@ -269,11 +293,11 @@ export const ExploreChartHeader = ({
|
||||
<AlteredSliceTag
|
||||
className="altered"
|
||||
diffs={formDiffs}
|
||||
origFormData={originalFormData}
|
||||
currentFormData={currentFormData}
|
||||
origFormData={originalFormData as QueryFormData}
|
||||
currentFormData={currentFormData as QueryFormData}
|
||||
/>
|
||||
) : null}
|
||||
{formData && isMatrixifyEnabled(formData) && (
|
||||
{formData && isMatrixifyEnabled(formData as MatrixifyFormData) && (
|
||||
<Tag name="Matrixified" color="purple" />
|
||||
)}
|
||||
{metadataBar}
|
||||
@@ -364,6 +388,4 @@ export const ExploreChartHeader = ({
|
||||
);
|
||||
};
|
||||
|
||||
ExploreChartHeader.propTypes = propTypes;
|
||||
|
||||
export default ExploreChartHeader;
|
||||
@@ -88,6 +88,7 @@ export interface ExploreChartPanelProps {
|
||||
errorMessage?: ReactNode;
|
||||
triggerRender?: boolean;
|
||||
chartAlert?: string;
|
||||
exploreState?: JsonObject;
|
||||
}
|
||||
|
||||
type PanelSizes = [number, number];
|
||||
@@ -181,14 +182,14 @@ const ExploreChartPanel = ({
|
||||
|
||||
const updateQueryContext = useCallback(
|
||||
async function fetchChartData() {
|
||||
if (slice && slice.query_context === null) {
|
||||
if (slice && slice.query_context === null && slice.form_data) {
|
||||
const queryContext = await buildV1ChartDataPayload({
|
||||
formData: slice.form_data,
|
||||
force,
|
||||
resultFormat: 'json',
|
||||
resultType: 'full',
|
||||
setDataMask: null,
|
||||
ownState: null,
|
||||
setDataMask: undefined,
|
||||
ownState: undefined,
|
||||
});
|
||||
|
||||
await SupersetClient.put({
|
||||
@@ -269,7 +270,9 @@ const ExploreChartPanel = ({
|
||||
onQuery={onQuery}
|
||||
queriesResponse={chart.queriesResponse}
|
||||
chartIsStale={chartIsStale}
|
||||
setControlValue={actions.setControlValue}
|
||||
setControlValue={(name, value) =>
|
||||
actions.setControlValue(name, value, chart.id)
|
||||
}
|
||||
timeout={timeout}
|
||||
triggerQuery={chart.triggerQuery}
|
||||
vizType={vizType}
|
||||
|
||||
@@ -17,16 +17,30 @@
|
||||
* under the License.
|
||||
*/
|
||||
/* eslint camelcase: 0 */
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import {
|
||||
ComponentType,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { bindActionCreators, Dispatch } from 'redux';
|
||||
import { connect } from 'react-redux';
|
||||
import {
|
||||
useChangeEffect,
|
||||
useComponentDidMount,
|
||||
usePrevious,
|
||||
isMatrixifyEnabled,
|
||||
QueryFormData,
|
||||
JsonObject,
|
||||
MatrixifyFormData,
|
||||
DatasourceType,
|
||||
} from '@superset-ui/core';
|
||||
import {
|
||||
ControlStateMapping,
|
||||
ControlPanelState,
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { t, styled, css, useTheme } from '@apache-superset/core/ui';
|
||||
import { logging } from '@apache-superset/core';
|
||||
import { debounce, isEqual, isObjectLike, omit, pick } from 'lodash';
|
||||
@@ -53,7 +67,6 @@ import { getUrlParam } from 'src/utils/urlUtils';
|
||||
import cx from 'classnames';
|
||||
import * as chartActions from 'src/components/Chart/chartAction';
|
||||
import { fetchDatasourceMetadata } from 'src/dashboard/actions/datasources';
|
||||
import { chartPropShape } from 'src/dashboard/util/propShapes';
|
||||
import { mergeExtraFormData } from 'src/dashboard/components/nativeFilters/utils';
|
||||
import { postFormData, putFormData } from 'src/explore/exploreUtils/formData';
|
||||
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 { useTabId } from 'src/hooks/useTabId';
|
||||
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 ConnectedControlPanelsContainer from '../ControlPanelsContainer';
|
||||
import SaveModal from '../SaveModal';
|
||||
@@ -70,30 +92,6 @@ import DataSourcePanel from '../DatasourcePanel';
|
||||
import ConnectedExploreChartHeader from '../ExploreChartHeader';
|
||||
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`
|
||||
${({ theme }) => css`
|
||||
text-align: left;
|
||||
@@ -187,13 +185,13 @@ const updateHistory = debounce(
|
||||
const urlParams = payload?.url_params || {};
|
||||
Object.entries(urlParams).forEach(([key, value]) => {
|
||||
if (!RESERVED_CHART_URL_PARAMS.includes(key)) {
|
||||
additionalParam[key] = value;
|
||||
additionalParam[key] = value as string;
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
let key;
|
||||
let stateModifier;
|
||||
let key: string | null | undefined;
|
||||
let stateModifier: 'replaceState' | 'pushState';
|
||||
if (isReplace) {
|
||||
key = await postFormData(
|
||||
datasourceId,
|
||||
@@ -205,22 +203,24 @@ const updateHistory = debounce(
|
||||
stateModifier = 'replaceState';
|
||||
} else {
|
||||
key = getUrlParam(URL_PARAMS.formDataKey);
|
||||
await putFormData(
|
||||
datasourceId,
|
||||
datasourceType,
|
||||
key,
|
||||
formData,
|
||||
chartId,
|
||||
tabId,
|
||||
);
|
||||
if (key) {
|
||||
await putFormData(
|
||||
datasourceId,
|
||||
datasourceType,
|
||||
key,
|
||||
formData,
|
||||
chartId,
|
||||
tabId,
|
||||
);
|
||||
}
|
||||
stateModifier = 'pushState';
|
||||
}
|
||||
// avoid race condition in case user changes route before explore updates the url
|
||||
if (window.location.pathname.startsWith(ensureAppRoot('/explore'))) {
|
||||
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,
|
||||
},
|
||||
force,
|
||||
@@ -234,16 +234,27 @@ const updateHistory = debounce(
|
||||
1000,
|
||||
);
|
||||
|
||||
const defaultSidebarsWidth = {
|
||||
type DefaultSidebarWidthKey = 'controls_width' | 'datasource_width';
|
||||
|
||||
const defaultSidebarsWidth: Record<DefaultSidebarWidthKey, number> = {
|
||||
controls_width: 320,
|
||||
datasource_width: 300,
|
||||
};
|
||||
|
||||
function getSidebarWidths(key) {
|
||||
return getItem(key, defaultSidebarsWidth[key]);
|
||||
function getSidebarWidths(
|
||||
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;
|
||||
setItem(key, newDimension);
|
||||
}
|
||||
@@ -266,13 +277,100 @@ const AGGREGATED_CHART_TYPES = [
|
||||
'table',
|
||||
];
|
||||
|
||||
function isAggregatedChartType(vizType) {
|
||||
return AGGREGATED_CHART_TYPES.includes(vizType);
|
||||
function isAggregatedChartType(vizType: string | undefined): boolean {
|
||||
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 dynamicPlugin = dynamicPluginContext.dynamicPlugins[props.vizType];
|
||||
const dynamicPlugin = props.vizType
|
||||
? dynamicPluginContext.dynamicPlugins[props.vizType]
|
||||
: undefined;
|
||||
const isDynamicPluginLoading = dynamicPlugin && dynamicPlugin.mounting;
|
||||
const wasDynamicPluginLoading = usePrevious(isDynamicPluginLoading);
|
||||
|
||||
@@ -362,7 +460,7 @@ function ExploreViewContainer(props) {
|
||||
props.actions.setForceQuery(false);
|
||||
|
||||
// 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
|
||||
props.actions.chartUpdateSucceeded([], props.chart.id);
|
||||
props.actions.chartRenderingSucceeded(props.chart.id);
|
||||
@@ -386,31 +484,18 @@ function ExploreViewContainer(props) {
|
||||
]);
|
||||
|
||||
const handleKeydown = useCallback(
|
||||
event => {
|
||||
(event: KeyboardEvent) => {
|
||||
const controlOrCommand = event.ctrlKey || event.metaKey;
|
||||
if (controlOrCommand) {
|
||||
const isEnter = event.key === 'Enter' || event.keyCode === 13;
|
||||
const isS = event.key === 's' || event.keyCode === 83;
|
||||
if (isEnter) {
|
||||
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() {
|
||||
@@ -430,7 +515,7 @@ function ExploreViewContainer(props) {
|
||||
? {
|
||||
slice_id: props.slice.slice_id,
|
||||
}
|
||||
: undefined,
|
||||
: {},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -480,7 +565,7 @@ function ExploreViewContainer(props) {
|
||||
}, []);
|
||||
|
||||
const reRenderChart = useCallback(
|
||||
controlsChanged => {
|
||||
(controlsChanged?: string[]) => {
|
||||
const newQueryFormData = controlsChanged
|
||||
? {
|
||||
...props.chart.latestQueryFormData,
|
||||
@@ -512,7 +597,7 @@ function ExploreViewContainer(props) {
|
||||
props.controls.datasource.value !== previousControls.datasource.value)
|
||||
) {
|
||||
// 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(
|
||||
@@ -525,15 +610,29 @@ function ExploreViewContainer(props) {
|
||||
);
|
||||
|
||||
if (changedControlKeys.includes('tooltip_contents')) {
|
||||
const tooltipContents = props.controls.tooltip_contents?.value || [];
|
||||
const currentTemplate = props.controls.tooltip_template?.value || '';
|
||||
const tooltipContentsValue = props.controls.tooltip_contents?.value;
|
||||
const tooltipContents = Array.isArray(tooltipContentsValue)
|
||||
? tooltipContentsValue
|
||||
: [];
|
||||
const currentTemplateValue = props.controls.tooltip_template?.value;
|
||||
const currentTemplate =
|
||||
typeof currentTemplateValue === 'string' ? currentTemplateValue : '';
|
||||
|
||||
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 (item?.item_type === 'column') return item.column_name;
|
||||
if (item?.item_type === 'column') return item.column_name ?? null;
|
||||
if (item?.item_type === 'metric') {
|
||||
return item.metric_name || item.label;
|
||||
return item.metric_name || item.label || 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 fieldNames = tooltipContents.map(getFieldName).filter(Boolean);
|
||||
const fieldNames = tooltipContents
|
||||
.map(getFieldName)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
const missingVariables = fieldNames.filter(
|
||||
fieldName =>
|
||||
(fieldName: string) =>
|
||||
!currentTemplate.includes(`{{ ${fieldName} }}`) &&
|
||||
!currentTemplate.includes(`{{ limit ${fieldName}`),
|
||||
);
|
||||
|
||||
if (missingVariables.length > 0) {
|
||||
const newVariables = missingVariables.map(fieldName => {
|
||||
const newVariables = missingVariables.map((fieldName: string) => {
|
||||
const item = tooltipContents[fieldNames.indexOf(fieldName)];
|
||||
const isColumn =
|
||||
item?.item_type === 'column' || typeof item === 'string';
|
||||
(typeof item === 'object' && item?.item_type === 'column') ||
|
||||
typeof item === 'string';
|
||||
|
||||
if (isAggregatedChart && isColumn) {
|
||||
return `{{ limit ${fieldName} ${DEFAULT_TOOLTIP_LIMIT} }}`;
|
||||
@@ -609,7 +711,10 @@ function ExploreViewContainer(props) {
|
||||
}, [lastQueriedControls, props.controls]);
|
||||
|
||||
useChangeEffect(props.saveAction, () => {
|
||||
if (['saveas', 'overwrite'].includes(props.saveAction)) {
|
||||
if (
|
||||
props.saveAction &&
|
||||
['saveas', 'overwrite'].includes(props.saveAction)
|
||||
) {
|
||||
onQuery();
|
||||
addHistory({ isReplace: true });
|
||||
props.actions.setSaveAction(null);
|
||||
@@ -618,7 +723,7 @@ function ExploreViewContainer(props) {
|
||||
|
||||
const previousOwnState = usePrevious(props.ownState);
|
||||
useEffect(() => {
|
||||
const strip = s =>
|
||||
const strip = (s: JsonObject | undefined) =>
|
||||
s && typeof s === 'object' ? omit(s, ['clientView']) : s;
|
||||
if (!isEqual(strip(previousOwnState), strip(props.ownState))) {
|
||||
onQuery();
|
||||
@@ -627,7 +732,7 @@ function ExploreViewContainer(props) {
|
||||
}, [props.ownState]);
|
||||
|
||||
if (chartIsStale) {
|
||||
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS);
|
||||
props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS, {});
|
||||
}
|
||||
|
||||
const errorMessage = useMemo(() => {
|
||||
@@ -651,7 +756,10 @@ function ExploreViewContainer(props) {
|
||||
.filter(control => control.validationErrors?.includes(message))
|
||||
.map(control =>
|
||||
typeof control.label === 'function'
|
||||
? control.label(props.exploreState)
|
||||
? control.label(
|
||||
props.exploreState as unknown as ControlPanelState,
|
||||
control,
|
||||
)
|
||||
: control.label,
|
||||
);
|
||||
return [matchingLabels, message];
|
||||
@@ -694,7 +802,10 @@ function ExploreViewContainer(props) {
|
||||
.filter(control => control.validationErrors?.includes(message))
|
||||
.map(control =>
|
||||
typeof control.label === 'function'
|
||||
? control.label(props.exploreState)
|
||||
? control.label(
|
||||
props.exploreState as unknown as ControlPanelState,
|
||||
control,
|
||||
)
|
||||
: control.label,
|
||||
);
|
||||
return [matchingLabels, message];
|
||||
@@ -725,10 +836,35 @@ function ExploreViewContainer(props) {
|
||||
function renderChartContainer() {
|
||||
return (
|
||||
<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}
|
||||
chartIsStale={chartIsStale}
|
||||
onQuery={onQuery}
|
||||
exploreState={props.exploreState}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -740,21 +876,21 @@ function ExploreViewContainer(props) {
|
||||
return (
|
||||
<ExploreContainer>
|
||||
<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}
|
||||
canDownload={props.can_download}
|
||||
dashboardId={props.dashboardId}
|
||||
colorScheme={props.dashboardColorScheme}
|
||||
isStarred={props.isStarred}
|
||||
slice={props.slice}
|
||||
sliceName={props.sliceName}
|
||||
sliceName={props.sliceName ?? undefined}
|
||||
table_name={props.table_name}
|
||||
formData={props.form_data}
|
||||
chart={props.chart}
|
||||
ownState={props.ownState}
|
||||
user={props.user}
|
||||
reports={props.reports}
|
||||
saveDisabled={errorMessage || props.chart.chartStatus === 'loading'}
|
||||
saveDisabled={!!errorMessage || props.chart.chartStatus === 'loading'}
|
||||
metadata={props.metadata}
|
||||
isSaveModalVisible={props.isSaveModalVisible}
|
||||
/>
|
||||
@@ -817,14 +953,15 @@ function ExploreViewContainer(props) {
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
{/* eslint-disable @typescript-eslint/no-explicit-any -- DataSourcePanel uses narrower types that are compatible at runtime */}
|
||||
<DataSourcePanel
|
||||
formData={props.form_data}
|
||||
datasource={props.datasource}
|
||||
controls={props.controls}
|
||||
actions={props.actions}
|
||||
datasource={props.datasource as any}
|
||||
controls={props.controls as any}
|
||||
actions={props.actions as any}
|
||||
width={width}
|
||||
user={props.user}
|
||||
/>
|
||||
{/* eslint-enable @typescript-eslint/no-explicit-any */}
|
||||
</Resizable>
|
||||
{isCollapsed ? (
|
||||
<div
|
||||
@@ -863,7 +1000,8 @@ function ExploreViewContainer(props) {
|
||||
>
|
||||
<ConnectedControlPanelsContainer
|
||||
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}
|
||||
controls={props.controls}
|
||||
chart={props.chart}
|
||||
@@ -891,22 +1029,32 @@ function ExploreViewContainer(props) {
|
||||
addDangerToast={props.addDangerToast}
|
||||
actions={props.actions}
|
||||
form_data={props.form_data}
|
||||
sliceName={props.sliceName}
|
||||
dashboardId={props.dashboardId}
|
||||
sliceName={props.sliceName ?? undefined}
|
||||
dashboardId={props.dashboardId ?? null}
|
||||
/>
|
||||
)}
|
||||
</ExploreContainer>
|
||||
);
|
||||
}
|
||||
|
||||
ExploreViewContainer.propTypes = propTypes;
|
||||
|
||||
const retainQueryModeRequirements = hiddenFormData =>
|
||||
const retainQueryModeRequirements = (
|
||||
hiddenFormData: Partial<QueryFormData> | undefined,
|
||||
): string[] =>
|
||||
Object.keys(hiddenFormData ?? {}).filter(
|
||||
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 (
|
||||
form_data.viz_type === 'big_number_total' &&
|
||||
!form_data.subtitle &&
|
||||
@@ -917,7 +1065,7 @@ function patchBigNumberTotalFormData(form_data, slice) {
|
||||
return form_data;
|
||||
}
|
||||
|
||||
function mapStateToProps(state) {
|
||||
function mapStateToProps(state: ExploreRootState) {
|
||||
const {
|
||||
explore,
|
||||
charts,
|
||||
@@ -937,24 +1085,31 @@ function mapStateToProps(state) {
|
||||
const controlsBasedFormData = omit(
|
||||
getFormDataFromControls(controls),
|
||||
fieldsToOmit,
|
||||
);
|
||||
) as QueryFormData;
|
||||
const isDeckGLChart = explore.form_data?.viz_type === 'deck_multi';
|
||||
|
||||
const getDeckGLFormData = () => {
|
||||
const formData = { ...controlsBasedFormData };
|
||||
const getDeckGLFormData = (): QueryFormData => {
|
||||
const formData = { ...controlsBasedFormData } as QueryFormData & {
|
||||
layer_filter_scope?: JsonObject;
|
||||
filter_data_mapping?: JsonObject;
|
||||
};
|
||||
|
||||
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) {
|
||||
formData.filter_data_mapping = explore.form_data.filter_data_mapping;
|
||||
formData.filter_data_mapping = explore.form_data
|
||||
.filter_data_mapping as JsonObject;
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
@@ -972,7 +1127,7 @@ function mapStateToProps(state) {
|
||||
const ownColorScheme = explore.form_data?.own_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)) {
|
||||
dashboardId = undefined;
|
||||
}
|
||||
@@ -1001,7 +1156,7 @@ function mapStateToProps(state) {
|
||||
isDatasourceMetaLoading: explore.isDatasourceMetaLoading,
|
||||
datasource,
|
||||
datasource_type: datasource.type,
|
||||
datasourceId: datasource.datasource_id,
|
||||
datasourceId: datasource.id,
|
||||
dashboardId,
|
||||
colorScheme,
|
||||
ownColorScheme,
|
||||
@@ -1028,7 +1183,9 @@ function mapStateToProps(state) {
|
||||
ownState: dataMask[slice_id]?.ownState,
|
||||
impressionId,
|
||||
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,
|
||||
metadata,
|
||||
saveAction: explore.saveAction,
|
||||
@@ -1036,7 +1193,7 @@ function mapStateToProps(state) {
|
||||
};
|
||||
}
|
||||
|
||||
function mapDispatchToProps(dispatch) {
|
||||
function mapDispatchToProps(dispatch: Dispatch): DispatchProps {
|
||||
const actions = {
|
||||
...exploreActions,
|
||||
...datasourcesActions,
|
||||
@@ -1045,11 +1202,18 @@ function mapDispatchToProps(dispatch) {
|
||||
...logActions,
|
||||
};
|
||||
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(
|
||||
mapStateToProps,
|
||||
mapDispatchToProps,
|
||||
)(withToasts(memo(ExploreViewContainer)));
|
||||
)(
|
||||
withToasts(memo(ExploreViewContainer)) as ComponentType<
|
||||
Record<string, never>
|
||||
>,
|
||||
);
|
||||
@@ -305,8 +305,14 @@ function mapDispatchToProps(
|
||||
dispatch: ThunkDispatch<any, undefined, AnyAction>,
|
||||
) {
|
||||
return {
|
||||
refreshAnnotationData: (annotationObj: Annotation) =>
|
||||
dispatch(runAnnotationQuery(annotationObj)),
|
||||
// Note: There's a type mismatch between the local Annotation interface
|
||||
// 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,
|
||||
})
|
||||
.then(({ json }) => {
|
||||
setResult(ensureIsArray(json.result));
|
||||
setResult(ensureIsArray(json.result) as Result[]);
|
||||
setIsLoading(false);
|
||||
setError(null);
|
||||
})
|
||||
|
||||
@@ -16,10 +16,25 @@
|
||||
* specific language governing permissions and limitations
|
||||
* 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 { 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 {
|
||||
Icons,
|
||||
@@ -28,7 +43,7 @@ import {
|
||||
Input,
|
||||
} from '@superset-ui/core/components';
|
||||
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 { DEFAULT_CSV_STREAMING_ROW_THRESHOLD } from 'src/constants';
|
||||
import { exportChart, getChartKey } from 'src/explore/exploreUtils';
|
||||
@@ -45,7 +60,13 @@ import {
|
||||
LOG_ACTIONS_CHART_DOWNLOAD_AS_XLS,
|
||||
} from 'src/logger/LogUtils';
|
||||
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 EmbedCodeContent from '../EmbedCodeContent';
|
||||
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 = (
|
||||
latestQueryFormData,
|
||||
canDownloadCSV,
|
||||
slice,
|
||||
onOpenInEditor,
|
||||
onOpenPropertiesModal,
|
||||
ownState,
|
||||
dashboards,
|
||||
showReportModal,
|
||||
setCurrentReportDeleting,
|
||||
...rest
|
||||
) => {
|
||||
latestQueryFormData: LatestQueryFormData,
|
||||
canDownloadCSV: boolean,
|
||||
slice: Slice | null | undefined,
|
||||
onOpenInEditor: (
|
||||
formData: LatestQueryFormData,
|
||||
openNewWindow?: boolean,
|
||||
) => void,
|
||||
onOpenPropertiesModal: () => void,
|
||||
ownState: OwnStateWithClientView | undefined,
|
||||
dashboards:
|
||||
| NonNullable<ExplorePageInitialData['metadata']>['dashboards']
|
||||
| undefined,
|
||||
showReportModal: () => void,
|
||||
setCurrentReportDeleting: Dispatch<SetStateAction<AlertObject | null>>,
|
||||
...rest: MenuProps[]
|
||||
): UseExploreAdditionalActionsMenuReturn => {
|
||||
const theme = useTheme();
|
||||
const { addDangerToast, addSuccessToast } = useToasts();
|
||||
const dispatch = useDispatch();
|
||||
@@ -140,10 +212,10 @@ export const useExploreAdditionalActionsMenu = (
|
||||
dashboardSearchTerm,
|
||||
300,
|
||||
);
|
||||
const chart = useSelector(
|
||||
state => state.charts?.[getChartKey(state.explore)],
|
||||
const chart = useSelector<ExploreState, ChartState | undefined>(state =>
|
||||
state.explore ? state.charts?.[getChartKey(state.explore)] : undefined,
|
||||
);
|
||||
const streamingThreshold = useSelector(
|
||||
const streamingThreshold = useSelector<ExploreState, number>(
|
||||
state =>
|
||||
state.common?.conf?.CSV_STREAMING_ROW_THRESHOLD ||
|
||||
DEFAULT_CSV_STREAMING_ROW_THRESHOLD,
|
||||
@@ -153,9 +225,9 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const [isStreamingModalVisible, setIsStreamingModalVisible] = useState(false);
|
||||
const {
|
||||
progress,
|
||||
isExporting,
|
||||
isExporting: _isExporting,
|
||||
startExport,
|
||||
cancelExport,
|
||||
cancelExport: _cancelExport,
|
||||
resetExport,
|
||||
retryExport,
|
||||
} = useStreamingExport({
|
||||
@@ -192,19 +264,24 @@ export const useExploreAdditionalActionsMenu = (
|
||||
searchTerm: debouncedDashboardSearchTerm,
|
||||
});
|
||||
|
||||
const showDashboardSearch = dashboards?.length > SEARCH_THRESHOLD;
|
||||
const showDashboardSearch = (dashboards?.length ?? 0) > SEARCH_THRESHOLD;
|
||||
const vizType = latestQueryFormData?.viz_type;
|
||||
const meta = vizType ? getChartMetadataRegistry().get(vizType) : undefined;
|
||||
|
||||
// Detect if the chart plugin exposes the export-current-view behavior
|
||||
const hasExportCurrentView = !!meta?.behaviors?.includes(
|
||||
'EXPORT_CURRENT_VIEW',
|
||||
'EXPORT_CURRENT_VIEW' as Behavior,
|
||||
);
|
||||
|
||||
const shareByEmail = useCallback(async () => {
|
||||
try {
|
||||
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) {
|
||||
throw new Error('Failed to generate permalink');
|
||||
}
|
||||
@@ -227,11 +304,12 @@ export const useExploreAdditionalActionsMenu = (
|
||||
|
||||
if (
|
||||
isTableViz &&
|
||||
queriesResponse?.length > 1 &&
|
||||
queriesResponse &&
|
||||
queriesResponse.length > 1 &&
|
||||
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;
|
||||
} else {
|
||||
actualRowCount = latestQueryFormData?.row_limit;
|
||||
@@ -241,7 +319,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
const shouldUseStreaming =
|
||||
actualRowCount && actualRowCount >= streamingThreshold;
|
||||
|
||||
let filename;
|
||||
let filename: string | undefined;
|
||||
if (shouldUseStreaming) {
|
||||
const now = new Date();
|
||||
const date = now.toISOString().slice(0, 10);
|
||||
@@ -254,18 +332,22 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
|
||||
return exportChart({
|
||||
formData: latestQueryFormData,
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'full',
|
||||
resultFormat: 'csv',
|
||||
onStartStreamingExport: shouldUseStreaming
|
||||
? exportParams => {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
});
|
||||
if (exportParams.url) {
|
||||
setIsStreamingModalVisible(true);
|
||||
startExport({
|
||||
...exportParams,
|
||||
url: exportParams.url,
|
||||
filename,
|
||||
expectedRows: actualRowCount,
|
||||
exportType: exportParams.exportType as 'csv' | 'xlsx',
|
||||
});
|
||||
}
|
||||
}
|
||||
: null,
|
||||
});
|
||||
@@ -283,7 +365,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData,
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'post_processed',
|
||||
resultFormat: 'csv',
|
||||
@@ -296,7 +378,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData,
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'json',
|
||||
@@ -309,7 +391,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
() =>
|
||||
canDownloadCSV
|
||||
? exportChart({
|
||||
formData: latestQueryFormData,
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'xlsx',
|
||||
@@ -320,11 +402,13 @@ export const useExploreAdditionalActionsMenu = (
|
||||
|
||||
const copyLink = useCallback(async () => {
|
||||
try {
|
||||
if (!latestQueryFormData) {
|
||||
throw new Error();
|
||||
if (!latestQueryFormData?.datasource) {
|
||||
throw new Error('No datasource available');
|
||||
}
|
||||
await copyTextToClipboard(async () => {
|
||||
const result = await getChartPermalink(latestQueryFormData);
|
||||
const result = await getChartPermalink(
|
||||
latestQueryFormData as Pick<QueryFormData, 'datasource'>,
|
||||
);
|
||||
if (!result?.url) {
|
||||
throw new Error('Failed to generate permalink');
|
||||
}
|
||||
@@ -337,9 +421,13 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}, [addDangerToast, addSuccessToast, latestQueryFormData]);
|
||||
|
||||
// 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;
|
||||
const esc = v => {
|
||||
const esc = (v: unknown): string => {
|
||||
if (v === null || v === undefined) return '';
|
||||
const s = String(v);
|
||||
const wrapped = /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
@@ -361,20 +449,29 @@ export const useExploreAdditionalActionsMenu = (
|
||||
};
|
||||
|
||||
// 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;
|
||||
|
||||
const norm = v => {
|
||||
const norm = (v: unknown): unknown => {
|
||||
if (v instanceof Date) return v.toISOString();
|
||||
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 v;
|
||||
};
|
||||
|
||||
const data = rows.map(r => {
|
||||
const out = {};
|
||||
const out: Record<string, unknown> = {};
|
||||
columns.forEach(c => {
|
||||
out[c.key] = norm(r[c.key]);
|
||||
});
|
||||
@@ -402,8 +499,12 @@ export const useExploreAdditionalActionsMenu = (
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
// NEW: Client-side XLSX for "Current View" (uses 'xlsx' already in deps)
|
||||
const downloadClientXLSX = async (rows, columns, filename) => {
|
||||
// Client-side XLSX for "Current View" (uses 'xlsx' already in deps)
|
||||
const downloadClientXLSX = async (
|
||||
rows: ClientViewRow[],
|
||||
columns: ClientViewColumn[],
|
||||
filename: string,
|
||||
) => {
|
||||
if (!rows?.length || !columns?.length) return;
|
||||
try {
|
||||
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
|
||||
const data = rows.map(r => {
|
||||
const o = {};
|
||||
const o: Record<string, unknown> = {};
|
||||
columns.forEach(c => {
|
||||
const v = r[c.key];
|
||||
o[c.label ?? c.key] =
|
||||
v && typeof v === 'object' && 'input' in v && 'formatter' in v
|
||||
? v.input instanceof Date
|
||||
? v.input.toISOString()
|
||||
: (v.input ?? v.value ?? '')
|
||||
: v instanceof Date
|
||||
? v.toISOString()
|
||||
: v;
|
||||
if (v && typeof v === 'object' && 'input' in v && 'formatter' in v) {
|
||||
const typedV = v as { input?: unknown; value?: unknown };
|
||||
o[c.label ?? c.key] =
|
||||
typedV.input instanceof Date
|
||||
? typedV.input.toISOString()
|
||||
: (typedV.input ?? typedV.value ?? '');
|
||||
} else if (v instanceof Date) {
|
||||
o[c.label ?? c.key] = v.toISOString();
|
||||
} else {
|
||||
o[c.label ?? c.key] = v;
|
||||
}
|
||||
});
|
||||
return o;
|
||||
});
|
||||
@@ -437,8 +541,8 @@ export const useExploreAdditionalActionsMenu = (
|
||||
ws['!cols'] = colWidths;
|
||||
|
||||
XLSX.writeFile(wb, `${filename || 'current_view'}.xlsx`);
|
||||
} catch (e) {
|
||||
// If xlsx isn’t available for some reason, fall back to CSV
|
||||
} catch {
|
||||
// If xlsx isn't available for some reason, fall back to CSV
|
||||
downloadClientCSV(rows, columns, filename || 'current_view');
|
||||
addDangerToast?.(
|
||||
t('Falling back to CSV; Excel export library not available.'),
|
||||
@@ -493,7 +597,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DASHBOARDS_ADDED_TO,
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('On dashboards'),
|
||||
children: dashboardsChildren,
|
||||
popupStyle: {
|
||||
@@ -503,12 +607,15 @@ export const useExploreAdditionalActionsMenu = (
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
menuItems.push({ type: 'divider' as const });
|
||||
|
||||
// Download submenu
|
||||
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(
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_TO_CSV,
|
||||
@@ -603,7 +710,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
key: MENU_KEYS.EXPORT_ALL_SCREENSHOT,
|
||||
label: t('Export screenshot (jpeg)'),
|
||||
icon: <Icons.FileImageOutlined />,
|
||||
onClick: e => {
|
||||
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
@@ -659,7 +766,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
);
|
||||
} else {
|
||||
exportChart({
|
||||
formData: latestQueryFormData,
|
||||
formData: latestQueryFormData as QueryFormData,
|
||||
ownState,
|
||||
resultType: 'results',
|
||||
resultFormat: 'csv',
|
||||
@@ -707,7 +814,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
key: MENU_KEYS.EXPORT_CURRENT_SCREENSHOT,
|
||||
label: t('Export screenshot (jpeg)'),
|
||||
icon: <Icons.FileImageOutlined />,
|
||||
onClick: e => {
|
||||
onClick: (e: { domEvent: React.MouseEvent | React.KeyboardEvent }) => {
|
||||
downloadAsImage(
|
||||
'.panel-body .chart-container',
|
||||
slice?.slice_name ?? t('New chart'),
|
||||
@@ -758,12 +865,12 @@ export const useExploreAdditionalActionsMenu = (
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.DATA_EXPORT_OPTIONS,
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Data Export Options'),
|
||||
children: [
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_ALL_DATA_GROUP,
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Export All Data'),
|
||||
children: allDataChildren,
|
||||
},
|
||||
@@ -771,7 +878,7 @@ export const useExploreAdditionalActionsMenu = (
|
||||
? [
|
||||
{
|
||||
key: MENU_KEYS.EXPORT_CURRENT_VIEW_GROUP,
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Export Current View'),
|
||||
children: currentViewChildren,
|
||||
},
|
||||
@@ -781,7 +888,11 @@ export const useExploreAdditionalActionsMenu = (
|
||||
});
|
||||
|
||||
// Share submenu
|
||||
const shareChildren = [
|
||||
const shareChildren: Array<{
|
||||
key: string;
|
||||
label: React.ReactNode;
|
||||
onClick: () => void;
|
||||
}> = [
|
||||
{
|
||||
key: MENU_KEYS.COPY_PERMALINK,
|
||||
label: t('Copy permalink to clipboard'),
|
||||
@@ -826,13 +937,13 @@ export const useExploreAdditionalActionsMenu = (
|
||||
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.SHARE_SUBMENU,
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Share'),
|
||||
children: shareChildren,
|
||||
});
|
||||
|
||||
// Divider
|
||||
menuItems.push({ type: 'divider' });
|
||||
menuItems.push({ type: 'divider' as const });
|
||||
|
||||
// Report menu item
|
||||
if (reportMenuItem) {
|
||||
@@ -849,7 +960,9 @@ export const useExploreAdditionalActionsMenu = (
|
||||
}
|
||||
modalTitle={t('View query')}
|
||||
modalBody={
|
||||
<ViewQueryModal latestQueryFormData={latestQueryFormData} />
|
||||
<ViewQueryModal
|
||||
latestQueryFormData={latestQueryFormData as QueryFormData}
|
||||
/>
|
||||
}
|
||||
draggable
|
||||
resizable
|
||||
@@ -864,8 +977,11 @@ export const useExploreAdditionalActionsMenu = (
|
||||
menuItems.push({
|
||||
key: MENU_KEYS.RUN_IN_SQL_LAB,
|
||||
label: t('Run in SQL Lab'),
|
||||
onClick: e => {
|
||||
onOpenInEditor(latestQueryFormData, e.domEvent?.metaKey);
|
||||
onClick: (e: { domEvent?: React.MouseEvent | React.KeyboardEvent }) => {
|
||||
onOpenInEditor(
|
||||
latestQueryFormData,
|
||||
!!(e.domEvent as React.MouseEvent | undefined)?.metaKey,
|
||||
);
|
||||
setIsDropdownVisible(false);
|
||||
},
|
||||
});
|
||||
@@ -28,15 +28,21 @@ import {
|
||||
} from 'src/explore/exploreUtils';
|
||||
import { DashboardStandaloneMode } from 'src/dashboard/util/constants';
|
||||
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
|
||||
describe('exploreUtils', () => {
|
||||
const { location } = window;
|
||||
const formData = {
|
||||
const formData: QueryFormData = {
|
||||
datasource: '1__table',
|
||||
viz_type: 'table',
|
||||
};
|
||||
function compareURI(uri1, uri2) {
|
||||
function compareURI(uri1: URI, uri2: URI): void {
|
||||
expect(uri1.toString()).toBe(uri2.toString());
|
||||
}
|
||||
|
||||
@@ -53,7 +59,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'http://superset.com',
|
||||
});
|
||||
compareURI(URI(url), URI('/explore/'));
|
||||
compareURI(URI(url!), URI('/explore/'));
|
||||
});
|
||||
test('generates proper json url', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -62,7 +68,7 @@ describe('exploreUtils', () => {
|
||||
force: false,
|
||||
curUrl: 'http://superset.com',
|
||||
});
|
||||
compareURI(URI(url), URI('/superset/explore_json/'));
|
||||
compareURI(URI(url!), URI('/superset/explore_json/'));
|
||||
});
|
||||
test('generates proper json forced url', () => {
|
||||
const url = getExploreUrl({
|
||||
@@ -72,7 +78,7 @@ describe('exploreUtils', () => {
|
||||
curUrl: 'superset.com',
|
||||
});
|
||||
compareURI(
|
||||
URI(url),
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ force: 'true' }),
|
||||
);
|
||||
});
|
||||
@@ -84,7 +90,7 @@ describe('exploreUtils', () => {
|
||||
curUrl: 'superset.com',
|
||||
});
|
||||
compareURI(
|
||||
URI(url),
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ csv: 'true' }),
|
||||
);
|
||||
});
|
||||
@@ -96,7 +102,7 @@ describe('exploreUtils', () => {
|
||||
curUrl: 'superset.com',
|
||||
});
|
||||
compareURI(
|
||||
URI(url),
|
||||
URI(url!),
|
||||
URI('/explore/').search({
|
||||
standalone: DashboardStandaloneMode.HideNav,
|
||||
}),
|
||||
@@ -110,7 +116,7 @@ describe('exploreUtils', () => {
|
||||
curUrl: 'superset.com?foo=bar',
|
||||
});
|
||||
compareURI(
|
||||
URI(url),
|
||||
URI(url!),
|
||||
URI('/superset/explore_json/').search({ foo: 'bar' }),
|
||||
);
|
||||
});
|
||||
@@ -122,7 +128,7 @@ describe('exploreUtils', () => {
|
||||
curUrl: 'superset.com?foo=bar',
|
||||
});
|
||||
compareURI(
|
||||
URI(url),
|
||||
URI(url!),
|
||||
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
|
||||
describe('domain sharding', () => {
|
||||
let stub;
|
||||
let stub: sinon.SinonStub;
|
||||
const availableDomains = [
|
||||
'http://localhost/',
|
||||
'domain1.com',
|
||||
@@ -199,7 +205,9 @@ describe('exploreUtils', () => {
|
||||
const v1RequestPayload = await buildV1ChartDataPayload({
|
||||
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', () => {
|
||||
beforeAll(() => {
|
||||
getChartMetadataRegistry()
|
||||
.registerValue('my_legacy_viz', { useLegacyApi: true })
|
||||
.registerValue('my_v1_viz', { useLegacyApi: false });
|
||||
.registerValue('my_legacy_viz', {
|
||||
useLegacyApi: true,
|
||||
} as unknown as ChartMetadata)
|
||||
.registerValue('my_v1_viz', {
|
||||
useLegacyApi: false,
|
||||
} as unknown as ChartMetadata);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
@@ -293,9 +305,7 @@ describe('exploreUtils', () => {
|
||||
const postFormSpy = jest.spyOn(SupersetClient, 'postForm');
|
||||
postFormSpy.mockImplementation(jest.fn());
|
||||
|
||||
exploreChart({
|
||||
formData: { ...formData, viz_type: 'my_custom_viz' },
|
||||
});
|
||||
exploreChart({ ...formData, viz_type: 'my_custom_viz' });
|
||||
expect(postFormSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -31,7 +31,7 @@ describe('Get ChartUri', () => {
|
||||
expect(
|
||||
getChartDataUri({
|
||||
path: '/path',
|
||||
qs: 'same-string',
|
||||
qs: { key: 'same-string' },
|
||||
allowDomainSharding: false,
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -46,7 +46,7 @@ describe('Get ChartUri', () => {
|
||||
port: '',
|
||||
preventInvalidHostname: false,
|
||||
protocol: 'http',
|
||||
query: 'same-string',
|
||||
query: 'key=same-string',
|
||||
urn: null,
|
||||
username: null,
|
||||
},
|
||||
@@ -58,7 +58,7 @@ describe('Get ChartUri', () => {
|
||||
expect(
|
||||
getChartDataUri({
|
||||
path: '/path-allowDomainSharding-true',
|
||||
qs: 'same-string-allowDomainSharding-true',
|
||||
qs: { key: 'allowDomainSharding-true' },
|
||||
allowDomainSharding: true,
|
||||
}),
|
||||
).toEqual({
|
||||
@@ -73,7 +73,7 @@ describe('Get ChartUri', () => {
|
||||
port: '',
|
||||
preventInvalidHostname: false,
|
||||
protocol: 'http',
|
||||
query: 'same-string-allowDomainSharding-true',
|
||||
query: 'key=allowDomainSharding-true',
|
||||
urn: null,
|
||||
username: null,
|
||||
},
|
||||
|
||||
@@ -16,8 +16,11 @@
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { Slice } from 'src/types/Chart';
|
||||
import { getChartKey } from '.';
|
||||
|
||||
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,
|
||||
requestParams: {},
|
||||
allowDomainSharding: false,
|
||||
method: 'POST',
|
||||
method: 'POST' as const,
|
||||
});
|
||||
|
||||
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(null, params.operator, params.comparator)).toBe(
|
||||
'',
|
||||
);
|
||||
expect(
|
||||
getSimpleSQLExpression(undefined, params.operator, params.comparator),
|
||||
).toBe('');
|
||||
@@ -56,9 +53,6 @@ test('Should return subject if operator is falsy', () => {
|
||||
expect(getSimpleSQLExpression(params.subject, '', params.comparator)).toBe(
|
||||
params.subject,
|
||||
);
|
||||
expect(getSimpleSQLExpression(params.subject, null, params.comparator)).toBe(
|
||||
params.subject,
|
||||
);
|
||||
expect(
|
||||
getSimpleSQLExpression(params.subject, undefined, params.comparator),
|
||||
).toBe(params.subject);
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* under the License.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useCallback, useEffect, DependencyList } from 'react';
|
||||
/* eslint camelcase: 0 */
|
||||
import URI from 'urijs';
|
||||
import {
|
||||
@@ -25,7 +25,10 @@ import {
|
||||
ensureIsArray,
|
||||
getChartBuildQueryRegistry,
|
||||
getChartMetadataRegistry,
|
||||
QueryFormData,
|
||||
SupersetClient,
|
||||
SetDataMaskHook,
|
||||
JsonObject,
|
||||
} from '@superset-ui/core';
|
||||
import { availableDomains } from 'src/utils/hostNamesConfig';
|
||||
import { safeStringify } from 'src/utils/safeStringify';
|
||||
@@ -35,18 +38,82 @@ import { URL_PARAMS } from 'src/constants';
|
||||
import {
|
||||
DISABLE_INPUT_OPERATORS,
|
||||
MULTI_OPERATORS,
|
||||
Operators,
|
||||
OPERATOR_ENUM_TO_OPERATOR_TYPE,
|
||||
UNSAVED_CHART_ID,
|
||||
} from 'src/explore/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;
|
||||
return slice?.slice_id ?? form_data?.slice_id ?? UNSAVED_CHART_ID;
|
||||
}
|
||||
|
||||
let requestCounter = 0;
|
||||
export function getHostName(allowDomainSharding = false) {
|
||||
export function getHostName(allowDomainSharding = false): string {
|
||||
let currentIndex = 0;
|
||||
if (allowDomainSharding) {
|
||||
currentIndex = requestCounter % availableDomains.length;
|
||||
@@ -63,7 +130,10 @@ export function getHostName(allowDomainSharding = false) {
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
@@ -78,7 +148,10 @@ export function getAnnotationJsonUrl(slice_id, force) {
|
||||
.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
|
||||
const uri = ['full', 'json', 'csv', 'query', 'results', 'samples'].includes(
|
||||
endpointType,
|
||||
@@ -89,14 +162,14 @@ export function getURIDirectory(endpointType = 'base', includeAppRoot = true) {
|
||||
}
|
||||
|
||||
export function mountExploreUrl(
|
||||
endpointType,
|
||||
extraSearch = {},
|
||||
endpointType: EndpointType | string,
|
||||
extraSearch: Record<string, string | number> = {},
|
||||
force = false,
|
||||
includeAppRoot = true,
|
||||
) {
|
||||
): string {
|
||||
const uri = new URI('/');
|
||||
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 => {
|
||||
search[key] = extraSearch[key];
|
||||
});
|
||||
@@ -109,7 +182,11 @@ export function mountExploreUrl(
|
||||
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,
|
||||
// but can be specified with curUrl (used for unit tests to spoof
|
||||
// the window.location).
|
||||
@@ -138,7 +215,7 @@ export function getExploreUrl({
|
||||
requestParams = {},
|
||||
allowDomainSharding = false,
|
||||
method = 'POST',
|
||||
}) {
|
||||
}: GetExploreUrlParams): string | null {
|
||||
if (!formData.datasource) {
|
||||
return null;
|
||||
}
|
||||
@@ -158,10 +235,10 @@ export function getExploreUrl({
|
||||
const directory = getURIDirectory(endpointType);
|
||||
|
||||
// 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;
|
||||
if (slice_id) {
|
||||
const form_data = { slice_id };
|
||||
const form_data: Record<string, unknown> = { slice_id };
|
||||
if (method === 'GET') {
|
||||
form_data.viz_type = viz_type;
|
||||
if (extra_filters && extra_filters.length) {
|
||||
@@ -194,7 +271,7 @@ export function getExploreUrl({
|
||||
const paramNames = Object.keys(requestParams);
|
||||
if (paramNames.length) {
|
||||
paramNames.forEach(name => {
|
||||
if (requestParams.hasOwnProperty(name)) {
|
||||
if (Object.hasOwn(requestParams, name)) {
|
||||
search[name] = requestParams[name];
|
||||
}
|
||||
});
|
||||
@@ -202,8 +279,12 @@ export function getExploreUrl({
|
||||
return uri.search(search).directory(directory).toString();
|
||||
}
|
||||
|
||||
export const getQuerySettings = formData => {
|
||||
const vizMetadata = getChartMetadataRegistry().get(formData.viz_type);
|
||||
export const getQuerySettings = (
|
||||
formData: Partial<QueryFormData>,
|
||||
): [boolean, string] => {
|
||||
const vizMetadata = formData.viz_type
|
||||
? getChartMetadataRegistry().get(formData.viz_type)
|
||||
: undefined;
|
||||
return [
|
||||
vizMetadata?.useLegacyApi ?? false,
|
||||
vizMetadata?.parseMethod ?? 'json-bigint',
|
||||
@@ -217,33 +298,44 @@ export const buildV1ChartDataPayload = async ({
|
||||
resultType,
|
||||
setDataMask,
|
||||
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 =
|
||||
getChartBuildQueryRegistry().get(formData.viz_type) ??
|
||||
(buildQueryformData =>
|
||||
buildQueryContext(buildQueryformData, baseQueryObject => [
|
||||
{
|
||||
...baseQueryObject,
|
||||
},
|
||||
]));
|
||||
(registryResult ? await registryResult : undefined) ?? defaultBuildQuery;
|
||||
return buildQuery(
|
||||
{
|
||||
...formData,
|
||||
force,
|
||||
result_format: resultFormat,
|
||||
result_type: resultType,
|
||||
},
|
||||
} as QueryFormData,
|
||||
{
|
||||
ownState,
|
||||
hooks: {
|
||||
setDataMask,
|
||||
setDataMask: setDataMask ?? (() => {}),
|
||||
setCachedChanges: () => {},
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const getLegacyEndpointType = ({ resultType, resultFormat }) =>
|
||||
resultFormat === 'csv' ? resultFormat : resultType;
|
||||
export const getLegacyEndpointType = ({
|
||||
resultType,
|
||||
resultFormat,
|
||||
}: {
|
||||
resultType: string;
|
||||
resultFormat: string;
|
||||
}): string => (resultFormat === 'csv' ? resultFormat : resultType);
|
||||
|
||||
export const exportChart = async ({
|
||||
formData,
|
||||
@@ -252,10 +344,10 @@ export const exportChart = async ({
|
||||
force = false,
|
||||
ownState = {},
|
||||
onStartStreamingExport = null,
|
||||
}) => {
|
||||
let url;
|
||||
let payload;
|
||||
const [useLegacyApi, parseMethod] = getQuerySettings(formData);
|
||||
}: ExportChartParams): Promise<void> => {
|
||||
let url: string | null;
|
||||
let payload: QueryFormData | ReturnType<typeof buildQueryContext>;
|
||||
const [useLegacyApi] = getQuerySettings(formData);
|
||||
if (useLegacyApi) {
|
||||
const endpointType = getLegacyEndpointType({ resultFormat, resultType });
|
||||
url = getExploreUrl({
|
||||
@@ -272,7 +364,6 @@ export const exportChart = async ({
|
||||
resultFormat,
|
||||
resultType,
|
||||
ownState,
|
||||
parseMethod,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -286,21 +377,32 @@ export const exportChart = async ({
|
||||
});
|
||||
} else {
|
||||
// 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({
|
||||
formData,
|
||||
endpointType: 'base',
|
||||
allowDomainSharding: false,
|
||||
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
|
||||
const callback = useCallback(effect, deps);
|
||||
|
||||
@@ -315,17 +417,22 @@ export const useDebouncedEffect = (effect, delay, deps) => {
|
||||
}, [callback, delay]);
|
||||
};
|
||||
|
||||
export const getSimpleSQLExpression = (subject, operator, comparator) => {
|
||||
const isMulti =
|
||||
[...MULTI_OPERATORS]
|
||||
.map(op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation)
|
||||
.indexOf(operator) >= 0;
|
||||
export const getSimpleSQLExpression = (
|
||||
subject?: string | SubjectWithColumnName,
|
||||
operator?: string,
|
||||
comparator?: ComparatorValue | ComparatorValue[],
|
||||
): 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 =
|
||||
DISABLE_INPUT_OPERATORS.map(
|
||||
op => OPERATOR_ENUM_TO_OPERATOR_TYPE[op].operation,
|
||||
).indexOf(operator) === -1;
|
||||
disableInputOperatorValues.indexOf(operator ?? '') === -1;
|
||||
// If returned value is an object after changing dataset
|
||||
let expression =
|
||||
let expression: string =
|
||||
typeof subject === 'object'
|
||||
? (subject?.column_name ?? '')
|
||||
: (subject ?? '');
|
||||
@@ -351,6 +458,8 @@ export const getSimpleSQLExpression = (subject, operator, comparator) => {
|
||||
return expression;
|
||||
};
|
||||
|
||||
export function formatSelectOptions(options) {
|
||||
export function formatSelectOptions<T extends { toString(): string }>(
|
||||
options: T[],
|
||||
): [T, string][] {
|
||||
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.
|
||||
*/
|
||||
|
||||
import exploreReducer from './exploreReducer';
|
||||
import exploreReducer, { ExploreState } from './exploreReducer';
|
||||
import { setStashFormData } from '../actions/exploreActions';
|
||||
import { QueryFormData } from '@superset-ui/core';
|
||||
|
||||
test('reset hiddenFormData on SET_STASH_FORM_DATA', () => {
|
||||
const initialState = {
|
||||
form_data: { a: 3, c: 4 },
|
||||
const initialState: ExploreState = {
|
||||
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);
|
||||
expect(newState.form_data).toEqual({});
|
||||
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);
|
||||
expect(newState2.form_data).toEqual({ c: 4 });
|
||||
expect(newState2.hiddenFormData).toEqual({ a: 3 });
|
||||
});
|
||||
|
||||
test('skips updates when the field is already updated on SET_STASH_FORM_DATA', () => {
|
||||
const initialState = {
|
||||
form_data: { a: 3, c: 4 },
|
||||
hiddenFormData: { b: 2 },
|
||||
const initialState: ExploreState = {
|
||||
form_data: { a: 3, c: 4 } as unknown as QueryFormData,
|
||||
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);
|
||||
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[];
|
||||
created_by?: string;
|
||||
changed_by?: string;
|
||||
color_namespace?: string;
|
||||
dashboards?: {
|
||||
id: number;
|
||||
dashboard_title: string;
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
fetchUISpecificReport,
|
||||
toggleActive,
|
||||
} from 'src/features/reports/ReportModal/actions';
|
||||
import { ReportObject } from 'src/features/reports/types';
|
||||
import { MenuItemWithCheckboxContainer } from 'src/explore/components/useExploreAdditionalActionsMenu/index';
|
||||
|
||||
const extensionsRegistry = getExtensionsRegistry();
|
||||
@@ -116,7 +117,7 @@ export const useHeaderReportMenuItems = ({
|
||||
|
||||
// Fetch report data when needed
|
||||
useEffect(() => {
|
||||
if (shouldFetch) {
|
||||
if (shouldFetch && resourceId) {
|
||||
dispatch(
|
||||
fetchUISpecificReport({
|
||||
userId: user.userId,
|
||||
@@ -137,8 +138,8 @@ export const useHeaderReportMenuItems = ({
|
||||
const handleShowModal = () => showReportModal();
|
||||
const handleDeleteReport = () => setCurrentReportDeleting(report);
|
||||
const handleToggleActive = () => {
|
||||
if (report?.id) {
|
||||
dispatch(toggleActive(report, !report.active));
|
||||
if (report?.id && report.active !== undefined) {
|
||||
dispatch(toggleActive(report as unknown as ReportObject, !report.active));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -146,7 +147,7 @@ export const useHeaderReportMenuItems = ({
|
||||
if (!report || !report.id) {
|
||||
return {
|
||||
key: 'email-report-setup',
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Manage email report'),
|
||||
children: [
|
||||
{
|
||||
@@ -168,7 +169,7 @@ export const useHeaderReportMenuItems = ({
|
||||
// If report exists, show management options
|
||||
return {
|
||||
key: 'email-report-manage',
|
||||
type: 'submenu',
|
||||
type: 'submenu' as const,
|
||||
label: t('Manage email report'),
|
||||
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 });
|
||||
try {
|
||||
if (isEditMode) {
|
||||
if (isEditMode && currentReport.id) {
|
||||
await dispatch(
|
||||
editReport(currentReport.id, newReportValues as ReportObject),
|
||||
);
|
||||
} else {
|
||||
} else if (!isEditMode) {
|
||||
await dispatch(addReport(newReportValues as ReportObject));
|
||||
}
|
||||
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
|
||||
* under the License.
|
||||
*/
|
||||
import sinon from 'sinon';
|
||||
import sinon, { SinonSpy, SinonStub } from 'sinon';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import logger from 'src/middleware/loggerMiddleware';
|
||||
import { LOG_EVENT } from 'src/logger/actions';
|
||||
@@ -24,11 +24,21 @@ import {
|
||||
LOG_ACTIONS_LOAD_CHART,
|
||||
LOG_ACTIONS_SPA_NAVIGATION,
|
||||
} 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
|
||||
describe('logger middleware', () => {
|
||||
const dashboardId = 123;
|
||||
const next = sinon.spy();
|
||||
const next: SinonSpy = sinon.spy();
|
||||
// Mock store with minimal state needed for tests
|
||||
const mockStore = {
|
||||
getState: () => ({
|
||||
dashboardInfo: {
|
||||
@@ -36,8 +46,9 @@ describe('logger middleware', () => {
|
||||
},
|
||||
impressionId: 'impression_id',
|
||||
}),
|
||||
dispatch: ((action: unknown) => action) as Dispatch,
|
||||
};
|
||||
const action = {
|
||||
const action: LogEventAction = {
|
||||
type: LOG_EVENT,
|
||||
payload: {
|
||||
eventName: LOG_ACTIONS_LOAD_CHART,
|
||||
@@ -52,7 +63,7 @@ describe('logger middleware', () => {
|
||||
useFakeTimers: true,
|
||||
});
|
||||
|
||||
let postStub;
|
||||
let postStub: SinonStub;
|
||||
beforeEach(() => {
|
||||
postStub = sinon.stub(SupersetClient, 'post');
|
||||
});
|
||||
@@ -69,61 +80,72 @@ describe('logger middleware', () => {
|
||||
some: 'data',
|
||||
},
|
||||
};
|
||||
logger(mockStore)(next)(action1);
|
||||
(logger as Function)(mockStore)(next)(action1);
|
||||
expect(next.callCount).toBe(1);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
timeSandbox.clock.tick(2000);
|
||||
expect(SupersetClient.post.callCount).toBe(1);
|
||||
expect(SupersetClient.post.getCall(0).args[0].endpoint).toMatch(
|
||||
'/superset/log/',
|
||||
);
|
||||
expect(postStub.callCount).toBe(1);
|
||||
expect(postStub.getCall(0).args[0].endpoint).toMatch('/superset/log/');
|
||||
});
|
||||
|
||||
test('should include ts, start_offset, event_name, impression_id, source, and source_id in every event', () => {
|
||||
const fetchLog = logger(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(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,
|
||||
// Set window.location to include /dashboard/ so the middleware adds dashboard context
|
||||
const originalHref = window.location.href;
|
||||
Object.defineProperty(window, 'location', {
|
||||
value: { href: `http://localhost/dashboard/${dashboardId}/` },
|
||||
writable: true,
|
||||
});
|
||||
|
||||
expect(typeof events[0].ts).toBe('number');
|
||||
expect(typeof events[0].start_offset).toBe('number');
|
||||
try {
|
||||
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', () => {
|
||||
logger(mockStore)(next)(action);
|
||||
logger(mockStore)(next)(action);
|
||||
logger(mockStore)(next)(action);
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
timeSandbox.clock.tick(2000);
|
||||
|
||||
expect(SupersetClient.post.callCount).toBe(1);
|
||||
expect(
|
||||
SupersetClient.post.getCall(0).args[0].postPayload.events,
|
||||
).toHaveLength(3);
|
||||
expect(postStub.callCount).toBe(1);
|
||||
expect(postStub.getCall(0).args[0].postPayload.events).toHaveLength(3);
|
||||
});
|
||||
|
||||
test('should use navigator.sendBeacon if it exists', () => {
|
||||
@@ -133,7 +155,7 @@ describe('logger middleware', () => {
|
||||
value: beaconMock,
|
||||
});
|
||||
|
||||
logger(mockStore)(next)(action);
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
expect(beaconMock.mock.calls.length).toBe(0);
|
||||
timeSandbox.clock.tick(2000);
|
||||
|
||||
@@ -150,7 +172,7 @@ describe('logger middleware', () => {
|
||||
});
|
||||
SupersetClient.configure({ guestToken: 'token' });
|
||||
|
||||
logger(mockStore)(next)(action);
|
||||
(logger as Function)(mockStore)(next)(action);
|
||||
expect(beaconMock.mock.calls.length).toBe(0);
|
||||
timeSandbox.clock.tick(2000);
|
||||
expect(beaconMock.mock.calls.length).toBe(1);
|
||||
@@ -20,6 +20,7 @@
|
||||
/* eslint prefer-const: 2 */
|
||||
import { nanoid } from 'nanoid';
|
||||
import { SupersetClient } from '@superset-ui/core';
|
||||
import type { Middleware, Dispatch, Action } from 'redux';
|
||||
|
||||
import { safeStringify } from '../utils/safeStringify';
|
||||
import { LOG_EVENT } from '../logger/actions';
|
||||
@@ -29,15 +30,74 @@ import {
|
||||
} from '../logger/LogUtils';
|
||||
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
|
||||
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 sendBeacon = events => {
|
||||
|
||||
const sendBeacon = (events: LogEventData[]): void => {
|
||||
if (events.length <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
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
|
||||
if (source === 'dashboard') {
|
||||
endpoint += `&dashboard_id=${source_id}`;
|
||||
@@ -50,7 +110,7 @@ const sendBeacon = events => {
|
||||
formData.append('events', safeStringify(events));
|
||||
if (SupersetClient.getGuestToken()) {
|
||||
// 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);
|
||||
} else {
|
||||
@@ -65,27 +125,37 @@ const sendBeacon = events => {
|
||||
// beacon API has data size limit = 2^16.
|
||||
// assume avg each log entry has 2^6 characters
|
||||
const MAX_EVENTS_PER_REQUEST = 1024;
|
||||
const logMessageQueue = new DebouncedMessageQueue({
|
||||
const logMessageQueue = new DebouncedMessageQueue<LogEventData>({
|
||||
callback: sendBeacon,
|
||||
sizeThreshold: MAX_EVENTS_PER_REQUEST,
|
||||
delayThreshold: 1000,
|
||||
});
|
||||
let lastEventId = 0;
|
||||
const loggerMiddleware = store => next => {
|
||||
let navPath;
|
||||
return action => {
|
||||
if (action.type !== LOG_EVENT) {
|
||||
|
||||
let lastEventId: string | number = 0;
|
||||
|
||||
const loggerMiddleware: Middleware<
|
||||
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);
|
||||
}
|
||||
|
||||
const logAction = action as LogEventAction;
|
||||
const { dashboardInfo, explore, impressionId, dashboardLayout, sqlLab } =
|
||||
store.getState();
|
||||
let logMetadata = {
|
||||
let logMetadata: LogEventData = {
|
||||
impression_id: impressionId,
|
||||
version: 'v2',
|
||||
};
|
||||
const { eventName } = action.payload;
|
||||
let { eventData = {} } = action.payload;
|
||||
const { eventName } = logAction.payload;
|
||||
let eventData: LogEventData = logAction.payload.eventData || {};
|
||||
|
||||
if (eventName === LOG_ACTIONS_SPA_NAVIGATION) {
|
||||
navPath = eventData.path;
|
||||
@@ -107,7 +177,7 @@ const loggerMiddleware = store => next => {
|
||||
...logMetadata,
|
||||
};
|
||||
} else if (path?.includes('/sqllab/')) {
|
||||
const editor = sqlLab.queryEditors.find(
|
||||
const editor = sqlLab?.queryEditors.find(
|
||||
({ id }) => id === sqlLab.tabHistory.slice(-1)[0],
|
||||
);
|
||||
logMetadata = {
|
||||
@@ -152,6 +222,5 @@ const loggerMiddleware = store => next => {
|
||||
logMessageQueue.append(eventData);
|
||||
return eventData;
|
||||
};
|
||||
};
|
||||
|
||||
export default loggerMiddleware;
|
||||
@@ -23,6 +23,8 @@ import {
|
||||
NULL_STRING,
|
||||
TRUE_STRING,
|
||||
FALSE_STRING,
|
||||
TabularDataRow,
|
||||
ColumnDefinition,
|
||||
} from 'src/utils/common';
|
||||
|
||||
// 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
|
||||
describe('prepareCopyToClipboardTabularData', () => {
|
||||
test('converts empty array', () => {
|
||||
const data = [];
|
||||
const columns = [];
|
||||
const data: TabularDataRow[] = [];
|
||||
const columns: string[] = [];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual('');
|
||||
});
|
||||
test('converts non empty array', () => {
|
||||
const data = [
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
const columns = ['column1', 'column2', 'column3'];
|
||||
const columns: string[] = ['column1', 'column2', 'column3'];
|
||||
expect(prepareCopyToClipboardTabularData(data, columns)).toEqual(
|
||||
'column1\tcolumn2\tcolumn3\nlorem\tipsum\t\ndolor\tsit\tamet\n',
|
||||
);
|
||||
});
|
||||
test('includes 0 values and handle column objects', () => {
|
||||
const data = [
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 0, column2: 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(
|
||||
'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
|
||||
describe('applyFormattingToTabularData', () => {
|
||||
test('does not mutate empty array', () => {
|
||||
const data = [];
|
||||
const data: TabularDataRow[] = [];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('does not mutate array without temporal column', () => {
|
||||
const data = [
|
||||
const data: TabularDataRow[] = [
|
||||
{ column1: 'lorem', column2: 'ipsum' },
|
||||
{ column1: 'dolor', column2: 'sit', column3: 'amet' },
|
||||
];
|
||||
expect(applyFormattingToTabularData(data, [])).toEqual(data);
|
||||
});
|
||||
test('changes formatting of columns selected for formatting', () => {
|
||||
const originalData = [
|
||||
const originalData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
@@ -118,8 +124,8 @@ describe('utils/common', () => {
|
||||
column3: 1518566400000,
|
||||
},
|
||||
];
|
||||
const timeFormattedColumns = ['__timestamp', 'column3'];
|
||||
const expectedData = [
|
||||
const timeFormattedColumns: string[] = ['__timestamp', 'column3'];
|
||||
const expectedData: TabularDataRow[] = [
|
||||
{
|
||||
__timestamp: null,
|
||||
column1: 'lorem',
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
getTimeFormatter,
|
||||
TimeFormats,
|
||||
ensureIsArray,
|
||||
JsonObject,
|
||||
} from '@superset-ui/core';
|
||||
|
||||
// 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);
|
||||
|
||||
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({
|
||||
endpoint: '/kv/store/',
|
||||
postPayload: { data: query },
|
||||
@@ -47,7 +63,7 @@ export function storeQuery(query) {
|
||||
});
|
||||
}
|
||||
|
||||
export function optionLabel(opt) {
|
||||
export function optionLabel(opt: OptionValue): string {
|
||||
if (opt === null) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
@@ -60,34 +76,42 @@ export function optionLabel(opt) {
|
||||
if (opt === false) {
|
||||
return FALSE_STRING;
|
||||
}
|
||||
if (typeof opt !== 'string' && opt.toString) {
|
||||
if (typeof opt !== 'string' && typeof opt === 'number') {
|
||||
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) {
|
||||
return NULL_STRING;
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
|
||||
export function optionFromValue(opt) {
|
||||
export function optionFromValue(opt: OptionValue): OptionItem {
|
||||
// From a list of options, handles special values & labels
|
||||
return { value: optionValue(opt), label: optionLabel(opt) };
|
||||
}
|
||||
|
||||
function getColumnName(column) {
|
||||
return column.name || column;
|
||||
function getColumnName(column: string | ColumnDefinition): string {
|
||||
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
|
||||
? `${columns.map(getColumnName).join('\t')}\n`
|
||||
: '';
|
||||
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) {
|
||||
// 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.
|
||||
@@ -103,7 +127,10 @@ export function prepareCopyToClipboardTabularData(data, columns) {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
||||
export function applyFormattingToTabularData(
|
||||
data: TabularDataRow[],
|
||||
timeFormattedColumns: string | string[],
|
||||
): TabularDataRow[] {
|
||||
if (
|
||||
!data ||
|
||||
data.length === 0 ||
|
||||
@@ -115,19 +142,22 @@ export function applyFormattingToTabularData(data, timeFormattedColumns) {
|
||||
return data.map(row => ({
|
||||
...row,
|
||||
/* eslint-disable no-underscore-dangle */
|
||||
...timeFormattedColumns.reduce((acc, colName) => {
|
||||
if (row[colName] !== null && row[colName] !== undefined) {
|
||||
acc[colName] = DATETIME_FORMATTER(row[colName]);
|
||||
}
|
||||
return acc;
|
||||
}, {}),
|
||||
...ensureIsArray(timeFormattedColumns).reduce(
|
||||
(acc: Record<string, string>, colName: string) => {
|
||||
if (row[colName] !== null && row[colName] !== undefined) {
|
||||
acc[colName] = DATETIME_FORMATTER(row[colName] as Date | number);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
),
|
||||
}));
|
||||
}
|
||||
|
||||
export const noOp = () => undefined;
|
||||
export const noOp = (): undefined => undefined;
|
||||
|
||||
// Detects the user's OS through the browser
|
||||
export const detectOS = () => {
|
||||
export const detectOS = (): OSType => {
|
||||
const { appVersion } = navigator;
|
||||
|
||||
// Leveraging this condition because of stackOverflow
|
||||
@@ -140,8 +170,8 @@ export const detectOS = () => {
|
||||
return 'Unknown OS';
|
||||
};
|
||||
|
||||
export const isSafari = () => {
|
||||
export const isSafari = (): boolean => {
|
||||
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